diff --git a/coderd/coderd.go b/coderd/coderd.go index 1f1a4ff18dfac..cc411ef71e255 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -310,6 +310,7 @@ func New(options *Options) (http.Handler, func()) { r.Route("/autostop", func(r chi.Router) { r.Put("/", api.putWorkspaceAutostop) }) + r.Get("/watch", api.watchWorkspace) }) }) r.Route("/workspacebuilds/{workspacebuild}", func(r chi.Router) { diff --git a/coderd/coderd_test.go b/coderd/coderd_test.go index cc34d89ed6bee..23b82b0d374c6 100644 --- a/coderd/coderd_test.go +++ b/coderd/coderd_test.go @@ -128,6 +128,7 @@ func TestAuthorizeAllEndpoints(t *testing.T) { "PUT:/api/v2/workspaces/{workspace}/autostop": {NoAuthorize: true}, "GET:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, "POST:/api/v2/workspaces/{workspace}/builds": {NoAuthorize: true}, + "GET:/api/v2/workspaces/{workspace}/watch": {NoAuthorize: true}, "POST:/api/v2/files": {NoAuthorize: true}, "GET:/api/v2/files/{hash}": {NoAuthorize: true}, diff --git a/coderd/httpmw/apikey.go b/coderd/httpmw/apikey.go index c3038ace73b6a..abf55128d55d7 100644 --- a/coderd/httpmw/apikey.go +++ b/coderd/httpmw/apikey.go @@ -17,8 +17,8 @@ import ( "github.com/coder/coder/coderd/httpapi" ) -// AuthCookie represents the name of the cookie the API key is stored in. -const AuthCookie = "session_token" +// SessionTokenKey represents the name of the cookie or query paramater the API key is stored in. +const SessionTokenKey = "session_token" type apiKeyContextKey struct{} @@ -43,18 +43,24 @@ type OAuth2Configs struct { func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(AuthCookie) + var cookieValue string + cookie, err := r.Cookie(SessionTokenKey) if err != nil { + cookieValue = r.URL.Query().Get(SessionTokenKey) + } else { + cookieValue = cookie.Value + } + if cookieValue == "" { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("%q cookie must be provided", AuthCookie), + Message: fmt.Sprintf("%q cookie or query parameter must be provided", SessionTokenKey), }) return } - parts := strings.Split(cookie.Value, "-") + parts := strings.Split(cookieValue, "-") // APIKeys are formatted: ID-SECRET if len(parts) != 2 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key format", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key format", SessionTokenKey), }) return } @@ -63,13 +69,13 @@ func ExtractAPIKey(db database.Store, oauth *OAuth2Configs) func(http.Handler) h // Ensuring key lengths are valid. if len(keyID) != 10 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key id", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key id", SessionTokenKey), }) return } if len(keySecret) != 22 { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("invalid %q cookie api key secret", AuthCookie), + Message: fmt.Sprintf("invalid %q cookie api key secret", SessionTokenKey), }) return } diff --git a/coderd/httpmw/apikey_test.go b/coderd/httpmw/apikey_test.go index 0c8d8d396e55b..3be2877426134 100644 --- a/coderd/httpmw/apikey_test.go +++ b/coderd/httpmw/apikey_test.go @@ -56,7 +56,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "test-wow-hello", }) @@ -74,7 +74,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "test-wow", }) @@ -92,7 +92,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: "testtestid-wow", }) @@ -111,7 +111,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -130,7 +130,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -157,7 +157,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -182,7 +182,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -209,6 +209,37 @@ func TestAPIKey(t *testing.T) { require.Equal(t, sentAPIKey.ExpiresAt, gotAPIKey.ExpiresAt) }) + t.Run("QueryParameter", func(t *testing.T) { + t.Parallel() + var ( + db = databasefake.New() + id, secret = randomAPIKeyParts() + hashed = sha256.Sum256([]byte(secret)) + r = httptest.NewRequest("GET", "/", nil) + rw = httptest.NewRecorder() + ) + q := r.URL.Query() + q.Add(httpmw.SessionTokenKey, fmt.Sprintf("%s-%s", id, secret)) + r.URL.RawQuery = q.Encode() + + _, err := db.InsertAPIKey(r.Context(), database.InsertAPIKeyParams{ + ID: id, + HashedSecret: hashed[:], + ExpiresAt: database.Now().AddDate(0, 0, 1), + }) + require.NoError(t, err) + httpmw.ExtractAPIKey(db, nil)(http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { + // Checks that it exists on the context! + _ = httpmw.APIKey(r) + httpapi.Write(rw, http.StatusOK, httpapi.Response{ + Message: "it worked!", + }) + })).ServeHTTP(rw, r) + res := rw.Result() + defer res.Body.Close() + require.Equal(t, http.StatusOK, res.StatusCode) + }) + t.Run("ValidUpdateLastUsed", func(t *testing.T) { t.Parallel() var ( @@ -219,7 +250,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -252,7 +283,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -285,7 +316,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) @@ -319,7 +350,7 @@ func TestAPIKey(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/authorize_test.go b/coderd/httpmw/authorize_test.go index 59ee50c80d9be..0e2466da403cd 100644 --- a/coderd/httpmw/authorize_test.go +++ b/coderd/httpmw/authorize_test.go @@ -94,7 +94,7 @@ func TestExtractUserRoles(t *testing.T) { req := httptest.NewRequest("GET", "/", nil) req.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: token, }) diff --git a/coderd/httpmw/organizationparam_test.go b/coderd/httpmw/organizationparam_test.go index b062e63bc3819..2b3a01cc6aaa5 100644 --- a/coderd/httpmw/organizationparam_test.go +++ b/coderd/httpmw/organizationparam_test.go @@ -29,7 +29,7 @@ func TestOrganizationParam(t *testing.T) { hashed = sha256.Sum256([]byte(secret)) ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/templateparam_test.go b/coderd/httpmw/templateparam_test.go index b4db9925391c3..201961ba26c54 100644 --- a/coderd/httpmw/templateparam_test.go +++ b/coderd/httpmw/templateparam_test.go @@ -29,7 +29,7 @@ func TestTemplateParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/templateversionparam_test.go b/coderd/httpmw/templateversionparam_test.go index f168661d8bde8..d4487b183b788 100644 --- a/coderd/httpmw/templateversionparam_test.go +++ b/coderd/httpmw/templateversionparam_test.go @@ -29,7 +29,7 @@ func TestTemplateVersionParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/userparam_test.go b/coderd/httpmw/userparam_test.go index 48e1da72e2142..d7a467d65940c 100644 --- a/coderd/httpmw/userparam_test.go +++ b/coderd/httpmw/userparam_test.go @@ -29,7 +29,7 @@ func TestUserParam(t *testing.T) { rw = httptest.NewRecorder() ) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspaceagent.go b/coderd/httpmw/workspaceagent.go index 05e7fe213c242..8bcf10f086285 100644 --- a/coderd/httpmw/workspaceagent.go +++ b/coderd/httpmw/workspaceagent.go @@ -28,10 +28,10 @@ func WorkspaceAgent(r *http.Request) database.WorkspaceAgent { func ExtractWorkspaceAgent(db database.Store) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(rw http.ResponseWriter, r *http.Request) { - cookie, err := r.Cookie(AuthCookie) + cookie, err := r.Cookie(SessionTokenKey) if err != nil { httpapi.Write(rw, http.StatusUnauthorized, httpapi.Response{ - Message: fmt.Sprintf("%q cookie must be provided", AuthCookie), + Message: fmt.Sprintf("%q cookie must be provided", SessionTokenKey), }) return } diff --git a/coderd/httpmw/workspaceagent_test.go b/coderd/httpmw/workspaceagent_test.go index 650e68436b836..0661183abffcf 100644 --- a/coderd/httpmw/workspaceagent_test.go +++ b/coderd/httpmw/workspaceagent_test.go @@ -22,7 +22,7 @@ func TestWorkspaceAgent(t *testing.T) { token := uuid.New() r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: token.String(), }) return r, token diff --git a/coderd/httpmw/workspaceagentparam_test.go b/coderd/httpmw/workspaceagentparam_test.go index c7f931438901d..c985234824458 100644 --- a/coderd/httpmw/workspaceagentparam_test.go +++ b/coderd/httpmw/workspaceagentparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceAgentParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspacebuildparam_test.go b/coderd/httpmw/workspacebuildparam_test.go index 0e72e02fcc9b9..7ed74e274cc9b 100644 --- a/coderd/httpmw/workspacebuildparam_test.go +++ b/coderd/httpmw/workspacebuildparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceBuildParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/httpmw/workspaceparam_test.go b/coderd/httpmw/workspaceparam_test.go index 2f1f522b5a211..52731dc10f1cb 100644 --- a/coderd/httpmw/workspaceparam_test.go +++ b/coderd/httpmw/workspaceparam_test.go @@ -29,7 +29,7 @@ func TestWorkspaceParam(t *testing.T) { ) r := httptest.NewRequest("GET", "/", nil) r.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: fmt.Sprintf("%s-%s", id, secret), }) diff --git a/coderd/users.go b/coderd/users.go index fbbbde5e250c1..92e2135b2fd37 100644 --- a/coderd/users.go +++ b/coderd/users.go @@ -690,7 +690,7 @@ func (*api) postLogout(rw http.ResponseWriter, _ *http.Request) { cookie := &http.Cookie{ // MaxAge < 0 means to delete the cookie now MaxAge: -1, - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Path: "/", } @@ -748,7 +748,7 @@ func (api *api) createAPIKey(rw http.ResponseWriter, r *http.Request, params dat // This format is consumed by the APIKey middleware. sessionToken := fmt.Sprintf("%s-%s", keyID, keySecret) http.SetCookie(rw, &http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: sessionToken, Path: "/", HttpOnly: true, diff --git a/coderd/users_test.go b/coderd/users_test.go index 9c2846ed96cbd..e0e1033819526 100644 --- a/coderd/users_test.go +++ b/coderd/users_test.go @@ -122,7 +122,7 @@ func TestPostLogout(t *testing.T) { cookies := response.Cookies() require.Len(t, cookies, 1, "Exactly one cookie should be returned") - require.Equal(t, cookies[0].Name, httpmw.AuthCookie, "Cookie should be the auth cookie") + require.Equal(t, cookies[0].Name, httpmw.SessionTokenKey, "Cookie should be the auth cookie") require.Equal(t, cookies[0].MaxAge, -1, "Cookie should be set to delete") }) } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 4ef3b61301fe6..1e567dfe77c40 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -7,12 +7,17 @@ import ( "errors" "fmt" "net/http" + "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/moby/moby/pkg/namesgenerator" "golang.org/x/sync/errgroup" "golang.org/x/xerrors" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" + + "cdr.dev/slog" "github.com/coder/coder/coderd/autobuild/schedule" "github.com/coder/coder/coderd/database" @@ -535,6 +540,95 @@ func (api *api) putWorkspaceAutostop(rw http.ResponseWriter, r *http.Request) { } } +func (api *api) watchWorkspace(rw http.ResponseWriter, r *http.Request) { + workspace := httpmw.WorkspaceParam(r) + + c, err := websocket.Accept(rw, r, &websocket.AcceptOptions{ + // Fix for Safari 15.1: + // There is a bug in latest Safari in which compressed web socket traffic + // isn't handled correctly. Turning off compression is a workaround: + // https://github.com/nhooyr/websocket/issues/218 + CompressionMode: websocket.CompressionDisabled, + }) + if err != nil { + api.Logger.Warn(r.Context(), "accept websocket connection", slog.Error(err)) + return + } + defer c.Close(websocket.StatusInternalError, "internal error") + + // Makes the websocket connection write-only + ctx := c.CloseRead(r.Context()) + + // Send a heartbeat every 15 seconds to avoid the websocket being killed. + go func() { + ticker := time.NewTicker(time.Second * 15) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + err := c.Ping(ctx) + if err != nil { + return + } + } + } + }() + + t := time.NewTicker(time.Second * 1) + defer t.Stop() + for { + select { + case <-t.C: + workspace, err := api.Database.GetWorkspaceByID(r.Context(), workspace.ID) + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("get workspace: %s", err), + }) + return + } + build, err := api.Database.GetLatestWorkspaceBuildByWorkspaceID(r.Context(), workspace.ID) + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("get workspace build: %s", err), + }) + return + } + var ( + group errgroup.Group + job database.ProvisionerJob + template database.Template + owner database.User + ) + group.Go(func() (err error) { + job, err = api.Database.GetProvisionerJobByID(r.Context(), build.JobID) + return err + }) + group.Go(func() (err error) { + template, err = api.Database.GetTemplateByID(r.Context(), workspace.TemplateID) + return err + }) + group.Go(func() (err error) { + owner, err = api.Database.GetUserByID(r.Context(), workspace.OwnerID) + return err + }) + err = group.Wait() + if err != nil { + _ = wsjson.Write(ctx, c, httpapi.Response{ + Message: fmt.Sprintf("fetch resource: %s", err), + }) + return + } + + _ = wsjson.Write(ctx, c, convertWorkspace(workspace, convertWorkspaceBuild(build, convertProvisionerJob(job)), template, owner)) + case <-ctx.Done(): + return + } + } +} + func convertWorkspaces(ctx context.Context, db database.Store, workspaces []database.Workspace) ([]codersdk.Workspace, error) { workspaceIDs := make([]uuid.UUID, 0, len(workspaces)) templateIDs := make([]uuid.UUID, 0, len(workspaces)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index 58140b1d00e1d..6658aa8481940 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -621,3 +621,27 @@ func mustLocation(t *testing.T, location string) *time.Location { return loc } + +func TestWorkspaceWatcher(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + coderdtest.NewProvisionerDaemon(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJob(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, user.OrganizationID, template.ID) + w, err := client.Workspace(context.Background(), workspace.ID) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + wc, err := client.WatchWorkspace(ctx, w.ID) + require.NoError(t, err) + for i := 0; i < 3; i++ { + _, more := <-wc + require.True(t, more) + } + cancel() + require.EqualValues(t, codersdk.Workspace{}, <-wc) +} diff --git a/codersdk/client.go b/codersdk/client.go index 48571adff5d0b..71786de4f0cf4 100644 --- a/codersdk/client.go +++ b/codersdk/client.go @@ -12,6 +12,7 @@ import ( "strings" "golang.org/x/xerrors" + "nhooyr.io/websocket" "github.com/coder/coder/coderd/httpapi" "github.com/coder/coder/coderd/httpmw" @@ -63,7 +64,7 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return nil, xerrors.Errorf("create request: %w", err) } req.AddCookie(&http.Cookie{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }) if body != nil { @@ -80,6 +81,38 @@ func (c *Client) Request(ctx context.Context, method, path string, body interfac return resp, err } +// dialWebsocket opens a dialWebsocket connection on that path provided. +// The caller is responsible for closing the dialWebsocket.Conn. +func (c *Client) dialWebsocket(ctx context.Context, path string) (*websocket.Conn, error) { + serverURL, err := c.URL.Parse(path) + if err != nil { + return nil, xerrors.Errorf("parse path: %w", err) + } + + apiURL, err := url.Parse(serverURL.String()) + if err != nil { + return nil, xerrors.Errorf("parse server url: %w", err) + } + apiURL.Scheme = "ws" + if serverURL.Scheme == "https" { + apiURL.Scheme = "wss" + } + apiURL.Path = path + q := apiURL.Query() + q.Add(httpmw.SessionTokenKey, c.SessionToken) + apiURL.RawQuery = q.Encode() + + //nolint:bodyclose + conn, _, err := websocket.Dial(ctx, apiURL.String(), &websocket.DialOptions{ + HTTPClient: c.HTTPClient, + }) + if err != nil { + return nil, xerrors.Errorf("dial websocket: %w", err) + } + + return conn, nil +} + // readBodyAsError reads the response as an httpapi.Message, and // wraps it in a codersdk.Error type for easy marshaling. func readBodyAsError(res *http.Response) error { diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index d64b42bc5faaa..c634b1de7ea2a 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -188,7 +188,7 @@ func (c *Client) ListenWorkspaceAgent(ctx context.Context, logger slog.Logger) ( return agent.Metadata{}, nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ @@ -263,7 +263,7 @@ func (c *Client) DialWorkspaceAgent(ctx context.Context, agentID uuid.UUID, opti return nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ @@ -351,7 +351,7 @@ func (c *Client) WorkspaceAgentReconnectingPTY(ctx context.Context, agentID, rec return nil, xerrors.Errorf("create cookie jar: %w", err) } jar.SetCookies(serverURL, []*http.Cookie{{ - Name: httpmw.AuthCookie, + Name: httpmw.SessionTokenKey, Value: c.SessionToken, }}) httpClient := &http.Client{ diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index 6e4ab7afd6e57..d43219e0f9d65 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -9,6 +9,8 @@ import ( "github.com/google/uuid" "golang.org/x/xerrors" + "nhooyr.io/websocket" + "nhooyr.io/websocket/wsjson" "github.com/coder/coder/coderd/database" ) @@ -98,6 +100,36 @@ func (c *Client) WorkspaceBuildByName(ctx context.Context, workspace uuid.UUID, return workspaceBuild, json.NewDecoder(res.Body).Decode(&workspaceBuild) } +func (c *Client) WatchWorkspace(ctx context.Context, id uuid.UUID) (<-chan Workspace, error) { + conn, err := c.dialWebsocket(ctx, fmt.Sprintf("/api/v2/workspaces/%s/watch", id)) + if err != nil { + return nil, err + } + wc := make(chan Workspace, 256) + + go func() { + defer close(wc) + defer conn.Close(websocket.StatusNormalClosure, "") + + for { + select { + case <-ctx.Done(): + return + default: + var ws Workspace + err := wsjson.Read(ctx, conn, &ws) + if err != nil { + conn.Close(websocket.StatusInternalError, "failed to read workspace") + return + } + wc <- ws + } + } + }() + + return wc, nil +} + // UpdateWorkspaceAutostartRequest is a request to update a workspace's autostart schedule. type UpdateWorkspaceAutostartRequest struct { Schedule string `json:"schedule"`