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..7d64f909 100644 --- a/coder-sdk/error.go +++ b/coder-sdk/error.go @@ -11,9 +11,10 @@ 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"` + Msg string `json:"msg"` } `json:"error"` } @@ -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 }