From ad7814248267ce87930e757cc9a5dc6f5e625e1f Mon Sep 17 00:00:00 2001 From: Lars Lehtonen Date: Sat, 12 Sep 2020 19:32:20 -0700 Subject: [PATCH 1/2] coder-sdk: remove spurious JSON tag --- coder-sdk/error.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/coder-sdk/error.go b/coder-sdk/error.go index aa1adbd2..6573eb65 100644 --- a/coder-sdk/error.go +++ b/coder-sdk/error.go @@ -13,7 +13,7 @@ var ErrNotFound = xerrors.Errorf("resource not found") type apiError struct { Err struct { - Msg string `json:"msg,required"` + Msg string `json:"msg"` } `json:"error"` } From 6fb7c7bf63c2a03bf659646b3c537bb1d5ac081e Mon Sep 17 00:00:00 2001 From: "Guillaume J. Charmes" Date: Fri, 4 Sep 2020 11:36:06 -0400 Subject: [PATCH 2/2] Cleanup: - Scope errors when possible. - Fix nil pointers in unmarshal. - Align struct tags for readability. - Explicitly discard errors with a comment on why. - Import package order. - Fmt verb for types. - More comments. Signed-off-by: Guillaume J. Charmes --- ci/integration/setup_test.go | 2 +- cmd/coder/main.go | 18 ++-- coder-sdk/activity.go | 17 ++-- coder-sdk/client.go | 18 ++-- coder-sdk/devurl.go | 32 +++---- coder-sdk/env.go | 62 ++++++------- coder-sdk/error.go | 8 +- coder-sdk/image.go | 36 ++++---- coder-sdk/org.go | 8 +- coder-sdk/request.go | 60 +++++++------ coder-sdk/secrets.go | 37 +++++--- coder-sdk/users.go | 11 +-- coder-sdk/ws.go | 23 +++-- internal/activity/pusher.go | 18 ++-- internal/activity/writer.go | 11 +-- internal/cmd/auth.go | 19 ++-- internal/cmd/ceapi.go | 36 ++++---- internal/cmd/cmd.go | 11 ++- internal/cmd/configssh.go | 4 +- internal/cmd/secrets.go | 5 +- internal/cmd/shell.go | 106 ++++++++++++----------- internal/cmd/urls.go | 27 +++--- internal/cmd/users.go | 13 ++- internal/sync/sync.go | 95 ++++++++++---------- internal/x/xjson/duration.go | 16 ++-- internal/x/xtabwriter/tabwriter.go | 8 +- internal/x/xterminal/terminal.go | 66 +++++++++----- internal/x/xterminal/terminal_windows.go | 50 +++++++---- 28 files changed, 438 insertions(+), 379 deletions(-) diff --git a/ci/integration/setup_test.go b/ci/integration/setup_test.go index aecd7ed9..566f40e9 100644 --- a/ci/integration/setup_test.go +++ b/ci/integration/setup_test.go @@ -32,7 +32,7 @@ func init() { // build the coder-cli binary and move to the integration testing bin directory func build(path string) error { - tar := fmt.Sprintf("coder-cli-linux-amd64.tar.gz") + tar := "coder-cli-linux-amd64.tar.gz" dir := filepath.Dir(path) cmd := exec.Command( "sh", "-c", diff --git a/cmd/coder/main.go b/cmd/coder/main.go index 5ed4344b..5f2c0c9c 100644 --- a/cmd/coder/main.go +++ b/cmd/coder/main.go @@ -15,14 +15,14 @@ import ( "go.coder.com/flog" ) -var ( - version string = "unknown" -) +// Using a global for the version so it can be set at build time using ldflags. +var version = "unknown" func main() { ctx, cancel := context.WithCancel(context.Background()) defer cancel() + // If requested, spin up the pprof webserver. if os.Getenv("PPROF") != "" { go func() { log.Println(http.ListenAndServe("localhost:6060", nil)) @@ -31,15 +31,19 @@ func main() { stdoutState, err := xterminal.MakeOutputRaw(os.Stdout.Fd()) if err != nil { - flog.Fatal("set output to raw: %v", err) + flog.Fatal("set output to raw: %s", err) } - defer xterminal.Restore(os.Stdout.Fd(), stdoutState) + defer func() { + // Best effort. Would result in broken terminal on window but nothing we can do about it. + _ = xterminal.Restore(os.Stdout.Fd(), stdoutState) + }() app := cmd.Make() app.Version = fmt.Sprintf("%s %s %s/%s", version, runtime.Version(), runtime.GOOS, runtime.GOARCH) - err = app.ExecuteContext(ctx) - if err != nil { + if err := app.ExecuteContext(ctx); err != nil { + // NOTE: The returned error is already handled and logged by the cmd lib (cobra), so no need to re-handle it here. + // As we are in the main, if there was an error, exit the process with an error code. os.Exit(1) } } diff --git a/coder-sdk/activity.go b/coder-sdk/activity.go index f5001507..1aaeb301 100644 --- a/coder-sdk/activity.go +++ b/coder-sdk/activity.go @@ -5,18 +5,23 @@ import ( "net/http" ) +type activityRequest struct { + Source string `json:"source"` + EnvironmentID string `json:"environment_id"` +} + // PushActivity pushes CLI activity to Coder. -func (c Client) PushActivity(ctx context.Context, source string, envID string) error { - res, err := c.request(ctx, http.MethodPost, "/api/metrics/usage/push", map[string]string{ - "source": source, - "environment_id": envID, +func (c Client) PushActivity(ctx context.Context, source, envID string) error { + resp, err := c.request(ctx, http.MethodPost, "/api/metrics/usage/push", activityRequest{ + Source: source, + EnvironmentID: envID, }) if err != nil { return err } - if res.StatusCode != http.StatusOK { - return bodyError(res) + if resp.StatusCode != http.StatusOK { + return bodyError(resp) } return nil } diff --git a/coder-sdk/client.go b/coder-sdk/client.go index a4bcb3f2..959c7611 100644 --- a/coder-sdk/client.go +++ b/coder-sdk/client.go @@ -15,12 +15,17 @@ type Client struct { Token string } -func (c Client) copyURL() *url.URL { - swp := *c.BaseURL - return &swp -} - -func (c *Client) http() (*http.Client, error) { +// newHTTPClient creates a default underlying http client and sets the auth cookie. +// +// NOTE: As we do not specify a custom transport, the default one from the stdlib will be used, +// resulting in a persistent connection pool. +// We do not set a timeout here as it could cause issue with the websocket. +// The caller is expected to set it when needed. +// +// WARNING: If the caller sets a custom transport to set TLS settings or a custom CA, the default +// pool will not be used and it might result in a new dns lookup/tls session/socket begin +// established each time. +func (c *Client) newHTTPClient() (*http.Client, error) { jar, err := cookiejar.New(nil) if err != nil { return nil, err @@ -36,5 +41,6 @@ func (c *Client) http() (*http.Client, error) { Secure: c.BaseURL.Scheme == "https", }, }) + return &http.Client{Jar: jar}, nil } diff --git a/coder-sdk/devurl.go b/coder-sdk/devurl.go index 240a32de..e5c387e6 100644 --- a/coder-sdk/devurl.go +++ b/coder-sdk/devurl.go @@ -6,7 +6,7 @@ import ( "net/http" ) -// DevURL is the parsed json response record for a devURL from cemanager +// DevURL is the parsed json response record for a devURL from cemanager. type DevURL struct { ID string `json:"id" tab:"ID"` URL string `json:"url" tab:"URL"` @@ -20,21 +20,21 @@ type delDevURLRequest struct { DevURLID string `json:"url_id"` } -// DelDevURL deletes the specified devurl +// DelDevURL deletes the specified devurl. func (c Client) DelDevURL(ctx context.Context, envID, urlID string) error { reqURL := fmt.Sprintf("/api/environments/%s/devurls/%s", envID, urlID) - res, err := c.request(ctx, http.MethodDelete, reqURL, delDevURLRequest{ + resp, err := c.request(ctx, http.MethodDelete, reqURL, delDevURLRequest{ EnvID: envID, DevURLID: urlID, }) if err != nil { return err } - defer res.Body.Close() + defer func() { _ = resp.Body.Close() }() // Best effort. Likely connection drop. - if res.StatusCode != http.StatusOK { - return bodyError(res) + if resp.StatusCode != http.StatusOK { + return bodyError(resp) } return nil @@ -47,11 +47,11 @@ type createDevURLRequest struct { Name string `json:"name"` } -// InsertDevURL inserts a new devurl for the authenticated user +// InsertDevURL inserts a new devurl for the authenticated user. func (c Client) InsertDevURL(ctx context.Context, envID string, port int, name, access string) error { reqURL := fmt.Sprintf("/api/environments/%s/devurls", envID) - res, err := c.request(ctx, http.MethodPost, reqURL, createDevURLRequest{ + resp, err := c.request(ctx, http.MethodPost, reqURL, createDevURLRequest{ EnvID: envID, Port: port, Access: access, @@ -60,10 +60,10 @@ func (c Client) InsertDevURL(ctx context.Context, envID string, port int, name, if err != nil { return err } - defer res.Body.Close() + defer func() { _ = resp.Body.Close() }() // Best effort. Likely connection drop. - if res.StatusCode != http.StatusOK { - return bodyError(res) + if resp.StatusCode != http.StatusOK { + return bodyError(resp) } return nil @@ -71,11 +71,11 @@ func (c Client) InsertDevURL(ctx context.Context, envID string, port int, name, type updateDevURLRequest createDevURLRequest -// UpdateDevURL updates an existing devurl for the authenticated user +// UpdateDevURL updates an existing devurl for the authenticated user. func (c Client) UpdateDevURL(ctx context.Context, envID, urlID string, port int, name, access string) error { reqURL := fmt.Sprintf("/api/environments/%s/devurls/%s", envID, urlID) - res, err := c.request(ctx, http.MethodPut, reqURL, updateDevURLRequest{ + resp, err := c.request(ctx, http.MethodPut, reqURL, updateDevURLRequest{ EnvID: envID, Port: port, Access: access, @@ -84,10 +84,10 @@ func (c Client) UpdateDevURL(ctx context.Context, envID, urlID string, port int, if err != nil { return err } - defer res.Body.Close() + defer func() { _ = resp.Body.Close() }() // Best effort. Likefly connection drop. - if res.StatusCode != http.StatusOK { - return bodyError(res) + if resp.StatusCode != http.StatusOK { + return bodyError(resp) } return nil diff --git a/coder-sdk/env.go b/coder-sdk/env.go index c27a33b0..06fab259 100644 --- a/coder-sdk/env.go +++ b/coder-sdk/env.go @@ -31,13 +31,18 @@ type Environment struct { UpdatedAt time.Time `json:"updated_at" tab:"-"` LastOpenedAt time.Time `json:"last_opened_at" tab:"-"` LastConnectionAt time.Time `json:"last_connection_at" tab:"-"` - AutoOffThreshold xjson.Duration `json:"auto_off_threshold" tab:"-"` + AutoOffThreshold xjson.MSDuration `json:"auto_off_threshold" tab:"-"` } // RebuildMessage defines the message shown when an Environment requires a rebuild for it can be accessed. type RebuildMessage struct { - Text string `json:"text"` - Required bool `json:"required"` + Text string `json:"text"` + Required bool `json:"required"` + AutoOffThreshold xjson.MSDuration `json:"auto_off_threshold" tab:"-"` + RebuildMessages []struct { + Text string `json:"text"` + Required bool `json:"required"` + } `json:"rebuild_messages" tab:"-"` } // EnvironmentStat represents the state of an environment @@ -53,9 +58,7 @@ type EnvironmentStat struct { DiskUsed int64 `json:"disk_used"` } -func (e EnvironmentStat) String() string { - return string(e.ContainerStatus) -} +func (e EnvironmentStat) String() string { return string(e.ContainerStatus) } // EnvironmentStatus refers to the states of an environment. type EnvironmentStatus string @@ -69,7 +72,7 @@ const ( EnvironmentUnknown EnvironmentStatus = "UNKNOWN" ) -// CreateEnvironmentRequest is used to configure a new environment +// CreateEnvironmentRequest is used to configure a new environment. type CreateEnvironmentRequest struct { Name string `json:"name"` ImageID string `json:"image_id"` @@ -84,61 +87,50 @@ type CreateEnvironmentRequest struct { // CreateEnvironment sends a request to create an environment. func (c Client) CreateEnvironment(ctx context.Context, orgID string, req CreateEnvironmentRequest) (*Environment, error) { var env Environment - err := c.requestBody( - ctx, - http.MethodPost, "/api/orgs/"+orgID+"/environments", - req, - &env, - ) - return &env, err + if err := c.requestBody(ctx, http.MethodPost, "/api/orgs/"+orgID+"/environments", req, &env); err != nil { + return nil, err + } + return &env, nil } // EnvironmentsByOrganization gets the list of environments owned by the given user. func (c Client) EnvironmentsByOrganization(ctx context.Context, userID, orgID string) ([]Environment, error) { var envs []Environment - err := c.requestBody( - ctx, - http.MethodGet, "/api/orgs/"+orgID+"/members/"+userID+"/environments", - nil, - &envs, - ) - return envs, err + if err := c.requestBody(ctx, http.MethodGet, "/api/orgs/"+orgID+"/members/"+userID+"/environments", nil, &envs); err != nil { + return nil, err + } + return envs, nil } // DeleteEnvironment deletes the environment. func (c Client) DeleteEnvironment(ctx context.Context, envID string) error { - return c.requestBody( - ctx, - http.MethodDelete, "/api/environments/"+envID, - nil, - nil, - ) + return c.requestBody(ctx, http.MethodDelete, "/api/environments/"+envID, nil, nil) } // DialWsep dials an environments command execution interface -// See github.com/cdr/wsep for details +// See https://github.com/cdr/wsep for details. func (c Client) DialWsep(ctx context.Context, env *Environment) (*websocket.Conn, error) { - return c.dialWs(ctx, "/proxy/environments/"+env.ID+"/wsep") + return c.dialWebsocket(ctx, "/proxy/environments/"+env.ID+"/wsep") } // DialIDEStatus opens a websocket connection for cpu load metrics on the environment func (c Client) DialIDEStatus(ctx context.Context, envID string) (*websocket.Conn, error) { - return c.dialWs(ctx, "/proxy/environments/"+envID+"/ide/api/status") + return c.dialWebsocket(ctx, "/proxy/environments/"+envID+"/ide/api/status") } -// DialEnvironmentBuildLog opens a websocket connection for the environment build log messages +// DialEnvironmentBuildLog opens a websocket connection for the environment build log messages. func (c Client) DialEnvironmentBuildLog(ctx context.Context, envID string) (*websocket.Conn, error) { - return c.dialWs(ctx, "/api/environments/"+envID+"/watch-update") + return c.dialWebsocket(ctx, "/api/environments/"+envID+"/watch-update") } -// DialEnvironmentStats opens a websocket connection for environment stats +// DialEnvironmentStats opens a websocket connection for environment stats. func (c Client) DialEnvironmentStats(ctx context.Context, envID string) (*websocket.Conn, error) { - return c.dialWs(ctx, "/api/environments/"+envID+"/watch-stats") + return c.dialWebsocket(ctx, "/api/environments/"+envID+"/watch-stats") } // DialResourceLoad opens a websocket connection for cpu load metrics on the environment func (c Client) DialResourceLoad(ctx context.Context, envID string) (*websocket.Conn, error) { - return c.dialWs(ctx, "/api/environments/"+envID+"/watch-resource-load") + return c.dialWebsocket(ctx, "/api/environments/"+envID+"/watch-resource-load") } // BuildLogType describes the type of an event. diff --git a/coder-sdk/error.go b/coder-sdk/error.go index aa1adbd2..a76302a0 100644 --- a/coder-sdk/error.go +++ b/coder-sdk/error.go @@ -11,6 +11,7 @@ import ( // ErrNotFound describes an error case in which the requested resource could not be found var ErrNotFound = xerrors.Errorf("resource not found") +// apiError is the expected payload format for our errors. type apiError struct { Err struct { Msg string `json:"msg,required"` @@ -24,9 +25,12 @@ func bodyError(resp *http.Response) error { } var msg apiError - err = json.NewDecoder(resp.Body).Decode(&msg) - if err != nil || msg.Err.Msg == "" { + // Try to decode the payload as an error, if it fails or if there is no error message, + // return the response URL with the dump. + if err := json.NewDecoder(resp.Body).Decode(&msg); err != nil || msg.Err.Msg == "" { return xerrors.Errorf("%s\n%s", resp.Request.URL, byt) } + + // If the payload was a in the expected error format with a message, include it. return xerrors.Errorf("%s\n%s%s", resp.Request.URL, byt, msg.Err.Msg) } diff --git a/coder-sdk/image.go b/coder-sdk/image.go index 87ad584f..ba59e8bf 100644 --- a/coder-sdk/image.go +++ b/coder-sdk/image.go @@ -11,7 +11,7 @@ type Image struct { OrganizationID string `json:"organization_id"` Repository string `json:"repository"` Description string `json:"description"` - URL string `json:"url"` // user-supplied URL for image + URL string `json:"url"` // User-supplied URL for image. DefaultCPUCores float32 `json:"default_cpu_cores"` DefaultMemoryGB int `json:"default_memory_gb"` DefaultDiskGB int `json:"default_disk_gb"` @@ -28,28 +28,22 @@ type NewRegistryRequest struct { // ImportImageRequest is used to import new images and registries into Coder type ImportImageRequest struct { - // RegistryID is used to import images to existing registries. - RegistryID *string `json:"registry_id"` - // NewRegistry is used when adding a new registry. - NewRegistry *NewRegistryRequest `json:"new_registry"` - // Repository refers to the image. For example: "codercom/ubuntu". - Repository string `json:"repository"` - Tag string `json:"tag"` - DefaultCPUCores float32 `json:"default_cpu_cores"` - DefaultMemoryGB int `json:"default_memory_gb"` - DefaultDiskGB int `json:"default_disk_gb"` - Description string `json:"description"` - URL string `json:"url"` + RegistryID *string `json:"registry_id"` // Used to import images to existing registries. + NewRegistry *NewRegistryRequest `json:"new_registry"` // Used when adding a new registry. + Repository string `json:"repository"` // Refers to the image. Ex: "codercom/ubuntu". + Tag string `json:"tag"` + DefaultCPUCores float32 `json:"default_cpu_cores"` + DefaultMemoryGB int `json:"default_memory_gb"` + DefaultDiskGB int `json:"default_disk_gb"` + Description string `json:"description"` + URL string `json:"url"` } // ImportImage creates a new image and optionally a new registry func (c Client) ImportImage(ctx context.Context, orgID string, req ImportImageRequest) (*Image, error) { - var img *Image - err := c.requestBody( - ctx, - http.MethodPost, "/api/orgs/"+orgID+"/images", - req, - img, - ) - return img, err + var img Image + if err := c.requestBody(ctx, http.MethodPost, "/api/orgs/"+orgID+"/images", req, &img); err != nil { + return nil, err + } + return &img, nil } diff --git a/coder-sdk/org.go b/coder-sdk/org.go index 260e6321..10158c40 100644 --- a/coder-sdk/org.go +++ b/coder-sdk/org.go @@ -14,7 +14,9 @@ type Org struct { // Orgs gets all Organizations func (c Client) Orgs(ctx context.Context) ([]Org, error) { - var os []Org - err := c.requestBody(ctx, http.MethodGet, "/api/orgs", nil, &os) - return os, err + var orgs []Org + if err := c.requestBody(ctx, http.MethodGet, "/api/orgs", nil, &orgs); err != nil { + return nil, err + } + return orgs, nil } diff --git a/coder-sdk/request.go b/coder-sdk/request.go index fba5f8ab..4ae2bdf2 100644 --- a/coder-sdk/request.go +++ b/coder-sdk/request.go @@ -4,51 +4,61 @@ import ( "bytes" "context" "encoding/json" + "fmt" + "io" "net/http" "golang.org/x/xerrors" ) -func (c Client) request( - ctx context.Context, - method string, path string, - request interface{}, -) (*http.Response, error) { - client, err := c.http() +// request is a helper to set the cookie, marshal the payload and execute the request. +func (c Client) request(ctx context.Context, method, path string, in interface{}) (*http.Response, error) { + // Create a default http client with the auth in the cookie. + client, err := c.newHTTPClient() if err != nil { - return nil, err + return nil, xerrors.Errorf("new http client: %w", err) } - if request == nil { - request = []byte{} - } - body, err := json.Marshal(request) - if err != nil { - return nil, xerrors.Errorf("marshal request: %w", err) + + // If we have incoming data, encode it as json. + var payload io.Reader + if in != nil { + body, err := json.Marshal(in) + if err != nil { + return nil, xerrors.Errorf("marshal request: %w", err) + } + payload = bytes.NewReader(body) } - req, err := http.NewRequestWithContext(ctx, method, c.BaseURL.String()+path, bytes.NewReader(body)) + + // Create the http request. + req, err := http.NewRequestWithContext(ctx, method, c.BaseURL.String()+path, payload) if err != nil { return nil, xerrors.Errorf("create request: %w", err) } + + // Execute the request. return client.Do(req) } -func (c Client) requestBody( - ctx context.Context, - method string, path string, request interface{}, response interface{}, -) error { - resp, err := c.request(ctx, method, path, request) +// requestBody is a helper extending the Client.request helper, checking the response code +// and decoding the response payload. +func (c Client) requestBody(ctx context.Context, method, path string, in, out interface{}) error { + resp, err := c.request(ctx, method, path, in) if err != nil { - return err + return xerrors.Errorf("Execute request: %q", err) } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Best effort, likely connection dropped. + // Responses in the 100 are handled by the http lib, in the 200 range, we have a success. + // Consider anything at or above 300 to be an error. if resp.StatusCode > 299 { - return bodyError(resp) + return fmt.Errorf("unexpected status code: %w", bodyError(resp)) } - err = json.NewDecoder(resp.Body).Decode(&response) - if err != nil { - return err + // If we expect a payload, process it as json. + if out != nil { + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + return xerrors.Errorf("decode response body: %w", err) + } } return nil } diff --git a/coder-sdk/secrets.go b/coder-sdk/secrets.go index 18b391c1..21a1583b 100644 --- a/coder-sdk/secrets.go +++ b/coder-sdk/secrets.go @@ -19,26 +19,36 @@ type Secret struct { // Secrets gets all secrets for the given user func (c *Client) Secrets(ctx context.Context, userID string) ([]Secret, error) { var secrets []Secret - err := c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets", nil, &secrets) - return secrets, err + if err := c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets", nil, &secrets); err != nil { + return nil, err + } + return secrets, nil } // SecretWithValueByName gets the Coder secret with its value by its name. func (c *Client) SecretWithValueByName(ctx context.Context, name, userID string) (*Secret, error) { + // Lookup the secret from the name. s, err := c.SecretByName(ctx, name, userID) if err != nil { return nil, err } + // Pull the secret value. + // NOTE: This is racy, but acceptable. If the secret is gone or the permission changed since we looked up the id, + // the call will simply fail and surface the error to the user. var secret Secret - err = c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets/"+s.ID, nil, &secret) - return &secret, err + if err := c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets/"+s.ID, nil, &secret); err != nil { + return nil, err + } + return &secret, nil } // SecretWithValueByID gets the Coder secret with its value by the secret_id. func (c *Client) SecretWithValueByID(ctx context.Context, id, userID string) (*Secret, error) { var secret Secret - err := c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets/"+id, nil, &secret) - return &secret, err + if err := c.requestBody(ctx, http.MethodGet, "/api/users/"+userID+"/secrets/"+id, nil, &secret); err != nil { + return nil, err + } + return &secret, nil } // SecretByName gets a secret object by name @@ -55,7 +65,7 @@ func (c *Client) SecretByName(ctx context.Context, name, userID string) (*Secret return nil, ErrNotFound } -// InsertSecretReq describes the request body for creating a new secret +// InsertSecretReq describes the request body for creating a new secret. type InsertSecretReq struct { Name string `json:"name"` Value string `json:"value"` @@ -64,16 +74,21 @@ type InsertSecretReq struct { // InsertSecret adds a new secret for the authed user func (c *Client) InsertSecret(ctx context.Context, user *User, req InsertSecretReq) error { - var resp interface{} - return c.requestBody(ctx, http.MethodPost, "/api/users/"+user.ID+"/secrets", req, &resp) + return c.requestBody(ctx, http.MethodPost, "/api/users/"+user.ID+"/secrets", req, nil) } // DeleteSecretByName deletes the authenticated users secret with the given name func (c *Client) DeleteSecretByName(ctx context.Context, name, userID string) error { + // Lookup the secret by name to get the ID. secret, err := c.SecretByName(ctx, name, userID) if err != nil { return err } - _, err = c.request(ctx, http.MethodDelete, "/api/users/"+userID+"/secrets/"+secret.ID, nil) - return err + // Delete the secret. + // NOTE: This is racy, but acceptable. If the secret is gone or the permission changed since we looked up the id, + // the call will simply fail and surface the error to the user. + if _, err := c.request(ctx, http.MethodDelete, "/api/users/"+userID+"/secrets/"+secret.ID, nil); err != nil { + return err + } + return nil } diff --git a/coder-sdk/users.go b/coder-sdk/users.go index 02262bb1..1008f38b 100644 --- a/coder-sdk/users.go +++ b/coder-sdk/users.go @@ -24,14 +24,13 @@ func (c Client) Me(ctx context.Context) (*User, error) { // UserByID get the details of a user by their id. func (c Client) UserByID(ctx context.Context, id string) (*User, error) { var u User - err := c.requestBody(ctx, http.MethodGet, "/api/users/"+id, nil, &u) - if err != nil { + if err := c.requestBody(ctx, http.MethodGet, "/api/users/"+id, nil, &u); err != nil { return nil, err } return &u, nil } -// SSHKey describes an SSH keypair +// SSHKey describes an SSH keypair. type SSHKey struct { PublicKey string `json:"public_key"` PrivateKey string `json:"private_key"` @@ -40,8 +39,7 @@ type SSHKey struct { // SSHKey gets the current SSH kepair of the authenticated user. func (c Client) SSHKey(ctx context.Context) (*SSHKey, error) { var key SSHKey - err := c.requestBody(ctx, http.MethodGet, "/api/users/me/sshkey", nil, &key) - if err != nil { + if err := c.requestBody(ctx, http.MethodGet, "/api/users/me/sshkey", nil, &key); err != nil { return nil, err } return &key, nil @@ -50,8 +48,7 @@ func (c Client) SSHKey(ctx context.Context) (*SSHKey, error) { // Users gets the list of user accounts. func (c Client) Users(ctx context.Context) ([]User, error) { var u []User - err := c.requestBody(ctx, http.MethodGet, "/api/users", nil, &u) - if err != nil { + if err := c.requestBody(ctx, http.MethodGet, "/api/users", nil, &u); err != nil { return nil, err } return u, nil diff --git a/coder-sdk/ws.go b/coder-sdk/ws.go index d2569377..5dad6293 100644 --- a/coder-sdk/ws.go +++ b/coder-sdk/ws.go @@ -2,26 +2,23 @@ package coder import ( "context" + "net/http" "nhooyr.io/websocket" ) -func (c Client) dialWs(ctx context.Context, path string) (*websocket.Conn, error) { - u := c.copyURL() - if c.BaseURL.Scheme == "https" { - u.Scheme = "wss" +// dialWebsocket establish the websocket connection while setting the authentication header. +func (c Client) dialWebsocket(ctx context.Context, path string) (*websocket.Conn, error) { + // Make a copy of the url so we can update the scheme to ws(s) without mutating the state. + url := *c.BaseURL + if url.Scheme == "https" { + url.Scheme = "wss" } else { - u.Scheme = "ws" + url.Scheme = "ws" } - u.Path = path + url.Path = path - conn, resp, err := websocket.Dial(ctx, u.String(), - &websocket.DialOptions{ - HTTPHeader: map[string][]string{ - "Session-Token": {c.Token}, - }, - }, - ) + conn, resp, err := websocket.Dial(ctx, url.String(), &websocket.DialOptions{HTTPHeader: http.Header{"Session-Token": {c.Token}}}) if err != nil { if resp != nil { return nil, bodyError(resp) diff --git a/internal/activity/pusher.go b/internal/activity/pusher.go index 5efa15d1..7f0f0e00 100644 --- a/internal/activity/pusher.go +++ b/internal/activity/pusher.go @@ -4,9 +4,10 @@ import ( "context" "time" - "cdr.dev/coder-cli/coder-sdk" "golang.org/x/time/rate" + "cdr.dev/coder-cli/coder-sdk" + "go.coder.com/flog" ) @@ -19,27 +20,28 @@ type Pusher struct { source string client *coder.Client - rate *rate.Limiter + rate *rate.Limiter // Use a rate limiter to control the sampling rate. } -// NewPusher instantiates a new instance of Pusher +// NewPusher instantiates a new instance of Pusher. func NewPusher(c *coder.Client, envID, source string) *Pusher { return &Pusher{ envID: envID, source: source, client: c, - rate: rate.NewLimiter(rate.Every(pushInterval), 1), + // Sample only 1 per interval to avoid spamming the api. + rate: rate.NewLimiter(rate.Every(pushInterval), 1), } } -// Push pushes activity, abiding by a rate limit +// Push pushes activity, abiding by a rate limit. func (p *Pusher) Push(ctx context.Context) { + // If we already sampled data within the allowable range, do nothing. if !p.rate.Allow() { return } - err := p.client.PushActivity(ctx, p.source, p.envID) - if err != nil { - flog.Error("push activity: %s", err.Error()) + if err := p.client.PushActivity(ctx, p.source, p.envID); err != nil { + flog.Error("push activity: %s", err) } } diff --git a/internal/activity/writer.go b/internal/activity/writer.go index f7358924..0daf6dc8 100644 --- a/internal/activity/writer.go +++ b/internal/activity/writer.go @@ -5,18 +5,19 @@ import ( "io" ) -type activityWriter struct { +// writer wraps a standard io.Writer with the activity pusher. +type writer struct { p *Pusher wr io.Writer } -// Write writes to the underlying writer and tracks activity -func (w *activityWriter) Write(p []byte) (n int, err error) { +// Write writes to the underlying writer and tracks activity. +func (w *writer) Write(buf []byte) (int, error) { w.p.Push(context.Background()) - return w.wr.Write(p) + return w.wr.Write(buf) } // Writer wraps the given writer such that all writes trigger an activity push func (p *Pusher) Writer(wr io.Writer) io.Writer { - return &activityWriter{p: p, wr: wr} + return &writer{p: p, wr: wr} } diff --git a/internal/cmd/auth.go b/internal/cmd/auth.go index bfe00a3e..f060d0ad 100644 --- a/internal/cmd/auth.go +++ b/internal/cmd/auth.go @@ -3,18 +3,19 @@ package cmd import ( "net/url" + "golang.org/x/xerrors" + "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/config" - "golang.org/x/xerrors" "go.coder.com/flog" ) -// requireAuth exits the process with a nonzero exit code if the user is not authenticated to make requests +// requireAuth exits the process with a nonzero exit code if the user is not authenticated to make requests. func requireAuth() *coder.Client { client, err := newClient() if err != nil { - flog.Fatal("%v", err) + flog.Fatal("%s", err) } return client } @@ -22,23 +23,21 @@ func requireAuth() *coder.Client { func newClient() (*coder.Client, error) { sessionToken, err := config.Session.Read() if err != nil { - return nil, xerrors.Errorf("read session: %v (did you run coder login?)", err) + return nil, xerrors.Errorf("read session: %w (did you run coder login?)", err) } rawURL, err := config.URL.Read() if err != nil { - return nil, xerrors.Errorf("read url: %v (did you run coder login?)", err) + return nil, xerrors.Errorf("read url: %w (did you run coder login?)", err) } u, err := url.Parse(rawURL) if err != nil { - return nil, xerrors.Errorf("url misformatted: %v (try runing coder login)", err) + return nil, xerrors.Errorf("url misformatted: %w (try runing coder login)", err) } - client := &coder.Client{ + return &coder.Client{ BaseURL: u, Token: sessionToken, - } - - return client, nil + }, nil } diff --git a/internal/cmd/ceapi.go b/internal/cmd/ceapi.go index d9d8eb7e..dfce6598 100644 --- a/internal/cmd/ceapi.go +++ b/internal/cmd/ceapi.go @@ -11,47 +11,48 @@ import ( // Helpers for working with the Coder Enterprise API. -// userOrgs gets a list of orgs the user is apart of. -func userOrgs(user *coder.User, orgs []coder.Org) []coder.Org { - var uo []coder.Org -outer: +// lookupUserOrgs gets a list of orgs the user is apart of. +func lookupUserOrgs(user *coder.User, orgs []coder.Org) []coder.Org { + // NOTE: We don't know in advance how many orgs the user is in so we can't pre-alloc. + var userOrgs []coder.Org + for _, org := range orgs { for _, member := range org.Members { if member.ID != user.ID { continue } - uo = append(uo, org) - continue outer + // If we found the user in the org, add it to the list and skip to the next org. + userOrgs = append(userOrgs, org) + break } } - return uo + return userOrgs } // getEnvs returns all environments for the user. func getEnvs(ctx context.Context, client *coder.Client, email string) ([]coder.Environment, error) { user, err := client.UserByEmail(ctx, email) if err != nil { - return nil, xerrors.Errorf("get user: %+v", err) + return nil, xerrors.Errorf("get user: %w", err) } orgs, err := client.Orgs(ctx) if err != nil { - return nil, xerrors.Errorf("get orgs: %+v", err) + return nil, xerrors.Errorf("get orgs: %w", err) } - orgs = userOrgs(user, orgs) + orgs = lookupUserOrgs(user, orgs) + // NOTE: We don't know in advance how many envs we have so we can't pre-alloc. var allEnvs []coder.Environment for _, org := range orgs { envs, err := client.EnvironmentsByOrganization(ctx, user.ID, org.ID) if err != nil { - return nil, xerrors.Errorf("get envs for %v: %+v", org.Name, err) + return nil, xerrors.Errorf("get envs for %s: %w", org.Name, err) } - for _, env := range envs { - allEnvs = append(allEnvs, env) - } + allEnvs = append(allEnvs, envs...) } return allEnvs, nil } @@ -63,15 +64,16 @@ func findEnv(ctx context.Context, client *coder.Client, envName, userEmail strin return nil, xerrors.Errorf("get environments: %w", err) } + // NOTE: We don't know in advance where we will find the env, so we can't pre-alloc. var found []string - for _, env := range envs { - found = append(found, env.Name) if env.Name == envName { return &env, nil } + // Keep track of what we found for the logs. + found = append(found, env.Name) } flog.Error("found %q", found) flog.Error("%q not found", envName) - return nil, xerrors.New("environment not found") + return nil, coder.ErrNotFound } diff --git a/internal/cmd/cmd.go b/internal/cmd/cmd.go index 30e4af7d..6f13dddc 100644 --- a/internal/cmd/cmd.go +++ b/internal/cmd/cmd.go @@ -31,7 +31,7 @@ func Make() *cobra.Command { } func genDocs(rootCmd *cobra.Command) *cobra.Command { - cmd := &cobra.Command{ + return &cobra.Command{ Use: "gen-docs [dir_path]", Short: "Generate a markdown documentation tree for the root command.", Example: "coder gen-docs ./docs", @@ -41,7 +41,6 @@ func genDocs(rootCmd *cobra.Command) *cobra.Command { return doc.GenMarkdownTree(rootCmd, args[0]) }, } - return cmd } // reference: https://github.com/spf13/cobra/blob/master/shell_completions.md @@ -92,13 +91,13 @@ $ coder completion fish > ~/.config/fish/completions/coder.fish Run: func(cmd *cobra.Command, args []string) { switch args[0] { case "bash": - cmd.Root().GenBashCompletion(os.Stdout) + _ = cmd.Root().GenBashCompletion(os.Stdout) // Best effort. case "zsh": - cmd.Root().GenZshCompletion(os.Stdout) + _ = cmd.Root().GenZshCompletion(os.Stdout) // Best effort. case "fish": - cmd.Root().GenFishCompletion(os.Stdout, true) + _ = cmd.Root().GenFishCompletion(os.Stdout, true) // Best effort. case "powershell": - cmd.Root().GenPowerShellCompletion(os.Stdout) + _ = cmd.Root().GenPowerShellCompletion(os.Stdout) // Best effort. } }, } diff --git a/internal/cmd/configssh.go b/internal/cmd/configssh.go index 02323f41..9c04d48a 100644 --- a/internal/cmd/configssh.go +++ b/internal/cmd/configssh.go @@ -68,7 +68,7 @@ func configSSH(configpath *string, remove *bool) func(cmd *cobra.Command, _ []st // SSH configs are not always already there. currentConfig = "" } else if err != nil { - return xerrors.Errorf("read ssh config file %q: %w", configpath, err) + return xerrors.Errorf("read ssh config file %q: %w", *configpath, err) } startIndex := strings.Index(currentConfig, startToken) @@ -82,7 +82,7 @@ func configSSH(configpath *string, remove *bool) func(cmd *cobra.Command, _ []st err = writeStr(*configpath, currentConfig) if err != nil { - return xerrors.Errorf("write to ssh config file %q: %v", *configpath, err) + return xerrors.Errorf("write to ssh config file %q: %s", *configpath, err) } return nil diff --git a/internal/cmd/secrets.go b/internal/cmd/secrets.go index 9dd660e5..822543de 100644 --- a/internal/cmd/secrets.go +++ b/internal/cmd/secrets.go @@ -5,12 +5,13 @@ import ( "io/ioutil" "os" - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xtabwriter" "github.com/manifoldco/promptui" "github.com/spf13/cobra" "golang.org/x/xerrors" + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "go.coder.com/flog" ) diff --git a/internal/cmd/shell.go b/internal/cmd/shell.go index 62accce2..bc61e109 100644 --- a/internal/cmd/shell.go +++ b/internal/cmd/shell.go @@ -7,30 +7,32 @@ import ( "strings" "time" - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/activity" - "cdr.dev/coder-cli/internal/x/xterminal" - "cdr.dev/wsep" "github.com/spf13/cobra" "golang.org/x/crypto/ssh/terminal" "golang.org/x/time/rate" "golang.org/x/xerrors" "nhooyr.io/websocket" + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/internal/activity" + "cdr.dev/coder-cli/internal/x/xterminal" + "cdr.dev/wsep" + "go.coder.com/flog" ) func getEnvsForCompletion(user string) func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { return func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - var envNames []string client, err := newClient() if err != nil { - return envNames, cobra.ShellCompDirectiveDefault + return nil, cobra.ShellCompDirectiveDefault } envs, err := getEnvs(context.TODO(), client, user) if err != nil { - return envNames, cobra.ShellCompDirectiveDefault + return nil, cobra.ShellCompDirectiveDefault } + + envNames := make([]string, 0, len(envs)) for _, e := range envs { envNames = append(envNames, e.Name) } @@ -52,10 +54,7 @@ func makeShellCmd() *cobra.Command { } func shell(_ *cobra.Command, cmdArgs []string) error { - var ( - envName = cmdArgs[0] - ctx = context.Background() - ) + ctx := context.Background() command := "sh" args := []string{"-c"} @@ -66,26 +65,28 @@ func shell(_ *cobra.Command, cmdArgs []string) error { args = append(args, "exec $(getent passwd $(whoami) | awk -F: '{ print $7 }')") } - err := runCommand(ctx, envName, command, args) - if exitErr, ok := err.(wsep.ExitError); ok { - os.Exit(exitErr.Code) - } - if err != nil { + envName := cmdArgs[0] + + if err := runCommand(ctx, envName, command, args); err != nil { + if exitErr, ok := err.(wsep.ExitError); ok { + os.Exit(exitErr.Code) + } return xerrors.Errorf("run command: %w", err) } return nil } -func sendResizeEvents(ctx context.Context, termfd uintptr, process wsep.Process) { - events := xterminal.ResizeEvents(ctx, termfd) +// sendResizeEvents starts watching for the client's terminal resize signals +// and sends the event to the server so the remote tty can match the client. +func sendResizeEvents(ctx context.Context, termFD uintptr, process wsep.Process) { + events := xterminal.ResizeEvents(ctx, termFD) // Limit the frequency of resizes to prevent a stuttering effect. - resizeLimiter := rate.NewLimiter(rate.Every(time.Millisecond*100), 1) + resizeLimiter := rate.NewLimiter(rate.Every(100*time.Millisecond), 1) for { select { case newsize := <-events: - err := process.Resize(ctx, newsize.Height, newsize.Width) - if err != nil { + if err := process.Resize(ctx, newsize.Height, newsize.Width); err != nil { return } _ = resizeLimiter.Wait(ctx) @@ -95,24 +96,30 @@ func sendResizeEvents(ctx context.Context, termfd uintptr, process wsep.Process) } } -func runCommand(ctx context.Context, envName string, command string, args []string) error { - var ( - entClient = requireAuth() - ) +func runCommand(ctx context.Context, envName, command string, args []string) error { + entClient := requireAuth() + env, err := findEnv(ctx, entClient, envName, coder.Me) if err != nil { - return err + return xerrors.Errorf("find environment: %w", err) } - termfd := os.Stdout.Fd() + termFD := os.Stdout.Fd() - tty := terminal.IsTerminal(int(termfd)) - if tty { + isInteractive := terminal.IsTerminal(int(termFD)) + if isInteractive { + // If the client has a tty, take over it by setting the raw mode. + // This allows for all input to be directly forwarded to the remote process, + // otherwise, the local terminal would buffer input, interpret special keys, etc. stdinState, err := xterminal.MakeRaw(os.Stdin.Fd()) if err != nil { return err } - defer xterminal.Restore(os.Stdin.Fd(), stdinState) + defer func() { + // Best effort. If this fails it will result in a broken terminal, + // but there is nothing we can do about it. + _ = xterminal.Restore(os.Stdin.Fd(), stdinState) + }() } ctx, cancel := context.WithCancel(ctx) @@ -120,12 +127,12 @@ func runCommand(ctx context.Context, envName string, command string, args []stri conn, err := entClient.DialWsep(ctx, env) if err != nil { - return err + return xerrors.Errorf("dial websocket: %w", err) } go heartbeat(ctx, conn, 15*time.Second) var cmdEnv []string - if tty { + if isInteractive { term := os.Getenv("TERM") if term == "" { term = "xterm" @@ -137,47 +144,46 @@ func runCommand(ctx context.Context, envName string, command string, args []stri process, err := execer.Start(ctx, wsep.Command{ Command: command, Args: args, - TTY: tty, + TTY: isInteractive, Stdin: true, Env: cmdEnv, }) if err != nil { var closeErr websocket.CloseError if xerrors.As(err, &closeErr) { - return xerrors.Errorf("network error, is %q online?", envName) + return xerrors.Errorf("network error, is %q online? (%w)", envName, err) } - return err + return xerrors.Errorf("start remote command: %w", err) } - if tty { - go sendResizeEvents(ctx, termfd, process) + // Now that the remote process successfully started, if we have a tty, start the resize event watcher. + if isInteractive { + go sendResizeEvents(ctx, termFD, process) } go func() { stdin := process.Stdin() - defer stdin.Close() + defer func() { _ = stdin.Close() }() // Best effort. ap := activity.NewPusher(entClient, env.ID, sshActivityName) wr := ap.Writer(stdin) - _, err := io.Copy(wr, os.Stdin) - if err != nil { + if _, err := io.Copy(wr, os.Stdin); err != nil { cancel() } }() go func() { - _, err := io.Copy(os.Stdout, process.Stdout()) - if err != nil { + if _, err := io.Copy(os.Stdout, process.Stdout()); err != nil { cancel() } }() go func() { - _, err := io.Copy(os.Stderr, process.Stderr()) - if err != nil { + + if _, err := io.Copy(os.Stderr, process.Stderr()); err != nil { cancel() } }() - err = process.Wait() - if err != nil { + + if err := process.Wait(); err != nil { var closeErr websocket.CloseError if xerrors.Is(err, ctx.Err()) || xerrors.As(err, &closeErr) { return xerrors.Errorf("network error, is %q online?", envName) @@ -187,7 +193,7 @@ func runCommand(ctx context.Context, envName string, command string, args []stri return nil } -func heartbeat(ctx context.Context, c *websocket.Conn, interval time.Duration) { +func heartbeat(ctx context.Context, conn *websocket.Conn, interval time.Duration) { ticker := time.NewTicker(interval) defer ticker.Stop() @@ -196,9 +202,9 @@ func heartbeat(ctx context.Context, c *websocket.Conn, interval time.Duration) { case <-ctx.Done(): return case <-ticker.C: - err := c.Ping(ctx) - if err != nil { - flog.Fatal("\nFailed to ping websocket: %v, exiting...", err) + if err := conn.Ping(ctx); err != nil { + // NOTE: Prefix with \r\n to attempt to have clearer output as we might still be in raw mode. + flog.Fatal("\r\nFailed to ping websocket: %s, exiting.", err) } } } diff --git a/internal/cmd/urls.go b/internal/cmd/urls.go index ebe694ce..5e54c8c7 100644 --- a/internal/cmd/urls.go +++ b/internal/cmd/urls.go @@ -10,11 +10,12 @@ import ( "strconv" "strings" - "cdr.dev/coder-cli/coder-sdk" - "cdr.dev/coder-cli/internal/x/xtabwriter" "github.com/spf13/cobra" "golang.org/x/xerrors" + "cdr.dev/coder-cli/coder-sdk" + "cdr.dev/coder-cli/internal/x/xtabwriter" + "go.coder.com/flog" ) @@ -59,7 +60,7 @@ type DevURL struct { } var urlAccessLevel = map[string]string{ - //Remote API endpoint requires these in uppercase + // Remote API endpoint requires these in uppercase. "PRIVATE": "Only you can access", "ORG": "All members of your organization can access", "AUTHED": "Authenticated users can access", @@ -73,10 +74,9 @@ func validatePort(port string) (int, error) { return 0, err } if p < 1 { - // port 0 means 'any free port', which we don't support - err = strconv.ErrRange + // Port 0 means 'any free port', which we don't support. flog.Error("Port must be > 0") - return 0, err + return 0, strconv.ErrRange } return int(p), nil } @@ -112,8 +112,7 @@ func makeListDevURLs(outputFmt *string) func(cmd *cobra.Command, args []string) return xerrors.Errorf("write table: %w", err) } case "json": - err := json.NewEncoder(os.Stdout).Encode(devURLs) - if err != nil { + if err := json.NewEncoder(os.Stdout).Encode(devURLs); err != nil { return xerrors.Errorf("encode DevURLs as json: %w", err) } default: @@ -237,8 +236,7 @@ func removeDevURL(cmd *cobra.Command, args []string) error { return xerrors.Errorf("No devurl found for port %v", port) } - err = entClient.DelDevURL(cmd.Context(), env.ID, urlID) - if err != nil { + if err := entClient.DelDevURL(cmd.Context(), env.ID, urlID); err != nil { return xerrors.Errorf("delete DevURL: %w", err) } return nil @@ -259,17 +257,16 @@ func urlList(ctx context.Context, envName string) ([]DevURL, error) { if err != nil { return nil, err } - defer resp.Body.Close() + defer func() { _ = resp.Body.Close() }() // Best effort. - if resp.StatusCode != 200 { + if resp.StatusCode != http.StatusOK { return nil, xerrors.Errorf("non-success status code: %d", resp.StatusCode) } dec := json.NewDecoder(resp.Body) - devURLs := make([]DevURL, 0) - err = dec.Decode(&devURLs) - if err != nil { + var devURLs []DevURL + if err := dec.Decode(&devURLs); err != nil { return nil, err } diff --git a/internal/cmd/users.go b/internal/cmd/users.go index c88dae47..ed892f45 100644 --- a/internal/cmd/users.go +++ b/internal/cmd/users.go @@ -4,9 +4,10 @@ import ( "encoding/json" "os" - "cdr.dev/coder-cli/internal/x/xtabwriter" "github.com/spf13/cobra" "golang.org/x/xerrors" + + "cdr.dev/coder-cli/internal/x/xtabwriter" ) func makeUsersCmd() *cobra.Command { @@ -40,15 +41,13 @@ func listUsers(outputFmt *string) func(cmd *cobra.Command, args []string) error switch *outputFmt { case "human": - err := xtabwriter.WriteTable(len(users), func(i int) interface{} { - return users[i] - }) - if err != nil { + // For each element, return the user. + each := func(i int) interface{} { return users[i] } + if err := xtabwriter.WriteTable(len(users), each); err != nil { return xerrors.Errorf("write table: %w", err) } case "json": - err = json.NewEncoder(os.Stdout).Encode(users) - if err != nil { + if err := json.NewEncoder(os.Stdout).Encode(users); err != nil { return xerrors.Errorf("encode users as json: %w", err) } default: diff --git a/internal/sync/sync.go b/internal/sync/sync.go index 6e5b6c49..83e6ca85 100644 --- a/internal/sync/sync.go +++ b/internal/sync/sync.go @@ -16,7 +16,6 @@ import ( "sync/atomic" "time" - "cdr.dev/coder-cli/coder-sdk" "github.com/gorilla/websocket" "github.com/rjeczalik/notify" "golang.org/x/sync/semaphore" @@ -24,6 +23,7 @@ import ( "go.coder.com/flog" + "cdr.dev/coder-cli/coder-sdk" "cdr.dev/coder-cli/internal/activity" "cdr.dev/wsep" ) @@ -72,8 +72,8 @@ func (s Sync) syncPaths(delete bool, local, remote string) error { cmd.Stdout = os.Stdout cmd.Stderr = ioutil.Discard cmd.Stdin = os.Stdin - err := cmd.Run() - if err != nil { + + if err := cmd.Run(); err != nil { if exitError, ok := err.(*exec.ExitError); ok { if exitError.ExitCode() == rsyncExitCodeIncompat { return xerrors.Errorf("no compatible rsync on remote machine: rsync: %w", err) @@ -91,9 +91,9 @@ func (s Sync) syncPaths(delete bool, local, remote string) error { func (s Sync) remoteCmd(ctx context.Context, prog string, args ...string) error { conn, err := s.Client.DialWsep(ctx, &s.Env) if err != nil { - return err + return xerrors.Errorf("dial websocket: %w", err) } - defer conn.Close(websocket.CloseNormalClosure, "") + defer func() { _ = conn.Close(websocket.CloseNormalClosure, "") }() // Best effort. execer := wsep.RemoteExecer(conn) process, err := execer.Start(ctx, wsep.Command{ @@ -101,34 +101,35 @@ func (s Sync) remoteCmd(ctx context.Context, prog string, args ...string) error Args: args, }) if err != nil { - return err + return xerrors.Errorf("exec remote process: %w", err) } - go io.Copy(os.Stdout, process.Stderr()) - go io.Copy(os.Stderr, process.Stdout()) + // NOTE: If the copy routine fail, it will result in `process.Wait` to unblock and report an error. + go func() { _, _ = io.Copy(os.Stdout, process.Stdout()) }() // Best effort. + go func() { _, _ = io.Copy(os.Stderr, process.Stderr()) }() // Best effort. - err = process.Wait() - if code, ok := err.(wsep.ExitError); ok { - return fmt.Errorf("%s exit status: %v", prog, code) - } - if err != nil { + if err := process.Wait(); err != nil { + if code, ok := err.(wsep.ExitError); ok { + return xerrors.Errorf("%s exit status: %d", prog, code) + } return xerrors.Errorf("execution failure: %w", err) } + return nil } // initSync performs the initial synchronization of the directory. func (s Sync) initSync() error { - flog.Info("doing initial sync (%v -> %v)", s.LocalDir, s.RemoteDir) + flog.Info("doing initial sync (%s -> %s)", s.LocalDir, s.RemoteDir) start := time.Now() // Delete old files on initial sync (e.g git checkout). // Add the "/." to the local directory so rsync doesn't try to place the directory // into the remote dir. - err := s.syncPaths(true, s.LocalDir+"/.", s.RemoteDir) - if err == nil { - flog.Success("finished initial sync (%v)", time.Since(start).Truncate(time.Millisecond)) + if err := s.syncPaths(true, s.LocalDir+"/.", s.RemoteDir); err != nil { + return err } - return err + flog.Success("finished initial sync (%s)", time.Since(start).Truncate(time.Millisecond)) + return nil } func (s Sync) convertPath(local string) string { @@ -136,22 +137,17 @@ func (s Sync) convertPath(local string) string { if err != nil { panic(err) } - return filepath.Join( - s.RemoteDir, - relLocalPath, - ) + return filepath.Join(s.RemoteDir, relLocalPath) } func (s Sync) handleCreate(localPath string) error { target := s.convertPath(localPath) - err := s.syncPaths(false, localPath, target) - if err != nil { - _, statErr := os.Stat(localPath) + + if err := s.syncPaths(false, localPath, target); err != nil { // File was quickly deleted. - if os.IsNotExist(statErr) { + if _, e1 := os.Stat(localPath); os.IsNotExist(e1) { // NOTE: Discard any other stat error and just expose the syncPath one. return nil } - return err } return nil @@ -197,14 +193,14 @@ func (s Sync) work(ev timedEvent) { case notify.Remove: err = s.handleDelete(localPath) default: - flog.Info("unhandled event %v %+v", ev.Event(), ev.Path()) + flog.Info("unhandled event %+v %s", ev.Event(), ev.Path()) } - log := fmt.Sprintf("%v %v (%v)", + log := fmt.Sprintf("%v %s (%s)", ev.Event(), filepath.Base(localPath), time.Since(ev.CreatedAt).Truncate(time.Millisecond*10), ) if err != nil { - flog.Error(log+": %v", err) + flog.Error(log+": %s", err) } else { flog.Success(log) } @@ -215,7 +211,7 @@ var ErrRestartSync = errors.New("the sync exited because it was overloaded, rest // workEventGroup converges a group of events to prevent duplicate work. func (s Sync) workEventGroup(evs []timedEvent) { - cache := make(eventCache) + cache := eventCache{} for _, ev := range evs { cache.Add(ev) } @@ -238,8 +234,10 @@ func (s Sync) workEventGroup(evs []timedEvent) { setConsoleTitle(fmtUpdateTitle(ev.Path())) wg.Add(1) - sem.Acquire(context.Background(), 1) - ev := ev + // TODO: Document why this error is discarded. See https://github.com/cdr/coder-cli/issues/122 for reference. + _ = sem.Acquire(context.Background(), 1) + + ev := ev // Copy the event in the scope to make sure the go routine use the proper value. go func() { defer sem.Release(1) defer wg.Done() @@ -256,25 +254,25 @@ const ( // or node_modules) is impossible to do performantly with individual // rsyncs. maxInflightInotify = 8 - maxEventDelay = time.Second * 7 + maxEventDelay = 7 * time.Second // maxAcceptableDispatch is the maximum amount of time before an event // should begin its journey to the server. This sets a lower bound for // perceivable latency, but the higher it is, the better the // optimization. - maxAcceptableDispatch = time.Millisecond * 50 + maxAcceptableDispatch = 50 * time.Millisecond ) // Version returns remote protocol version as a string. // Or, an error if one exists. func (s Sync) Version() (string, error) { - ctx, cancel := context.WithTimeout(context.Background(), time.Second*10) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() conn, err := s.Client.DialWsep(ctx, &s.Env) if err != nil { return "", err } - defer conn.Close(websocket.CloseNormalClosure, "") + defer func() { _ = conn.Close(websocket.CloseNormalClosure, "") }() // Best effort. execer := wsep.RemoteExecer(conn) process, err := execer.Start(ctx, wsep.Command{ @@ -285,10 +283,9 @@ func (s Sync) Version() (string, error) { return "", err } buf := &bytes.Buffer{} - io.Copy(buf, process.Stdout()) + _, _ = io.Copy(buf, process.Stdout()) // Ignore error, if any, it would be handled by the process.Wait return. - err = process.Wait() - if err != nil { + if err := process.Wait(); err != nil { return "", err } @@ -310,8 +307,7 @@ func (s Sync) Run() error { // Set up a recursive watch. // We do this before the initial sync so we can capture any changes // that may have happened during sync. - err := notify.Watch(path.Join(s.LocalDir, "..."), events, notify.All) - if err != nil { + if err := notify.Watch(path.Join(s.LocalDir, "..."), events, notify.All); err != nil { return xerrors.Errorf("create watch: %w", err) } defer notify.Stop(events) @@ -319,14 +315,15 @@ func (s Sync) Run() error { ctx, cancel := context.WithTimeout(context.Background(), time.Minute) defer cancel() - s.remoteCmd(ctx, "mkdir", "-p", s.RemoteDir) + if err := s.remoteCmd(ctx, "mkdir", "-p", s.RemoteDir); err != nil { + return xerrors.Errorf("create remote directory: %w", err) + } ap := activity.NewPusher(s.Client, s.Env.ID, activityName) ap.Push(ctx) setConsoleTitle("⏳ syncing project") - err = s.initSync() - if err != nil { + if err := s.initSync(); err != nil { return err } @@ -356,10 +353,9 @@ func (s Sync) Run() error { } }() - var ( - eventGroup []timedEvent - dispatchEventGroup = time.NewTicker(maxAcceptableDispatch) - ) + var eventGroup []timedEvent + + dispatchEventGroup := time.NewTicker(maxAcceptableDispatch) defer dispatchEventGroup.Stop() for { const watchingFilesystemTitle = "🛰 watching filesystem" @@ -370,7 +366,6 @@ func (s Sync) Run() error { if atomic.LoadUint64(&droppedEvents) > 0 { return ErrRestartSync } - eventGroup = append(eventGroup, ev) case <-dispatchEventGroup.C: if len(eventGroup) == 0 { diff --git a/internal/x/xjson/duration.go b/internal/x/xjson/duration.go index 3ec08b48..04ec1f17 100644 --- a/internal/x/xjson/duration.go +++ b/internal/x/xjson/duration.go @@ -6,28 +6,26 @@ import ( "time" ) -// Duration is a time.Duration that marshals to millisecond precision. -// Most javascript applications expect durations to be in milliseconds. -type Duration time.Duration +// MSDuration is a time.MSDuration that marshals to millisecond precision. +// While is looses precision, most javascript applications expect durations to be in milliseconds. +type MSDuration time.Duration // MarshalJSON marshals the duration to millisecond precision. -func (d Duration) MarshalJSON() ([]byte, error) { +func (d MSDuration) MarshalJSON() ([]byte, error) { du := time.Duration(d) return json.Marshal(du.Milliseconds()) } // UnmarshalJSON unmarshals a millisecond-precision integer to // a time.Duration. -func (d *Duration) UnmarshalJSON(b []byte) error { +func (d *MSDuration) UnmarshalJSON(b []byte) error { i, err := strconv.ParseInt(string(b), 10, 64) if err != nil { return err } - *d = Duration(time.Duration(i) * time.Millisecond) + *d = MSDuration(time.Duration(i) * time.Millisecond) return nil } -func (d Duration) String() string { - return time.Duration(d).String() -} +func (d MSDuration) String() string { return time.Duration(d).String() } diff --git a/internal/x/xtabwriter/tabwriter.go b/internal/x/xtabwriter/tabwriter.go index 4d3a91be..6c28b647 100644 --- a/internal/x/xtabwriter/tabwriter.go +++ b/internal/x/xtabwriter/tabwriter.go @@ -55,17 +55,15 @@ func WriteTable(length int, each func(i int) interface{}) error { return nil } w := NewWriter() - defer w.Flush() + defer func() { _ = w.Flush() }() // Best effort. for ix := 0; ix < length; ix++ { item := each(ix) if ix == 0 { - _, err := fmt.Fprintln(w, StructFieldNames(item)) - if err != nil { + if _, err := fmt.Fprintln(w, StructFieldNames(item)); err != nil { return err } } - _, err := fmt.Fprintln(w, StructValues(item)) - if err != nil { + if _, err := fmt.Fprintln(w, StructValues(item)); err != nil { return err } } diff --git a/internal/x/xterminal/terminal.go b/internal/x/xterminal/terminal.go index ce3c9e3f..ab21db04 100644 --- a/internal/x/xterminal/terminal.go +++ b/internal/x/xterminal/terminal.go @@ -18,14 +18,15 @@ type State struct { // MakeRaw sets the terminal to raw. func MakeRaw(fd uintptr) (*State, error) { - s, err := terminal.MakeRaw(int(fd)) - return &State{s}, err + previousState, err := terminal.MakeRaw(int(fd)) + if err != nil { + return nil, err + } + return &State{s: previousState}, nil } // MakeOutputRaw does nothing on non-Windows platforms. -func MakeOutputRaw(fd uintptr) (*State, error) { - return nil, nil -} +func MakeOutputRaw(fd uintptr) (*State, error) { return nil, nil } // Restore terminal back to original state. func Restore(fd uintptr, state *State) error { @@ -43,28 +44,51 @@ func ColorEnabled(fd uintptr) (bool, error) { // ResizeEvent describes the new terminal dimensions following a resize type ResizeEvent struct { - Height, Width uint16 + Height uint16 + Width uint16 } -// ResizeEvents sends terminal resize events -func ResizeEvents(ctx context.Context, termfd uintptr) chan ResizeEvent { - sigs := make(chan os.Signal, 16) - signal.Notify(sigs, unix.SIGWINCH) - - events := make(chan ResizeEvent) +// ResizeEvents sends terminal resize events. +func ResizeEvents(ctx context.Context, termFD uintptr) chan ResizeEvent { + // Use a buffered chan to avoid blocking when we emit the initial resize event. + // We send the event right away while the main routine might not be ready just yet. + events := make(chan ResizeEvent, 1) go func() { - for ctx.Err() == nil { - width, height, err := terminal.GetSize(int(termfd)) - if err != nil { + sigChan := make(chan os.Signal, 16) // Arbitrary large buffer size to allow for "continuous" resizing without blocking. + defer close(sigChan) + + // Terminal resize event are notified using the SIGWINCH signal, start watching for it. + signal.Notify(sigChan, unix.SIGWINCH) + defer signal.Stop(sigChan) + + // Emit an inital signal event to make sure the server receives our current window size. + select { + case <-ctx.Done(): + return + case sigChan <- unix.SIGWINCH: + } + + for { + select { + case <-ctx.Done(): return - } - events <- ResizeEvent{ - Height: uint16(height), - Width: uint16(width), - } + case <-sigChan: + width, height, err := terminal.GetSize(int(termFD)) + if err != nil { + return + } + event := ResizeEvent{ + Height: uint16(height), + Width: uint16(width), + } + select { + case <-ctx.Done(): + return + case events <- event: + } - <-sigs + } } }() diff --git a/internal/x/xterminal/terminal_windows.go b/internal/x/xterminal/terminal_windows.go index a7e9eaef..ba93b8ec 100644 --- a/internal/x/xterminal/terminal_windows.go +++ b/internal/x/xterminal/terminal_windows.go @@ -15,44 +15,45 @@ type State struct { mode uint32 } +// makeRaw sets the terminal in raw mode and returns the previous state so it can be restored. func makeRaw(handle windows.Handle, input bool) (uint32, error) { - var st uint32 - if err := windows.GetConsoleMode(handle, &st); err != nil { + var prevState uint32 + if err := windows.GetConsoleMode(handle, &prevState); err != nil { return 0, err } var raw uint32 if input { - raw = st &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) + raw = prevState &^ (windows.ENABLE_ECHO_INPUT | windows.ENABLE_PROCESSED_INPUT | windows.ENABLE_LINE_INPUT | windows.ENABLE_PROCESSED_OUTPUT) raw |= windows.ENABLE_VIRTUAL_TERMINAL_INPUT } else { - raw = st | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING + raw = prevState | windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING } if err := windows.SetConsoleMode(handle, raw); err != nil { return 0, err } - return st, nil + return prevState, nil } // MakeRaw sets an input terminal to raw and enables VT100 processing. func MakeRaw(handle uintptr) (*State, error) { - inSt, err := makeRaw(windows.Handle(handle), true) + prevState, err := makeRaw(windows.Handle(handle), true) if err != nil { return nil, err } - return &State{inSt}, nil + return &State{mode: prevState}, nil } // MakeOutputRaw sets an output terminal to raw and enables VT100 processing. func MakeOutputRaw(handle uintptr) (*State, error) { - outSt, err := makeRaw(windows.Handle(handle), false) + prevState, err := makeRaw(windows.Handle(handle), false) if err != nil { return nil, err } - return &State{outSt}, nil + return &State{mode: prevState}, nil } // Restore terminal back to original state. @@ -63,16 +64,18 @@ func Restore(handle uintptr, state *State) error { // ColorEnabled returns true if VT100 processing is enabled on the output // console. func ColorEnabled(handle uintptr) (bool, error) { - var st uint32 - if err := windows.GetConsoleMode(windows.Handle(handle), &st); err != nil { + var state uint32 + if err := windows.GetConsoleMode(windows.Handle(handle), &state); err != nil { return false, err } - return st&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0, nil + return state&windows.ENABLE_VIRTUAL_TERMINAL_PROCESSING != 0, nil } +// ResizeEvent represent the new size of the terminal. type ResizeEvent struct { - Height, Width uint16 + Height uint16 + Width uint16 } func (s ResizeEvent) equal(s2 *ResizeEvent) bool { @@ -85,20 +88,25 @@ func (s ResizeEvent) equal(s2 *ResizeEvent) bool { // ResizeEvents sends terminal resize events when the dimensions change. // Windows does not have a unix.SIGWINCH equivalent, so we poll the terminal size // at a fixed interval -func ResizeEvents(ctx context.Context, termfd uintptr) chan ResizeEvent { - events := make(chan ResizeEvent) - ticker := time.NewTicker(time.Millisecond * 100) +func ResizeEvents(ctx context.Context, termFD uintptr) chan ResizeEvent { + // Use a buffered chan to avoid blocking if the main is not ready yet when we send the initial resize event. + events := make(chan ResizeEvent, 1) go func() { + defer close(events) + + // On windows, as we don't have a signal to know the size changed, we + // use a ticker and emit then event if the current size differs from last time we checked. + ticker := time.NewTicker(100 * time.Millisecond) defer ticker.Stop() - var lastEvent *ResizeEvent + var lastEvent *ResizeEvent for { select { case <-ctx.Done(): return case <-ticker.C: - width, height, err := terminal.GetSize(int(windows.Handle(termfd))) + width, height, err := terminal.GetSize(int(windows.Handle(termFD))) if err != nil { return } @@ -107,7 +115,11 @@ func ResizeEvents(ctx context.Context, termfd uintptr) chan ResizeEvent { Width: uint16(width), } if !event.equal(lastEvent) { - events <- event + select { + case <-ctx.Done(): + return + case events <- event: + } } lastEvent = &event } pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy