From 410241d715ee5a15ea67efcc02c6729d2e3b6523 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 16 May 2025 11:42:29 +0000 Subject: [PATCH 1/4] feat: show devcontainer dirty status and allow recreate Updates #16424 --- agent/agentcontainers/acmock/acmock.go | 49 ++++++++++- agent/agentcontainers/acmock/doc.go | 2 +- agent/agentcontainers/api.go | 41 +++++---- agent/agentcontainers/api_test.go | 11 ++- coderd/apidoc/docs.go | 43 ++++++++++ coderd/apidoc/swagger.json | 39 +++++++++ coderd/coderd.go | 1 + coderd/workspaceagents.go | 87 +++++++++++++++++++ coderd/workspaceagents_test.go | 111 +++++++++++++++++++++++++ codersdk/workspaceagents.go | 17 ++++ docs/reference/api/agents.md | 28 +++++++ docs/reference/api/schemas.md | 29 ++++--- site/src/api/typesGenerated.ts | 1 + site/src/testHelpers/entities.ts | 1 + 14 files changed, 425 insertions(+), 35 deletions(-) diff --git a/agent/agentcontainers/acmock/acmock.go b/agent/agentcontainers/acmock/acmock.go index 93c84e8c54fd3..869d2f7d0923b 100644 --- a/agent/agentcontainers/acmock/acmock.go +++ b/agent/agentcontainers/acmock/acmock.go @@ -1,9 +1,9 @@ // Code generated by MockGen. DO NOT EDIT. -// Source: .. (interfaces: Lister) +// Source: .. (interfaces: Lister,DevcontainerCLI) // // Generated by this command: // -// mockgen -destination ./acmock.go -package acmock .. Lister +// mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI // // Package acmock is a generated GoMock package. @@ -13,6 +13,7 @@ import ( context "context" reflect "reflect" + agentcontainers "github.com/coder/coder/v2/agent/agentcontainers" codersdk "github.com/coder/coder/v2/codersdk" gomock "go.uber.org/mock/gomock" ) @@ -55,3 +56,47 @@ func (mr *MockListerMockRecorder) List(ctx any) *gomock.Call { mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockLister)(nil).List), ctx) } + +// MockDevcontainerCLI is a mock of DevcontainerCLI interface. +type MockDevcontainerCLI struct { + ctrl *gomock.Controller + recorder *MockDevcontainerCLIMockRecorder + isgomock struct{} +} + +// MockDevcontainerCLIMockRecorder is the mock recorder for MockDevcontainerCLI. +type MockDevcontainerCLIMockRecorder struct { + mock *MockDevcontainerCLI +} + +// NewMockDevcontainerCLI creates a new mock instance. +func NewMockDevcontainerCLI(ctrl *gomock.Controller) *MockDevcontainerCLI { + mock := &MockDevcontainerCLI{ctrl: ctrl} + mock.recorder = &MockDevcontainerCLIMockRecorder{mock} + return mock +} + +// EXPECT returns an object that allows the caller to indicate expected use. +func (m *MockDevcontainerCLI) EXPECT() *MockDevcontainerCLIMockRecorder { + return m.recorder +} + +// Up mocks base method. +func (m *MockDevcontainerCLI) Up(ctx context.Context, workspaceFolder, configPath string, opts ...agentcontainers.DevcontainerCLIUpOptions) (string, error) { + m.ctrl.T.Helper() + varargs := []any{ctx, workspaceFolder, configPath} + for _, a := range opts { + varargs = append(varargs, a) + } + ret := m.ctrl.Call(m, "Up", varargs...) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// Up indicates an expected call of Up. +func (mr *MockDevcontainerCLIMockRecorder) Up(ctx, workspaceFolder, configPath any, opts ...any) *gomock.Call { + mr.mock.ctrl.T.Helper() + varargs := append([]any{ctx, workspaceFolder, configPath}, opts...) + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Up", reflect.TypeOf((*MockDevcontainerCLI)(nil).Up), varargs...) +} diff --git a/agent/agentcontainers/acmock/doc.go b/agent/agentcontainers/acmock/doc.go index 47679708b0fc8..b807efa253b75 100644 --- a/agent/agentcontainers/acmock/doc.go +++ b/agent/agentcontainers/acmock/doc.go @@ -1,4 +1,4 @@ // Package acmock contains a mock implementation of agentcontainers.Lister for use in tests. package acmock -//go:generate mockgen -destination ./acmock.go -package acmock .. Lister +//go:generate mockgen -destination ./acmock.go -package acmock .. Lister,DevcontainerCLI diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index c3393c3fdec9e..d413eba67473e 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -336,7 +336,8 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC } // Check if the container is running and update the known devcontainers. - for _, container := range updated.Containers { + for i := range updated.Containers { + container := &updated.Containers[i] workspaceFolder := container.Labels[DevcontainerLocalFolderLabel] configFile := container.Labels[DevcontainerConfigFileLabel] @@ -344,6 +345,20 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC continue } + container.DevcontainerDirty = dirtyStates[workspaceFolder] + if container.DevcontainerDirty { + lastModified, hasModTime := api.configFileModifiedTimes[configFile] + if hasModTime && container.CreatedAt.After(lastModified) { + api.logger.Info(ctx, "new container created after config modification, not marking as dirty", + slog.F("container", container.ID), + slog.F("created_at", container.CreatedAt), + slog.F("config_modified_at", lastModified), + slog.F("file", configFile), + ) + container.DevcontainerDirty = false + } + } + // Check if this is already in our known list. if knownIndex := slices.IndexFunc(api.knownDevcontainers, func(dc codersdk.WorkspaceAgentDevcontainer) bool { return dc.WorkspaceFolder == workspaceFolder @@ -356,7 +371,7 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC } } api.knownDevcontainers[knownIndex].Running = container.Running - api.knownDevcontainers[knownIndex].Container = &container + api.knownDevcontainers[knownIndex].Container = container // Check if this container was created after the config // file was modified. @@ -395,28 +410,14 @@ func (api *API) getContainers(ctx context.Context) (codersdk.WorkspaceAgentListC } } - dirty := dirtyStates[workspaceFolder] - if dirty { - lastModified, hasModTime := api.configFileModifiedTimes[configFile] - if hasModTime && container.CreatedAt.After(lastModified) { - api.logger.Info(ctx, "new container created after config modification, not marking as dirty", - slog.F("container", container.ID), - slog.F("created_at", container.CreatedAt), - slog.F("config_modified_at", lastModified), - slog.F("file", configFile), - ) - dirty = false - } - } - api.knownDevcontainers = append(api.knownDevcontainers, codersdk.WorkspaceAgentDevcontainer{ ID: uuid.New(), Name: name, WorkspaceFolder: workspaceFolder, ConfigPath: configFile, Running: container.Running, - Dirty: dirty, - Container: &container, + Dirty: container.DevcontainerDirty, + Container: container, }) } @@ -510,6 +511,7 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques slog.F("name", api.knownDevcontainers[i].Name), ) api.knownDevcontainers[i].Dirty = false + api.knownDevcontainers[i].Container = nil } return } @@ -579,6 +581,9 @@ func (api *API) markDevcontainerDirty(configPath string, modifiedAt time.Time) { slog.F("modified_at", modifiedAt), ) api.knownDevcontainers[i].Dirty = true + if api.knownDevcontainers[i].Container != nil { + api.knownDevcontainers[i].Container.DevcontainerDirty = true + } } } }) diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 2c602de5cff3a..1a8863909c8e6 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -660,6 +660,9 @@ func TestAPI(t *testing.T) { require.NoError(t, err) require.Len(t, response.Devcontainers, 1) assert.False(t, response.Devcontainers[0].Dirty, + "devcontainer should not be marked as dirty initially") + require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") + assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty, "container should not be marked as dirty initially") // Verify the watcher is watching the config file. @@ -689,6 +692,9 @@ func TestAPI(t *testing.T) { require.Len(t, response.Devcontainers, 1) assert.True(t, response.Devcontainers[0].Dirty, "container should be marked as dirty after config file was modified") + require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") + assert.True(t, response.Devcontainers[0].Container.DevcontainerDirty, + "container should be marked as dirty after config file was modified") mClock.Advance(time.Minute).MustWait(ctx) @@ -707,7 +713,10 @@ func TestAPI(t *testing.T) { require.NoError(t, err) require.Len(t, response.Devcontainers, 1) assert.False(t, response.Devcontainers[0].Dirty, - "dirty flag should be cleared after container recreation") + "dirty flag should be cleared on the devcontainer after container recreation") + require.NotNil(t, response.Devcontainers[0].Container, "container should not be nil") + assert.False(t, response.Devcontainers[0].Container.DevcontainerDirty, + "dirty flag should be cleared on the container after container recreation") }) } diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index f744b988956e9..cb23a4d0a8cd3 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8606,6 +8606,45 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": [ + "application/json" + ], + "tags": [ + "Agents" + ], + "summary": "Recreate devcontainer for workspace agent", + "operationId": "recreate-devcontainer-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Container ID or name", + "name": "container", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -17134,6 +17173,10 @@ const docTemplate = `{ "type": "string", "format": "date-time" }, + "devcontainer_dirty": { + "description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.", + "type": "boolean" + }, "id": { "description": "ID is the unique identifier of the container.", "type": "string" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 1859a4f6f6214..99883cee6d58f 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7605,6 +7605,41 @@ } } }, + "/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": { + "post": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "produces": ["application/json"], + "tags": ["Agents"], + "summary": "Recreate devcontainer for workspace agent", + "operationId": "recreate-devcontainer-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Container ID or name", + "name": "container", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaceagents/{workspaceagent}/coordinate": { "get": { "security": [ @@ -15643,6 +15678,10 @@ "type": "string", "format": "date-time" }, + "devcontainer_dirty": { + "description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.", + "type": "boolean" + }, "id": { "description": "ID is the unique identifier of the container.", "type": "string" diff --git a/coderd/coderd.go b/coderd/coderd.go index cd8253792c354..bffde13d7f484 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1326,6 +1326,7 @@ func New(options *Options) *API { r.Get("/listening-ports", api.workspaceAgentListeningPorts) r.Get("/connection", api.workspaceAgentConnection) r.Get("/containers", api.workspaceAgentListContainers) + r.Post("/containers/devcontainers/container/{container}/recreate", api.workspaceAgentRecreateDevcontainer) r.Get("/coordinate", api.workspaceAgentClientCoordinate) // PTY is part of workspaceAppServer. diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 72a03580121af..8bf8515f25b6a 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/sqlc-dev/pqtype" "golang.org/x/exp/maps" @@ -893,6 +894,92 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, cts) } +// @Summary Recreate devcontainer for workspace agent +// @ID recreate-devcontainer-for-workspace-agent +// @Security CoderSessionToken +// @Produce json +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param container path string true "Container ID or name" +// @Success 204 +// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate [post] +func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgentParam(r) + + container := chi.URLParam(r, "container") + if container == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Container ID or name is required.", + Validations: []codersdk.ValidationError{ + {Field: "container", Detail: "Container ID or name is required."}, + }, + }) + return + } + + 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 + } + + // 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, dialCancel := context.WithTimeout(ctx, 30*time.Second) + defer dialCancel() + agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID) + if err != nil { + httpapi.Write(dialCtx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + err = agentConn.RecreateDevcontainer(ctx, container) + if err != nil { + if errors.Is(err, context.Canceled) { + httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{ + Message: "Failed to recreate devcontainer from agent.", + Detail: "Request timed out.", + }) + return + } + // If the agent returns a codersdk.Error, we can return that directly. + if cerr, ok := codersdk.AsError(err); ok { + httpapi.Write(ctx, rw, cerr.StatusCode(), cerr.Response) + return + } + httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error recreating devcontainer.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // @Summary Get connection info for workspace agent // @ID get-connection-info-for-workspace-agent // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 10403f1ac00ae..23bf5d8412b4f 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -8,6 +8,7 @@ import ( "net" "net/http" "os" + "path/filepath" "runtime" "strconv" "strings" @@ -36,6 +37,7 @@ import ( "github.com/coder/coder/v2/agent" "github.com/coder/coder/v2/agent/agentcontainers" "github.com/coder/coder/v2/agent/agentcontainers/acmock" + "github.com/coder/coder/v2/agent/agentcontainers/watcher" "github.com/coder/coder/v2/agent/agenttest" agentproto "github.com/coder/coder/v2/agent/proto" "github.com/coder/coder/v2/coderd/coderdtest" @@ -1347,6 +1349,115 @@ func TestWorkspaceAgentContainers(t *testing.T) { }) } +func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { + t.Parallel() + + t.Run("Mock", func(t *testing.T) { + t.Parallel() + + var ( + workspaceFolder = t.TempDir() + configFile = filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json") + dcLabels = map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configFile, + } + devContainer = codersdk.WorkspaceAgentContainer{ + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: dcLabels, + Running: true, + Status: "running", + DevcontainerDirty: true, + } + plainContainer = codersdk.WorkspaceAgentContainer{ + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: map[string]string{}, + Running: true, + Status: "running", + } + ) + + for _, tc := range []struct { + name string + setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int) + }{ + { + name: "Recreate", + setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{devContainer}, + }, nil).Times(1) + mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1) + return 0 + }, + }, + { + name: "Container does not exist", + setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).Times(1) + return http.StatusNotFound + }, + }, + { + name: "Not a devcontainer", + setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int { + mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{plainContainer}, + }, nil).Times(1) + return http.StatusNotFound + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctrl := gomock.NewController(t) + mcl := acmock.NewMockLister(ctrl) + mdccli := acmock.NewMockDevcontainerCLI(ctrl) + wantStatus := tc.setupMock(mcl, mdccli) + client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{}) + 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() + _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { + o.ExperimentalDevcontainersEnabled = true + o.ContainerAPIOptions = append( + o.ContainerAPIOptions, + agentcontainers.WithLister(mcl), + agentcontainers.WithDevcontainerCLI(mdccli), + 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 + + ctx := testutil.Context(t, testutil.WaitLong) + + err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID) + if wantStatus > 0 { + cerr, ok := codersdk.AsError(err) + require.True(t, ok, "expected error to be a coder error") + assert.Equal(t, wantStatus, cerr.StatusCode()) + } else { + require.NoError(t, err, "failed to recreate devcontainer") + } + }) + } + }) +} + func TestWorkspaceAgentAppHealth(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index f58338a209901..37048c6c4fcfe 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -439,6 +439,10 @@ type WorkspaceAgentContainer struct { // Volumes is a map of "things" mounted into the container. Again, this // is somewhat implementation-dependent. Volumes map[string]string `json:"volumes"` + // DevcontainerDirty is true if the devcontainer configuration has changed + // since the container was created. This is used to determine if the + // container needs to be rebuilt. + DevcontainerDirty bool `json:"devcontainer_dirty"` } func (c *WorkspaceAgentContainer) Match(idOrName string) bool { @@ -502,6 +506,19 @@ func (c *Client) WorkspaceAgentListContainers(ctx context.Context, agentID uuid. return cr, json.NewDecoder(res.Body).Decode(&cr) } +// WorkspaceAgentRecreateDevcontainer recreates the devcontainer with the given ID. +func (c *Client) WorkspaceAgentRecreateDevcontainer(ctx context.Context, agentID uuid.UUID, containerIDOrName string) error { + res, err := c.Request(ctx, http.MethodPost, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/container/%s/recreate", agentID, containerIDOrName), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return nil +} + //nolint:revive // Follow is a control flag on the server as well. func (c *Client) WorkspaceAgentLogsAfter(ctx context.Context, agentID uuid.UUID, after int64, follow bool) (<-chan []WorkspaceAgentLog, io.Closer, error) { var queryParams []string diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index eced88f4f72cc..f126fec59978c 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -776,6 +776,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con "containers": [ { "created_at": "2019-08-24T14:15:22Z", + "devcontainer_dirty": true, "id": "string", "image": "string", "labels": { @@ -813,6 +814,33 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con To perform this operation, you must be authenticated. [Learn more](authentication.md). +## Recreate devcontainer for workspace agent + +### Code samples + +```shell +# Example request using curl +curl -X POST http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate \ + -H 'Coder-Session-Token: API_KEY' +``` + +`POST /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate` + +### Parameters + +| Name | In | Type | Required | Description | +|------------------|------|--------------|----------|----------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `container` | path | string | true | Container ID or name | + +### Responses + +| Status | Meaning | Description | Schema | +|--------|-----------------------------------------------------------------|-------------|--------| +| 204 | [No Content](https://tools.ietf.org/html/rfc7231#section-6.3.5) | No Content | | + +To perform this operation, you must be authenticated. [Learn more](authentication.md). + ## Coordinate workspace agent ### Code samples diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index a001b7210016d..aa704b0fe6a57 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -8621,6 +8621,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| ```json { "created_at": "2019-08-24T14:15:22Z", + "devcontainer_dirty": true, "id": "string", "image": "string", "labels": { @@ -8647,19 +8648,20 @@ If the schedule is empty, the user will be updated to use the default schedule.| ### Properties -| Name | Type | Required | Restrictions | Description | -|--------------------|---------------------------------------------------------------------------------------|----------|--------------|--------------------------------------------------------------------------------------------------------------------------------------------| -| `created_at` | string | false | | Created at is the time the container was created. | -| `id` | string | false | | ID is the unique identifier of the container. | -| `image` | string | false | | Image is the name of the container image. | -| `labels` | object | false | | Labels is a map of key-value pairs of container labels. | -| » `[any property]` | string | false | | | -| `name` | string | false | | Name is the human-readable name of the container. | -| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. | -| `running` | boolean | false | | Running is true if the container is currently running. | -| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. | -| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. | -| » `[any property]` | string | false | | | +| Name | Type | Required | Restrictions | Description | +|----------------------|---------------------------------------------------------------------------------------|----------|--------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `created_at` | string | false | | Created at is the time the container was created. | +| `devcontainer_dirty` | boolean | false | | Devcontainer dirty is true if the devcontainer configuration has changed since the container was created. This is used to determine if the container needs to be rebuilt. | +| `id` | string | false | | ID is the unique identifier of the container. | +| `image` | string | false | | Image is the name of the container image. | +| `labels` | object | false | | Labels is a map of key-value pairs of container labels. | +| » `[any property]` | string | false | | | +| `name` | string | false | | Name is the human-readable name of the container. | +| `ports` | array of [codersdk.WorkspaceAgentContainerPort](#codersdkworkspaceagentcontainerport) | false | | Ports includes ports exposed by the container. | +| `running` | boolean | false | | Running is true if the container is currently running. | +| `status` | string | false | | Status is the current status of the container. This is somewhat implementation-dependent, but should generally be a human-readable string. | +| `volumes` | object | false | | Volumes is a map of "things" mounted into the container. Again, this is somewhat implementation-dependent. | +| » `[any property]` | string | false | | | ## codersdk.WorkspaceAgentContainerPort @@ -8726,6 +8728,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "containers": [ { "created_at": "2019-08-24T14:15:22Z", + "devcontainer_dirty": true, "id": "string", "image": "string", "labels": { diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 6c09014c4ed6f..92a5d11e7b8d8 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3301,6 +3301,7 @@ export interface WorkspaceAgentContainer { readonly ports: readonly WorkspaceAgentContainerPort[]; readonly status: string; readonly volumes: Record; + readonly devcontainer_dirty: boolean; } // From codersdk/workspaceagents.go diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index b05ec1d869b0d..6351e74d3c54d 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -4384,4 +4384,5 @@ export const MockWorkspaceAgentContainer: TypesGen.WorkspaceAgentContainer = { volumes: { "/mnt/volume1": "/volume1", }, + devcontainer_dirty: false, }; From 2e133c20d2c91d54d700d52af0ac0e391a62fa07 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 16 May 2025 11:52:42 +0000 Subject: [PATCH 2/4] reset mtime to force list refresh --- agent/agentcontainers/api.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index d413eba67473e..cb28df4bd05af 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -511,7 +511,13 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques slog.F("name", api.knownDevcontainers[i].Name), ) api.knownDevcontainers[i].Dirty = false + // TODO(mafredri): This should be handled by a service that + // updates the devcontainer state periodically and on-demand. api.knownDevcontainers[i].Container = nil + // Set the modified time to the zero value to indicate that + // the containers list must be refreshed. This will see to + // it that the new container is re-assigned. + api.mtime = time.Time{} } return } From 66827257632f233ff041b740ca78b13d11149c75 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 16 May 2025 12:25:10 +0000 Subject: [PATCH 3/4] fix import cycle due to acmock usage in internal test --- agent/agentcontainers/api.go | 9 ++ agent/agentcontainers/api_internal_test.go | 163 --------------------- agent/agentcontainers/api_test.go | 163 +++++++++++++++++++++ 3 files changed, 172 insertions(+), 163 deletions(-) delete mode 100644 agent/agentcontainers/api_internal_test.go diff --git a/agent/agentcontainers/api.go b/agent/agentcontainers/api.go index cb28df4bd05af..f2164c9a874ff 100644 --- a/agent/agentcontainers/api.go +++ b/agent/agentcontainers/api.go @@ -69,6 +69,15 @@ func WithClock(clock quartz.Clock) Option { } } +// WithCacheDuration sets the cache duration for the API. +// This is used to control how often the API refreshes the list of +// containers. The default is 10 seconds. +func WithCacheDuration(d time.Duration) Option { + return func(api *API) { + api.cacheDuration = d + } +} + // WithExecer sets the agentexec.Execer implementation to use. func WithExecer(execer agentexec.Execer) Option { return func(api *API) { diff --git a/agent/agentcontainers/api_internal_test.go b/agent/agentcontainers/api_internal_test.go deleted file mode 100644 index 331c41e8df10b..0000000000000 --- a/agent/agentcontainers/api_internal_test.go +++ /dev/null @@ -1,163 +0,0 @@ -package agentcontainers - -import ( - "math/rand" - "strings" - "testing" - "time" - - "github.com/google/uuid" - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - "go.uber.org/mock/gomock" - - "cdr.dev/slog" - "cdr.dev/slog/sloggers/slogtest" - "github.com/coder/coder/v2/agent/agentcontainers/acmock" - "github.com/coder/coder/v2/codersdk" - "github.com/coder/coder/v2/testutil" - "github.com/coder/quartz" -) - -func TestAPI(t *testing.T) { - t.Parallel() - - // List tests the API.getContainers method using a mock - // implementation. It specifically tests caching behavior. - t.Run("List", func(t *testing.T) { - t.Parallel() - - fakeCt := fakeContainer(t) - fakeCt2 := fakeContainer(t) - makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { - return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} - } - - // Each test case is called multiple times to ensure idempotency - for _, tc := range []struct { - name string - // data to be stored in the handler - cacheData codersdk.WorkspaceAgentListContainersResponse - // duration of cache - cacheDur time.Duration - // relative age of the cached data - cacheAge time.Duration - // function to set up expectations for the mock - setupMock func(*acmock.MockLister) - // expected result - expected codersdk.WorkspaceAgentListContainersResponse - // expected error - expectedErr string - }{ - { - name: "no cache", - setupMock: func(mcl *acmock.MockLister) { - mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() - }, - expected: makeResponse(fakeCt), - }, - { - name: "no data", - cacheData: makeResponse(), - cacheAge: 2 * time.Second, - cacheDur: time.Second, - setupMock: func(mcl *acmock.MockLister) { - mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).AnyTimes() - }, - expected: makeResponse(fakeCt), - }, - { - name: "cached data", - cacheAge: time.Second, - cacheData: makeResponse(fakeCt), - cacheDur: 2 * time.Second, - expected: makeResponse(fakeCt), - }, - { - name: "lister error", - setupMock: func(mcl *acmock.MockLister) { - mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).AnyTimes() - }, - expectedErr: assert.AnError.Error(), - }, - { - name: "stale cache", - cacheAge: 2 * time.Second, - cacheData: makeResponse(fakeCt), - cacheDur: time.Second, - setupMock: func(mcl *acmock.MockLister) { - mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).AnyTimes() - }, - expected: makeResponse(fakeCt2), - }, - } { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - var ( - ctx = testutil.Context(t, testutil.WaitShort) - clk = quartz.NewMock(t) - ctrl = gomock.NewController(t) - mockLister = acmock.NewMockLister(ctrl) - now = time.Now().UTC() - logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug) - api = NewAPI(logger, WithLister(mockLister)) - ) - defer api.Close() - - api.cacheDuration = tc.cacheDur - api.clock = clk - api.containers = tc.cacheData - if tc.cacheAge != 0 { - api.mtime = now.Add(-tc.cacheAge) - } - if tc.setupMock != nil { - tc.setupMock(mockLister) - } - - clk.Set(now).MustWait(ctx) - - // Repeat the test to ensure idempotency - for i := 0; i < 2; i++ { - actual, err := api.getContainers(ctx) - if tc.expectedErr != "" { - require.Empty(t, actual, "expected no data (attempt %d)", i) - require.ErrorContains(t, err, tc.expectedErr, "expected error (attempt %d)", i) - } else { - require.NoError(t, err, "expected no error (attempt %d)", i) - require.Equal(t, tc.expected, actual, "expected containers to be equal (attempt %d)", i) - } - } - }) - } - }) -} - -func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer { - t.Helper() - ct := codersdk.WorkspaceAgentContainer{ - CreatedAt: time.Now().UTC(), - ID: uuid.New().String(), - FriendlyName: testutil.GetRandomName(t), - Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0], - Labels: map[string]string{ - testutil.GetRandomName(t): testutil.GetRandomName(t), - }, - Running: true, - Ports: []codersdk.WorkspaceAgentContainerPort{ - { - Network: "tcp", - Port: testutil.RandomPortNoListen(t), - HostPort: testutil.RandomPortNoListen(t), - //nolint:gosec // this is a test - HostIP: []string{"127.0.0.1", "[::1]", "localhost", "0.0.0.0", "[::]", testutil.GetRandomName(t)}[rand.Intn(6)], - }, - }, - Status: testutil.MustRandString(t, 10), - Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)}, - } - for _, m := range mut { - m(&ct) - } - return ct -} diff --git a/agent/agentcontainers/api_test.go b/agent/agentcontainers/api_test.go index 1a8863909c8e6..2e173b7d5a6b4 100644 --- a/agent/agentcontainers/api_test.go +++ b/agent/agentcontainers/api_test.go @@ -3,8 +3,10 @@ package agentcontainers_test import ( "context" "encoding/json" + "math/rand" "net/http" "net/http/httptest" + "strings" "testing" "time" @@ -13,11 +15,13 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" "github.com/coder/coder/v2/agent/agentcontainers" + "github.com/coder/coder/v2/agent/agentcontainers/acmock" "github.com/coder/coder/v2/agent/agentcontainers/watcher" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/testutil" @@ -146,6 +150,136 @@ func (w *fakeWatcher) sendEventWaitNextCalled(ctx context.Context, event fsnotif func TestAPI(t *testing.T) { t.Parallel() + // List tests the API.getContainers method using a mock + // implementation. It specifically tests caching behavior. + t.Run("List", func(t *testing.T) { + t.Parallel() + + fakeCt := fakeContainer(t) + fakeCt2 := fakeContainer(t) + makeResponse := func(cts ...codersdk.WorkspaceAgentContainer) codersdk.WorkspaceAgentListContainersResponse { + return codersdk.WorkspaceAgentListContainersResponse{Containers: cts} + } + + // Each test case is called multiple times to ensure idempotency + for _, tc := range []struct { + name string + // data to be stored in the handler + cacheData codersdk.WorkspaceAgentListContainersResponse + // duration of cache + cacheDur time.Duration + // relative age of the cached data + cacheAge time.Duration + // function to set up expectations for the mock + setupMock func(mcl *acmock.MockLister, preReq *gomock.Call) + // expected result + expected codersdk.WorkspaceAgentListContainersResponse + // expected error + expectedErr string + }{ + { + name: "no cache", + setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() + }, + expected: makeResponse(fakeCt), + }, + { + name: "no data", + cacheData: makeResponse(), + cacheAge: 2 * time.Second, + cacheDur: time.Second, + setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt), nil).After(preReq).AnyTimes() + }, + expected: makeResponse(fakeCt), + }, + { + name: "cached data", + cacheAge: time.Second, + cacheData: makeResponse(fakeCt), + cacheDur: 2 * time.Second, + expected: makeResponse(fakeCt), + }, + { + name: "lister error", + setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(), assert.AnError).After(preReq).AnyTimes() + }, + expectedErr: assert.AnError.Error(), + }, + { + name: "stale cache", + cacheAge: 2 * time.Second, + cacheData: makeResponse(fakeCt), + cacheDur: time.Second, + setupMock: func(mcl *acmock.MockLister, preReq *gomock.Call) { + mcl.EXPECT().List(gomock.Any()).Return(makeResponse(fakeCt2), nil).After(preReq).AnyTimes() + }, + expected: makeResponse(fakeCt2), + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var ( + ctx = testutil.Context(t, testutil.WaitShort) + clk = quartz.NewMock(t) + ctrl = gomock.NewController(t) + mockLister = acmock.NewMockLister(ctrl) + now = time.Now().UTC() + logger = slogtest.Make(t, nil).Leveled(slog.LevelDebug) + r = chi.NewRouter() + api = agentcontainers.NewAPI(logger, + agentcontainers.WithCacheDuration(tc.cacheDur), + agentcontainers.WithClock(clk), + agentcontainers.WithLister(mockLister), + ) + ) + defer api.Close() + + r.Mount("/", api.Routes()) + + preReq := mockLister.EXPECT().List(gomock.Any()).Return(tc.cacheData, nil).Times(1) + if tc.setupMock != nil { + tc.setupMock(mockLister, preReq) + } + + if tc.cacheAge != 0 { + clk.Set(now.Add(-tc.cacheAge)).MustWait(ctx) + } else { + clk.Set(now).MustWait(ctx) + } + + // Prime the cache with the initial data. + req := httptest.NewRequest(http.MethodGet, "/", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + + clk.Set(now).MustWait(ctx) + + // Repeat the test to ensure idempotency + for i := 0; i < 2; i++ { + req = httptest.NewRequest(http.MethodGet, "/", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + + if tc.expectedErr != "" { + got := &codersdk.Error{} + err := json.NewDecoder(rec.Body).Decode(got) + require.NoError(t, err, "unmarshal response failed") + require.ErrorContains(t, got, tc.expectedErr, "expected error (attempt %d)", i) + } else { + var got codersdk.WorkspaceAgentListContainersResponse + err := json.NewDecoder(rec.Body).Decode(&got) + require.NoError(t, err, "unmarshal response failed") + require.Equal(t, tc.expected, got, "expected containers to be equal (attempt %d)", i) + } + } + }) + } + }) + t.Run("Recreate", func(t *testing.T) { t.Parallel() @@ -734,3 +868,32 @@ func mustFindDevcontainerByPath(t *testing.T, devcontainers []codersdk.Workspace require.Failf(t, "no devcontainer found with workspace folder %q", path) return codersdk.WorkspaceAgentDevcontainer{} // Unreachable, but required for compilation } + +func fakeContainer(t *testing.T, mut ...func(*codersdk.WorkspaceAgentContainer)) codersdk.WorkspaceAgentContainer { + t.Helper() + ct := codersdk.WorkspaceAgentContainer{ + CreatedAt: time.Now().UTC(), + ID: uuid.New().String(), + FriendlyName: testutil.GetRandomName(t), + Image: testutil.GetRandomName(t) + ":" + strings.Split(uuid.New().String(), "-")[0], + Labels: map[string]string{ + testutil.GetRandomName(t): testutil.GetRandomName(t), + }, + Running: true, + Ports: []codersdk.WorkspaceAgentContainerPort{ + { + Network: "tcp", + Port: testutil.RandomPortNoListen(t), + HostPort: testutil.RandomPortNoListen(t), + //nolint:gosec // this is a test + HostIP: []string{"127.0.0.1", "[::1]", "localhost", "0.0.0.0", "[::]", testutil.GetRandomName(t)}[rand.Intn(6)], + }, + }, + Status: testutil.MustRandString(t, 10), + Volumes: map[string]string{testutil.GetRandomName(t): testutil.GetRandomName(t)}, + } + for _, m := range mut { + m(&ct) + } + return ct +} From 19c460287c6fa598b2c7ca1942570739c14edab9 Mon Sep 17 00:00:00 2001 From: Mathias Fredriksson Date: Fri, 16 May 2025 13:38:45 +0000 Subject: [PATCH 4/4] fix swagger --- coderd/apidoc/docs.go | 3 --- coderd/apidoc/swagger.json | 1 - coderd/workspaceagents.go | 1 - 3 files changed, 5 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index cb23a4d0a8cd3..d55582afbbe8b 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8613,9 +8613,6 @@ const docTemplate = `{ "CoderSessionToken": [] } ], - "produces": [ - "application/json" - ], "tags": [ "Agents" ], diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 99883cee6d58f..00f940737a1d6 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7612,7 +7612,6 @@ "CoderSessionToken": [] } ], - "produces": ["application/json"], "tags": ["Agents"], "summary": "Recreate devcontainer for workspace agent", "operationId": "recreate-devcontainer-for-workspace-agent", diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index 8bf8515f25b6a..8b94566e75715 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -897,7 +897,6 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req // @Summary Recreate devcontainer for workspace agent // @ID recreate-devcontainer-for-workspace-agent // @Security CoderSessionToken -// @Produce json // @Tags Agents // @Param workspaceagent path string true "Workspace agent ID" format(uuid) // @Param container path string true "Container ID or name"