diff --git a/coder-sdk/interface.go b/coder-sdk/interface.go index e439ca32..dafb2114 100644 --- a/coder-sdk/interface.go +++ b/coder-sdk/interface.go @@ -241,4 +241,13 @@ type Client interface { // SetPolicyTemplate sets the workspace policy template SetPolicyTemplate(ctx context.Context, templateID string, templateScope TemplateScope, dryRun bool) (*SetPolicyTemplateResponse, error) + + // satellites fetches all satellitess known to the Coder control plane. + Satellites(ctx context.Context) ([]Satellite, error) + + // CreateSatellite creates a new satellite entity. + CreateSatellite(ctx context.Context, req CreateSatelliteReq) (*Satellite, error) + + // DeleteSatelliteByID deletes a satellite entity from the Coder control plane. + DeleteSatelliteByID(ctx context.Context, id string) error } diff --git a/coder-sdk/satellite.go b/coder-sdk/satellite.go new file mode 100644 index 00000000..975ee32d --- /dev/null +++ b/coder-sdk/satellite.go @@ -0,0 +1,51 @@ +package coder + +import ( + "context" + "net/http" +) + +type Satellite struct { + ID string `json:"id"` + Name string `json:"name"` + Fingerprint string `json:"fingerprint"` +} + +type satellites struct { + Data []Satellite `json:"data"` +} + +type createSatelliteResponse struct { + Data Satellite `json:"data"` +} + +// Satellites fetches all satellitess known to the Coder control plane. +func (c *DefaultClient) Satellites(ctx context.Context) ([]Satellite, error) { + var res satellites + err := c.requestBody(ctx, http.MethodGet, "/api/private/satellites", nil, &res) + if err != nil { + return nil, err + } + return res.Data, nil +} + +// CreateSatelliteReq defines the request parameters for creating a new satellite entity. +type CreateSatelliteReq struct { + Name string `json:"name"` + PublicKey string `json:"public_key"` +} + +// CreateSatellite creates a new satellite entity. +func (c *DefaultClient) CreateSatellite(ctx context.Context, req CreateSatelliteReq) (*Satellite, error) { + var res createSatelliteResponse + err := c.requestBody(ctx, http.MethodPost, "/api/private/satellites", req, &res) + if err != nil { + return nil, err + } + return &res.Data, nil +} + +// DeleteSatelliteByID deletes a satellite entity from the Coder control plane. +func (c *DefaultClient) DeleteSatelliteByID(ctx context.Context, id string) error { + return c.requestBody(ctx, http.MethodDelete, "/api/private/satellites/"+id, nil, nil) +} diff --git a/docs/coder.md b/docs/coder.md index ab6254e8..17e7fa7f 100644 --- a/docs/coder.md +++ b/docs/coder.md @@ -16,6 +16,7 @@ coder provides a CLI for working with an existing Coder installation * [coder images](coder_images.md) - Manage Coder images * [coder login](coder_login.md) - Authenticate this client for future operations * [coder logout](coder_logout.md) - Remove local authentication credentials if any exist +* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments * [coder ssh](coder_ssh.md) - Enter a shell of execute a command over SSH into a Coder workspace * [coder sync](coder_sync.md) - Establish a one way directory sync to a Coder workspace * [coder tokens](coder_tokens.md) - manage Coder API tokens for the active user diff --git a/docs/coder_satellites.md b/docs/coder_satellites.md new file mode 100644 index 00000000..2eaac5b9 --- /dev/null +++ b/docs/coder_satellites.md @@ -0,0 +1,27 @@ +## coder satellites + +Interact with Coder satellite deployments + +### Synopsis + +Perform operations on the Coder satellites for the platform. + +### Options + +``` + -h, --help help for satellites +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder](coder.md) - coder provides a CLI for working with an existing Coder installation +* [coder satellites create](coder_satellites_create.md) - create a new satellite. +* [coder satellites ls](coder_satellites_ls.md) - list satellites. +* [coder satellites rm](coder_satellites_rm.md) - remove a satellite. + diff --git a/docs/coder_satellites_create.md b/docs/coder_satellites_create.md new file mode 100644 index 00000000..9ab18362 --- /dev/null +++ b/docs/coder_satellites_create.md @@ -0,0 +1,36 @@ +## coder satellites create + +create a new satellite. + +### Synopsis + +Create a new Coder satellite. + +``` +coder satellites create [name] [satellite_access_url] [flags] +``` + +### Examples + +``` +# create a new satellite + +coder satellites create eu-west https://eu-west.coder.com +``` + +### Options + +``` + -h, --help help for create +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments + diff --git a/docs/coder_satellites_ls.md b/docs/coder_satellites_ls.md new file mode 100644 index 00000000..d2153685 --- /dev/null +++ b/docs/coder_satellites_ls.md @@ -0,0 +1,35 @@ +## coder satellites ls + +list satellites. + +### Synopsis + +List all Coder workspace satellites. + +``` +coder satellites ls [flags] +``` + +### Examples + +``` +# list satellites +coder satellites ls +``` + +### Options + +``` + -h, --help help for ls +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments + diff --git a/docs/coder_satellites_rm.md b/docs/coder_satellites_rm.md new file mode 100644 index 00000000..44669f6f --- /dev/null +++ b/docs/coder_satellites_rm.md @@ -0,0 +1,35 @@ +## coder satellites rm + +remove a satellite. + +### Synopsis + +Remove an existing Coder satellite by name. + +``` +coder satellites rm [satellite_name] [flags] +``` + +### Examples + +``` +# remove an existing satellite by name +coder satellites rm my-satellite +``` + +### Options + +``` + -h, --help help for rm +``` + +### Options inherited from parent commands + +``` + -v, --verbose show verbose output +``` + +### SEE ALSO + +* [coder satellites](coder_satellites.md) - Interact with Coder satellite deployments + diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 38e5ae4f..4ad33209 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -40,6 +40,7 @@ func Make() *cobra.Command { genDocsCmd(app), agentCmd(), tunnelCmd(), + satellitesCmd(), ) app.PersistentFlags().BoolVarP(&verbose, "verbose", "v", false, "show verbose output") return app diff --git a/internal/cmd/satellites.go b/internal/cmd/satellites.go new file mode 100644 index 00000000..982451f7 --- /dev/null +++ b/internal/cmd/satellites.go @@ -0,0 +1,222 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "net/http" + "net/url" + "strings" + + "cdr.dev/coder-cli/internal/x/xcobra" + + "github.com/spf13/cobra" + "golang.org/x/xerrors" + + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/pkg/clog" + "cdr.dev/coder-cli/pkg/tablewriter" +) + +const ( + satelliteKeyPath = "/api/private/satellites/key" +) + +type satelliteKeyResponse struct { + Key string `json:"key"` + Fingerprint string `json:"fingerprint"` +} + +func satellitesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "satellites", + Short: "Interact with Coder satellite deployments", + Long: "Perform operations on the Coder satellites for the platform.", + } + + cmd.AddCommand( + createSatelliteCmd(), + listSatellitesCmd(), + deleteSatelliteCmd(), + ) + return cmd +} + +func createSatelliteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "create [name] [satellite_access_url]", + Args: xcobra.ExactArgs(2), + Short: "create a new satellite.", + Long: "Create a new Coder satellite.", + Example: `# create a new satellite + +coder satellites create eu-west https://eu-west.coder.com`, + RunE: func(cmd *cobra.Command, args []string) error { + var ( + ctx = cmd.Context() + name = args[0] + accessURL = args[1] + ) + + client, err := newClient(ctx, true) + if err != nil { + return xerrors.Errorf("making coder client: %w", err) + } + + sURL, err := url.Parse(accessURL) + if err != nil { + return xerrors.Errorf("parsing satellite access url: %w", err) + } + sURL.Path = satelliteKeyPath + + // Create the http request. + req, err := http.NewRequestWithContext(ctx, http.MethodGet, sURL.String(), nil) + if err != nil { + return xerrors.Errorf("create satellite request: %w", err) + } + res, err := http.DefaultClient.Do(req) + if err != nil { + return xerrors.Errorf("doing satellite request: %w", err) + } + defer func() { _ = res.Body.Close() }() + + if res.StatusCode > 299 { + return fmt.Errorf("unexpected status code %d: %+v", res.StatusCode, res) + } + + var keyRes satelliteKeyResponse + if err := json.NewDecoder(res.Body).Decode(&keyRes); err != nil { + return xerrors.Errorf("decode response body: %w", err) + } + + if keyRes.Key == "" { + return xerrors.New("key field empty in response") + } + if keyRes.Fingerprint == "" { + return xerrors.New("fingerprint field empty in response") + } + + fmt.Printf(`The following satellite will be created: +Name: %s + +Public Key: +%s + +Fingerprint: +%s + +Do you wish to continue? (y/n) +`, name, keyRes.Key, keyRes.Fingerprint) + err = getConfirmation() + if err != nil { + return err + } + + _, err = client.CreateSatellite(ctx, coder.CreateSatelliteReq{ + Name: name, + PublicKey: keyRes.Key, + }) + if err != nil { + return xerrors.Errorf("making create satellite request: %w", err) + } + + clog.LogSuccess(fmt.Sprintf("satellite %s successfully created", name)) + + return nil + }, + } + + return cmd +} + +func getConfirmation() error { + var response string + + _, err := fmt.Scanln(&response) + if err != nil { + return xerrors.Errorf("scan line: %w", err) + } + + response = strings.ToLower(strings.TrimSpace(response)) + if response != "y" && response != "yes" { + return xerrors.New("request canceled") + } + + return nil +} + +func listSatellitesCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ls", + Short: "list satellites.", + Long: "List all Coder workspace satellites.", + Example: `# list satellites +coder satellites ls`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + + client, err := newClient(ctx, true) + if err != nil { + return xerrors.Errorf("making coder client: %w", err) + } + + sats, err := client.Satellites(ctx) + if err != nil { + return xerrors.Errorf("get satellites request: %w", err) + } + + if len(sats) == 0 { + return xerrors.Errorf("no satellites found") + } + + err = tablewriter.WriteTable(cmd.OutOrStdout(), len(sats), func(i int) interface{} { + return sats[i] + }) + if err != nil { + return xerrors.Errorf("write table: %w", err) + } + + return nil + }, + } + return cmd +} + +func deleteSatelliteCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "rm [satellite_name]", + Args: xcobra.ExactArgs(1), + Short: "remove a satellite.", + Long: "Remove an existing Coder satellite by name.", + Example: `# remove an existing satellite by name +coder satellites rm my-satellite`, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + name := args[0] + + client, err := newClient(ctx, true) + if err != nil { + return err + } + + sats, err := client.Satellites(ctx) + if err != nil { + return xerrors.Errorf("get satellites request: %w", err) + } + + for _, sat := range sats { + if sat.Name == name { + err = client.DeleteSatelliteByID(ctx, sat.ID) + if err != nil { + return xerrors.Errorf("delete satellites request: %w", err) + } + clog.LogSuccess(fmt.Sprintf("satellite %s successfully deleted", name)) + + return nil + } + } + + return xerrors.Errorf("no satellite found by name '%s'", name) + }, + } + return cmd +}