diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index d749bf88a522e..dc92a4d38d9a2 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -2,8 +2,10 @@ package agentcontainers import ( "context" + "encoding/json" "errors" "fmt" + "maps" "net/http" "os" "path" @@ -30,6 +32,7 @@ import ( "github.com/coder/coder/v2/codersdk/agentsdk" "github.com/coder/coder/v2/provisioner" "github.com/coder/quartz" + "github.com/coder/websocket" ) const ( @@ -74,6 +77,7 @@ type API struct { mu sync.RWMutex // Protects the following fields. initDone chan struct{} // Closed by Init. + updateChans []chan struct{} closed bool containers codersdk.WorkspaceAgentListContainersResponse // Output from the last list operation. containersErr error // Error from the last list operation. @@ -535,6 +539,7 @@ func (api *API) Routes() http.Handler { r.Use(ensureInitDoneMW) r.Get("/", api.handleList) + r.Get("/watch", api.watchContainers) // TODO(mafredri): Simplify this route as the previous /devcontainers // /-route was dropped. We can drop the /devcontainers prefix here too. r.Route("/devcontainers/{devcontainer}", func(r chi.Router) { @@ -544,6 +549,88 @@ func (api *API) Routes() http.Handler { return r } +func (api *API) broadcastUpdatesLocked() { + // Broadcast state changes to WebSocket listeners. + for _, ch := range api.updateChans { + select { + case ch <- struct{}{}: + default: + } + } +} + +func (api *API) watchContainers(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upgrade connection to websocket.", + Detail: err.Error(), + }) + return + } + + // Here we close the websocket for reading, so that the websocket library will handle pings and + // close frames. + _ = conn.CloseRead(context.Background()) + + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) + defer wsNetConn.Close() + + go httpapi.Heartbeat(ctx, conn) + + updateCh := make(chan struct{}, 1) + + api.mu.Lock() + api.updateChans = append(api.updateChans, updateCh) + api.mu.Unlock() + + defer func() { + api.mu.Lock() + api.updateChans = slices.DeleteFunc(api.updateChans, func(ch chan struct{}) bool { + return ch == updateCh + }) + close(updateCh) + api.mu.Unlock() + }() + + encoder := json.NewEncoder(wsNetConn) + + ct, err := api.getContainers() + if err != nil { + api.logger.Error(ctx, "unable to get containers", slog.Error(err)) + return + } + + if err := encoder.Encode(ct); err != nil { + api.logger.Error(ctx, "encode container list", slog.Error(err)) + return + } + + for { + select { + case <-api.ctx.Done(): + return + + case <-ctx.Done(): + return + + case <-updateCh: + ct, err := api.getContainers() + if err != nil { + api.logger.Error(ctx, "unable to get containers", slog.Error(err)) + continue + } + + if err := encoder.Encode(ct); err != nil { + api.logger.Error(ctx, "encode container list", slog.Error(err)) + return + } + } + } +} + // handleList handles the HTTP request to list containers. func (api *API) handleList(rw http.ResponseWriter, r *http.Request) { ct, err := api.getContainers() @@ -583,8 +670,26 @@ func (api *API) updateContainers(ctx context.Context) error { api.mu.Lock() defer api.mu.Unlock() + var previouslyKnownDevcontainers map[string]codersdk.WorkspaceAgentDevcontainer + if len(api.updateChans) > 0 { + previouslyKnownDevcontainers = maps.Clone(api.knownDevcontainers) + } + api.processUpdatedContainersLocked(ctx, updated) + if len(api.updateChans) > 0 { + statesAreEqual := maps.EqualFunc( + previouslyKnownDevcontainers, + api.knownDevcontainers, + func(dc1, dc2 codersdk.WorkspaceAgentDevcontainer) bool { + return dc1.Equals(dc2) + }) + + if !statesAreEqual { + api.broadcastUpdatesLocked() + } + } + api.logger.Debug(ctx, "containers updated successfully", slog.F("container_count", len(api.containers.Containers)), slog.F("warning_count", len(api.containers.Warnings)), slog.F("devcontainer_count", len(api.knownDevcontainers))) return nil @@ -955,6 +1060,8 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques dc.Container = nil dc.Error = "" api.knownDevcontainers[dc.WorkspaceFolder] = dc + api.broadcastUpdatesLocked() + go func() { _ = api.CreateDevcontainer(dc.WorkspaceFolder, dc.ConfigPath, WithRemoveExistingContainer()) }() @@ -1070,6 +1177,7 @@ func (api *API) CreateDevcontainer(workspaceFolder, configPath string, opts ...D dc.Error = "" api.recreateSuccessTimes[dc.WorkspaceFolder] = api.clock.Now("agentcontainers", "recreate", "successTimes") api.knownDevcontainers[dc.WorkspaceFolder] = dc + api.broadcastUpdatesLocked() api.mu.Unlock() // Ensure an immediate refresh to accurately reflect the diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 37ce66e2c150b..75b9342379a35 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -36,6 +36,7 @@ import ( "github.com/coder/coder/v2/pty" "github.com/coder/coder/v2/testutil" "github.com/coder/quartz" + "github.com/coder/websocket" ) // fakeContainerCLI implements the agentcontainers.ContainerCLI interface for @@ -441,6 +442,178 @@ func TestAPI(t *testing.T) { logbuf.Reset() }) + t.Run("Watch", func(t *testing.T) { + t.Parallel() + + fakeContainer1 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) { + c.ID = "container1" + c.FriendlyName = "devcontainer1" + c.Image = "busybox:latest" + c.Labels = map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", + } + }) + + fakeContainer2 := fakeContainer(t, func(c *codersdk.WorkspaceAgentContainer) { + c.ID = "container2" + c.FriendlyName = "devcontainer2" + c.Image = "ubuntu:latest" + c.Labels = map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", + } + }) + + stages := []struct { + containers []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse + }{ + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "stopped", + Container: nil, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + } + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + mClock = quartz.NewMock(t) + updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop") + mCtrl = gomock.NewController(t) + mLister = acmock.NewMockContainerCLI(mCtrl) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + ) + + // Set up initial state for immediate send on connection + mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil) + mLister.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + + api := agentcontainers.NewAPI(logger, + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mLister), + agentcontainers.WithWatcher(watcher.NewNoop()), + ) + api.Start() + defer api.Close() + + srv := httptest.NewServer(api.Routes()) + defer srv.Close() + + updaterTickerTrap.MustWait(ctx).MustRelease(ctx) + defer updaterTickerTrap.Close() + + client, res, err := websocket.Dial(ctx, srv.URL+"/watch", nil) + require.NoError(t, err) + if res != nil && res.Body != nil { + defer res.Body.Close() + } + + // Read initial state sent immediately on connection + mt, msg, err := client.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, mt) + + var got codersdk.WorkspaceAgentListContainersResponse + err = json.Unmarshal(msg, &got) + require.NoError(t, err) + + require.Equal(t, stages[0].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) + for j, expectedDev := range stages[0].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + + // Process remaining stages through updater loop + for i, stage := range stages[1:] { + mLister.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) + + // Given: We allow the update loop to progress + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + // When: We attempt to read a message from the socket. + mt, msg, err := client.Read(ctx) + require.NoError(t, err) + require.Equal(t, websocket.MessageText, mt) + + // Then: We expect the receieved message matches the expected response. + var got codersdk.WorkspaceAgentListContainersResponse + err = json.Unmarshal(msg, &got) + require.NoError(t, err) + + require.Equal(t, stages[i+1].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) + for j, expectedDev := range stages[i+1].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + } + }) + // List tests the API.getContainers method using a mock // implementation. It specifically tests caching behavior. t.Run("List", func(t *testing.T) { diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 79cff80b1fbc5..63de31ddcdd42 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8778,6 +8778,41 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Watch workspace agent for container updates.", + "operationId": "watch-workspace-agent-for-container-updates", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 5fa1d98030cb5..fddab50bea546 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7751,6 +7751,37 @@ } } }, + "/workspaceagents/{workspaceagent}/containers/watch": { + "get": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Watch workspace agent for container updates.", + "operationId": "watch-workspace-agent-for-container-updates", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + } + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/codersdk.WorkspaceAgentListContainersResponse" + } + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 72316d1ea18e5..61a5e7f65c522 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1351,6 +1351,7 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/containers", api.workspaceAgentListContainers) + r.Get("/containers/watch", api.watchWorkspaceAgentContainers) r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer) r.Get("/coordinate", api.workspaceAgentClientCoordinate) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 0ab28b340a1d1..3ae57d8394d43 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -801,6 +801,106 @@ func (api *API) workspaceAgentListeningPorts(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, portsResponse) } +// @Summary Watch workspace agent for container updates. +// @ID watch-workspace-agent-for-container-updates +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Success 200 {object} codersdk.WorkspaceAgentListContainersResponse +// @Router /workspaceagents/{workspaceagent}/containers/watch [get] +func (api *API) watchWorkspaceAgentContainers(rw http.ResponseWriter, r *http.Request) { + var ( + ctx = r.Context() + workspaceAgent = httpmw.WorkspaceAgentParam(r) + ) + + // If the agent is unreachable, the request will hang. Assume that if we + // don't get a response after 30s that the agent is unreachable. + dialCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + apiAgent, err := db2sdk.WorkspaceAgent( + api.DERPMap(), + *api.TailnetCoordinator.Load(), + workspaceAgent, + nil, + nil, + nil, + api.AgentInactiveDisconnectTimeout, + api.DeploymentValues.AgentFallbackTroubleshootingURL.String(), + ) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error reading workspace agent.", + Detail: err.Error(), + }) + return + } + if apiAgent.Status != codersdk.WorkspaceAgentConnected { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected), + }) + return + } + + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + watcherLogger := api.Logger.Named("agent_container_watcher").With(slog.F("agent_id", workspaceAgent.ID)) + containersCh, closer, err := agentConn.WatchContainers(ctx, watcherLogger) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error watching agent's containers.", + Detail: err.Error(), + }) + return + } + defer closer.Close() + + conn, err := websocket.Accept(rw, r, nil) + if err != nil { + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Failed to upgrade connection to websocket.", + Detail: err.Error(), + }) + return + } + + // Here we close the websocket for reading, so that the websocket library will handle pings and + // close frames. + _ = conn.CloseRead(context.Background()) + + ctx, wsNetConn := codersdk.WebsocketNetConn(ctx, conn, websocket.MessageText) + defer wsNetConn.Close() + + go httpapi.Heartbeat(ctx, conn) + + encoder := json.NewEncoder(wsNetConn) + + for { + select { + case <-api.ctx.Done(): + return + + case <-ctx.Done(): + return + + case containers := <-containersCh: + if err := encoder.Encode(containers); err != nil { + api.Logger.Error(ctx, "encode containers", slog.Error(err)) + return + } + } + } +} + // @Summary Get running containers for workspace agent // @ID get-running-containers-for-workspace-agent // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 899c863cc5fd6..30859cb6391e6 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1386,6 +1386,192 @@ func TestWorkspaceAgentContainers(t *testing.T) { }) } +func TestWatchWorkspaceAgentDevcontainers(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + mClock = quartz.NewMock(t) + updaterTickerTrap = mClock.Trap().TickerFunc("updaterLoop") + mCtrl = gomock.NewController(t) + mCCLI = acmock.NewMockContainerCLI(mCtrl) + + client, db = coderdtest.NewWithDatabase(t, &coderdtest.Options{Logger: &logger}) + user = coderdtest.CreateFirstUser(t, client) + r = dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{ + OrganizationID: user.OrganizationID, + OwnerID: user.UserID, + }).WithAgent(func(agents []*proto.Agent) []*proto.Agent { + return agents + }).Do() + + fakeContainer1 = codersdk.WorkspaceAgentContainer{ + ID: "container1", + CreatedAt: dbtime.Now(), + FriendlyName: "container1", + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project1", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project1/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } + + fakeContainer2 = codersdk.WorkspaceAgentContainer{ + ID: "container1", + CreatedAt: dbtime.Now(), + FriendlyName: "container2", + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: "/home/coder/project2", + agentcontainers.DevcontainerConfigFileLabel: "/home/coder/project2/.devcontainer/devcontainer.json", + }, + Running: true, + Status: "running", + } + ) + + stages := []struct { + containers []codersdk.WorkspaceAgentContainer + expected codersdk.WorkspaceAgentListContainersResponse + }{ + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer1, fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "project1", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer1, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + { + containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + expected: codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{fakeContainer2}, + Devcontainers: []codersdk.WorkspaceAgentDevcontainer{ + { + Name: "", + WorkspaceFolder: fakeContainer1.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer1.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "stopped", + Container: nil, + }, + { + Name: "project2", + WorkspaceFolder: fakeContainer2.Labels[agentcontainers.DevcontainerLocalFolderLabel], + ConfigPath: fakeContainer2.Labels[agentcontainers.DevcontainerConfigFileLabel], + Status: "running", + Container: &fakeContainer2, + }, + }, + }, + }, + } + + // Set up initial state for immediate send on connection + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stages[0].containers}, nil) + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), gomock.Any()).Return("", nil).AnyTimes() + + _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { + o.Logger = logger.Named("agent") + o.Devcontainers = true + o.DevcontainerAPIOptions = []agentcontainers.Option{ + agentcontainers.WithClock(mClock), + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + } + }) + + resources := coderdtest.NewWorkspaceAgentWaiter(t, client, r.Workspace.ID).Wait() + require.Len(t, resources, 1, "expected one resource") + require.Len(t, resources[0].Agents, 1, "expected one agent") + agentID := resources[0].Agents[0].ID + + updaterTickerTrap.MustWait(ctx).MustRelease(ctx) + defer updaterTickerTrap.Close() + + containers, closer, err := client.WatchWorkspaceAgentContainers(ctx, agentID) + require.NoError(t, err) + defer func() { + closer.Close() + }() + + // Read initial state sent immediately on connection + var got codersdk.WorkspaceAgentListContainersResponse + select { + case <-ctx.Done(): + case got = <-containers: + } + require.NoError(t, ctx.Err()) + + require.Equal(t, stages[0].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[0].expected.Devcontainers)) + for j, expectedDev := range stages[0].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + + // Process remaining stages through updater loop + for i, stage := range stages[1:] { + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{Containers: stage.containers}, nil) + + _, aw := mClock.AdvanceNext() + aw.MustWait(ctx) + + var got codersdk.WorkspaceAgentListContainersResponse + select { + case <-ctx.Done(): + case got = <-containers: + } + require.NoError(t, ctx.Err()) + + require.Equal(t, stages[i+1].expected.Containers, got.Containers) + require.Len(t, got.Devcontainers, len(stages[i+1].expected.Devcontainers)) + for j, expectedDev := range stages[i+1].expected.Devcontainers { + gotDev := got.Devcontainers[j] + require.Equal(t, expectedDev.Name, gotDev.Name) + require.Equal(t, expectedDev.WorkspaceFolder, gotDev.WorkspaceFolder) + require.Equal(t, expectedDev.ConfigPath, gotDev.ConfigPath) + require.Equal(t, expectedDev.Status, gotDev.Status) + require.Equal(t, expectedDev.Container, gotDev.Container) + } + } +} + func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { t.Parallel() diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 2bfae8aac36cf..1eb37bb07c989 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -421,6 +421,19 @@ type WorkspaceAgentDevcontainer struct { Error string `json:"error,omitempty"` } +func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) bool { + return d.ID == other.ID && + d.Name == other.Name && + d.WorkspaceFolder == other.WorkspaceFolder && + d.Status == other.Status && + d.Dirty == other.Dirty && + (d.Container == nil && other.Container == nil || + (d.Container != nil && other.Container != nil && d.Container.ID == other.Container.ID)) && + (d.Agent == nil && other.Agent == nil || + (d.Agent != nil && other.Agent != nil && *d.Agent == *other.Agent)) && + d.Error == other.Error +} + // WorkspaceAgentDevcontainerAgent represents the sub agent for a // devcontainer. type WorkspaceAgentDevcontainerAgent struct { @@ -520,6 +533,40 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid. return cr, json.NewDecoder(res.Body).Decode(&cr) } +func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid.UUID) (<-chan WorkspaceAgentListContainersResponse, io.Closer, error) { + reqURL, err := c.URL.Parse(fmt.Sprintf("/api/v2/workspaceagents/%s/containers/watch", agentID)) + if err != nil { + return nil, nil, err + } + + jar, err := cookiejar.New(nil) + if err != nil { + return nil, nil, xerrors.Errorf("create cookie jar: %w", err) + } + + jar.SetCookies(reqURL, []*http.Cookie{{ + Name: SessionTokenCookie, + Value: c.SessionToken(), + }}) + + conn, res, err := websocket.Dial(ctx, reqURL.String(), &websocket.DialOptions{ + CompressionMode: websocket.CompressionDisabled, + HTTPClient: &http.Client{ + Jar: jar, + Transport: c.HTTPClient.Transport, + }, + }) + if err != nil { + if res == nil { + return nil, nil, err + } + return nil, nil, ReadBodyAsError(res) + } + + d := wsjson.NewDecoder[WorkspaceAgentListContainersResponse](conn, websocket.MessageText, c.logger) + return d.Chan(), d, nil +} + // WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID. func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) (Response, error) { res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s/recreate", agentID, devcontainerID), nil) diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index ee0b36e5a0c23..ce66d5e1b8a70 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -20,10 +20,14 @@ import ( "tailscale.com/ipn/ipnstate" "tailscale.com/net/speedtest" + "cdr.dev/slog" + "github.com/coder/coder/v2/coderd/tracing" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/codersdk/healthsdk" + "github.com/coder/coder/v2/codersdk/wsjson" "github.com/coder/coder/v2/tailnet" + "github.com/coder/websocket" ) // NewAgentConn creates a new WorkspaceAgentConn. `conn` may be unique @@ -387,6 +391,30 @@ func (c *AgentConn) ListContainers(ctx context.Context) (codersdk.WorkspaceAgent return resp, json.NewDecoder(res.Body).Decode(&resp) } +func (c *AgentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<-chan codersdk.WorkspaceAgentListContainersResponse, io.Closer, error) { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + + host := net.JoinHostPort(c.agentAddress().String(), strconv.Itoa(AgentHTTPAPIServerPort)) + url := fmt.Sprintf("http://%s%s", host, "/api/v0/containers/watch") + + conn, res, err := websocket.Dial(ctx, url, &websocket.DialOptions{ + HTTPClient: c.apiClient(), + }) + if err != nil { + if res == nil { + return nil, nil, err + } + return nil, nil, codersdk.ReadBodyAsError(res) + } + if res != nil && res.Body != nil { + defer res.Body.Close() + } + + d := wsjson.NewDecoder[codersdk.WorkspaceAgentListContainersResponse](conn, websocket.MessageText, logger) + return d.Chan(), d, nil +} + // RecreateDevcontainer recreates a devcontainer with the given container. // This is a blocking call and will wait for the container to be recreated. func (c *AgentConn) RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) { diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index cff5fef6f3f8a..54e9b0e6ad628 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -899,6 +899,111 @@ curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/co To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Watch workspace agent for container updates + +### Code samples + +```shell +# Example request using curl +curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/watch \ + -H 'Accept: application/json' \ + -H 'Coder-Session-Token: API_KEY' +``` + +`GET /workspaceagents/{workspaceagent}/containers/watch` + +### Parameters + +| Name | In | Type | Required | Description | +|------------------|------|--------------|----------|--------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | + +### Example responses + +> 200 Response + +```json +{ + "containers": [ + { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + } + ], + "devcontainers": [ + { + "agent": { + "directory": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string" + }, + "config_path": "string", + "container": { + "created_at": "2019-08-24T14:15:22Z", + "id": "string", + "image": "string", + "labels": { + "property1": "string", + "property2": "string" + }, + "name": "string", + "ports": [ + { + "host_ip": "string", + "host_port": 0, + "network": "string", + "port": 0 + } + ], + "running": true, + "status": "string", + "volumes": { + "property1": "string", + "property2": "string" + } + }, + "dirty": true, + "error": "string", + "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "name": "string", + "status": "running", + "workspace_folder": "string" + } + ], + "warnings": [ + "string" + ] +} +``` + +### Responses + +| Status | Meaning | Description | Schema | +|--------|---------------------------------------------------------|-------------|----------------------------------------------------------------------------------------------------------| +| 200 | [OK](https://tools.ietf.org/html/rfc7231#section-6.3.1) | OK | [codersdk.WorkspaceAgentListContainersResponse](schemas.md#codersdkworkspaceagentlistcontainersresponse) | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Coordinate workspace agent ### Code samples diff --git a/site/src/api/api.ts b/site/src/api/api.ts index dd8d3d77998d2..7c10188648121 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -129,6 +129,14 @@ export const watchWorkspace = ( }); }; +export const watchAgentContainers = ( + agentId: string, +): OneWayWebSocket => { + return new OneWayWebSocket({ + apiRoute: `/api/v2/workspaceagents/${agentId}/containers/watch`, + }); +}; + type WatchInboxNotificationsParams = Readonly<{ read_status?: "read" | "unread" | "all"; }>; diff --git a/site/src/modules/resources/AgentDevcontainerCard.tsx b/site/src/modules/resources/AgentDevcontainerCard.tsx index c7516dde15c39..bd2f05b123cad 100644 --- a/site/src/modules/resources/AgentDevcontainerCard.tsx +++ b/site/src/modules/resources/AgentDevcontainerCard.tsx @@ -130,12 +130,6 @@ export const AgentDevcontainerCard: FC = ({ return { previousData }; }, - onSuccess: async () => { - // Invalidate the containers query to refetch updated data. - await queryClient.invalidateQueries({ - queryKey: ["agents", parentAgent.id, "containers"], - }); - }, onError: (error, _, context) => { // If the mutation fails, use the context returned from // onMutate to roll back. diff --git a/site/src/modules/resources/AgentRow.tsx b/site/src/modules/resources/AgentRow.tsx index 3d0888f7872b1..0b5d8a5dc15c3 100644 --- a/site/src/modules/resources/AgentRow.tsx +++ b/site/src/modules/resources/AgentRow.tsx @@ -2,14 +2,12 @@ import type { Interpolation, Theme } from "@emotion/react"; import Collapse from "@mui/material/Collapse"; import Divider from "@mui/material/Divider"; import Skeleton from "@mui/material/Skeleton"; -import { API } from "api/api"; import type { Template, Workspace, WorkspaceAgent, WorkspaceAgentMetadata, } from "api/typesGenerated"; -import { isAxiosError } from "axios"; import { Button } from "components/Button/Button"; import { DropdownArrow } from "components/DropdownArrow/DropdownArrow"; import { Stack } from "components/Stack/Stack"; @@ -25,7 +23,6 @@ import { useRef, useState, } from "react"; -import { useQuery } from "react-query"; import AutoSizer from "react-virtualized-auto-sizer"; import type { FixedSizeList as List, ListOnScrollProps } from "react-window"; import { AgentApps, organizeAgentApps } from "./AgentApps/AgentApps"; @@ -41,6 +38,7 @@ import { PortForwardButton } from "./PortForwardButton"; import { AgentSSHButton } from "./SSHButton/SSHButton"; import { TerminalLink } from "./TerminalLink/TerminalLink"; import { VSCodeDesktopButton } from "./VSCodeDesktopButton/VSCodeDesktopButton"; +import { useAgentContainers } from "./useAgentContainers"; import { useAgentLogs } from "./useAgentLogs"; interface AgentRowProps { @@ -133,20 +131,7 @@ export const AgentRow: FC = ({ setBottomOfLogs(distanceFromBottom < AGENT_LOG_LINE_HEIGHT); }, []); - const { data: devcontainers } = useQuery({ - queryKey: ["agents", agent.id, "containers"], - queryFn: () => API.getAgentContainers(agent.id), - enabled: agent.status === "connected", - select: (res) => res.devcontainers, - // TODO: Implement a websocket connection to get updates on containers - // without having to poll. - refetchInterval: ({ state }) => { - const { error } = state; - return isAxiosError(error) && error.response?.status === 403 - ? false - : 10_000; - }, - }); + const devcontainers = useAgentContainers(agent); // This is used to show the parent apps of the devcontainer. const [showParentApps, setShowParentApps] = useState(false); diff --git a/site/src/modules/resources/useAgentContainers.test.tsx b/site/src/modules/resources/useAgentContainers.test.tsx new file mode 100644 index 0000000000000..922941e04c074 --- /dev/null +++ b/site/src/modules/resources/useAgentContainers.test.tsx @@ -0,0 +1,196 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import * as API from "api/api"; +import type { WorkspaceAgentListContainersResponse } from "api/typesGenerated"; +import * as GlobalSnackbar from "components/GlobalSnackbar/utils"; +import { http, HttpResponse } from "msw"; +import type { FC, PropsWithChildren } from "react"; +import { QueryClient, QueryClientProvider } from "react-query"; +import { + MockWorkspaceAgent, + MockWorkspaceAgentDevcontainer, +} from "testHelpers/entities"; +import { server } from "testHelpers/server"; +import type { OneWayWebSocket } from "utils/OneWayWebSocket"; +import { useAgentContainers } from "./useAgentContainers"; + +const createWrapper = (): FC => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }); + return ({ children }) => ( + {children} + ); +}; + +describe("useAgentContainers", () => { + it("returns containers when agent is connected", async () => { + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { result } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(result.current).toEqual([MockWorkspaceAgentDevcontainer]); + }); + }); + + it("returns undefined when agent is not connected", () => { + const disconnectedAgent = { + ...MockWorkspaceAgent, + status: "disconnected" as const, + }; + + const { result } = renderHook(() => useAgentContainers(disconnectedAgent), { + wrapper: createWrapper(), + }); + + expect(result.current).toBeUndefined(); + }); + + it("handles API errors gracefully", async () => { + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.error(); + }, + ), + ); + + const { result } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + await waitFor(() => { + expect(result.current).toBeUndefined(); + }); + }); + + it("handles parsing errors from WebSocket", async () => { + const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); + + const mockSocket = { + addEventListener: jest.fn(), + close: jest.fn(), + }; + watchAgentContainersSpy.mockReturnValue( + mockSocket as unknown as OneWayWebSocket, + ); + + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { unmount } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + // Simulate message event with parsing error + const messageHandler = mockSocket.addEventListener.mock.calls.find( + (call) => call[0] === "message", + )?.[1]; + + if (messageHandler) { + messageHandler({ + parseError: new Error("Parse error"), + parsedMessage: null, + }); + } + + await waitFor(() => { + expect(displayErrorSpy).toHaveBeenCalledWith( + "Failed to update containers", + "Please try refreshing the page", + ); + }); + + unmount(); + displayErrorSpy.mockRestore(); + watchAgentContainersSpy.mockRestore(); + }); + + it("handles WebSocket errors", async () => { + const displayErrorSpy = jest.spyOn(GlobalSnackbar, "displayError"); + const watchAgentContainersSpy = jest.spyOn(API, "watchAgentContainers"); + + const mockSocket = { + addEventListener: jest.fn(), + close: jest.fn(), + }; + watchAgentContainersSpy.mockReturnValue( + mockSocket as unknown as OneWayWebSocket, + ); + + server.use( + http.get( + `/api/v2/workspaceagents/${MockWorkspaceAgent.id}/containers`, + () => { + return HttpResponse.json({ + devcontainers: [MockWorkspaceAgentDevcontainer], + containers: [], + }); + }, + ), + ); + + const { unmount } = renderHook( + () => useAgentContainers(MockWorkspaceAgent), + { + wrapper: createWrapper(), + }, + ); + + // Simulate error event + const errorHandler = mockSocket.addEventListener.mock.calls.find( + (call) => call[0] === "error", + )?.[1]; + + if (errorHandler) { + errorHandler(new Error("WebSocket error")); + } + + await waitFor(() => { + expect(displayErrorSpy).toHaveBeenCalledWith( + "Failed to load containers", + "Please try refreshing the page", + ); + }); + + unmount(); + displayErrorSpy.mockRestore(); + watchAgentContainersSpy.mockRestore(); + }); +}); diff --git a/site/src/modules/resources/useAgentContainers.ts b/site/src/modules/resources/useAgentContainers.ts new file mode 100644 index 0000000000000..0db4e2fc4b613 --- /dev/null +++ b/site/src/modules/resources/useAgentContainers.ts @@ -0,0 +1,59 @@ +import { API, watchAgentContainers } from "api/api"; +import type { + WorkspaceAgent, + WorkspaceAgentDevcontainer, + WorkspaceAgentListContainersResponse, +} from "api/typesGenerated"; +import { displayError } from "components/GlobalSnackbar/utils"; +import { useEffectEvent } from "hooks/hookPolyfills"; +import { useEffect } from "react"; +import { useQuery, useQueryClient } from "react-query"; + +export function useAgentContainers( + agent: WorkspaceAgent, +): readonly WorkspaceAgentDevcontainer[] | undefined { + const queryClient = useQueryClient(); + + const { data: devcontainers } = useQuery({ + queryKey: ["agents", agent.id, "containers"], + queryFn: () => API.getAgentContainers(agent.id), + enabled: agent.status === "connected", + select: (res) => res.devcontainers, + staleTime: Number.POSITIVE_INFINITY, + }); + + const updateDevcontainersCache = useEffectEvent( + async (data: WorkspaceAgentListContainersResponse) => { + const queryKey = ["agents", agent.id, "containers"]; + + queryClient.setQueryData(queryKey, data); + }, + ); + + useEffect(() => { + const socket = watchAgentContainers(agent.id); + + socket.addEventListener("message", (event) => { + if (event.parseError) { + displayError( + "Failed to update containers", + "Please try refreshing the page", + ); + return; + } + + updateDevcontainersCache(event.parsedMessage); + }); + + socket.addEventListener("error", () => { + displayError( + "Failed to load containers", + "Please try refreshing the page", + ); + }); + + return () => socket.close(); + }, [agent.id, updateDevcontainersCache]); + + return devcontainers; +} 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