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" 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