From 05529139bc9b90f01fc0ef4f413adc2f3febdbb7 Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 24 Dec 2025 12:34:39 +0000 Subject: [PATCH] feat(coderd): support deleting dev containers (#21248) Add an endpoint to coderd to support deleting dev containers --- coderd/apidoc/docs.go | 36 +++++ coderd/apidoc/swagger.json | 34 ++++ coderd/coderd.go | 1 + coderd/workspaceagents.go | 90 +++++++++++ coderd/workspaceagents_test.go | 152 ++++++++++++++++++ codersdk/workspaceagents.go | 13 ++ codersdk/workspacesdk/agentconn.go | 17 ++ .../agentconnmock/agentconnmock.go | 14 ++ docs/reference/api/agents.md | 27 ++++ 9 files changed, 384 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 930b7e1fbd..e48f97a138 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -9583,6 +9583,42 @@ const docTemplate = `{ } } }, + "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": [ + "Agents" + ], + "summary": "Delete devcontainer for workspace agent", + "operationId": "delete-devcontainer-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Devcontainer ID", + "name": "devcontainer", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate": { "post": { "security": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 71e6d43c26..577914a491 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -8472,6 +8472,40 @@ } } }, + "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}": { + "delete": { + "security": [ + { + "CoderSessionToken": [] + } + ], + "tags": ["Agents"], + "summary": "Delete devcontainer for workspace agent", + "operationId": "delete-devcontainer-for-workspace-agent", + "parameters": [ + { + "type": "string", + "format": "uuid", + "description": "Workspace agent ID", + "name": "workspaceagent", + "in": "path", + "required": true + }, + { + "type": "string", + "description": "Devcontainer ID", + "name": "devcontainer", + "in": "path", + "required": true + } + ], + "responses": { + "204": { + "description": "No Content" + } + } + } + }, "/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}/recreate": { "post": { "security": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index 720f1236a6..5101800e4d 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1442,6 +1442,7 @@ func New(options *Options) *API { r.Get("/connection", api.workspaceAgentConnection) r.Get("/containers", api.workspaceAgentListContainers) r.Get("/containers/watch", api.watchWorkspaceAgentContainers) + r.Delete("/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer) r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer) r.Get("/coordinate", api.workspaceAgentClientCoordinate) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index d3cca07066..779a19ee00 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -1122,6 +1122,96 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req httpapi.Write(ctx, rw, http.StatusOK, cts) } +// @Summary Delete devcontainer for workspace agent +// @ID delete-devcontainer-for-workspace-agent +// @Security CoderSessionToken +// @Tags Agents +// @Param workspaceagent path string true "Workspace agent ID" format(uuid) +// @Param devcontainer path string true "Devcontainer ID" +// @Success 204 +// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer} [delete] +func (api *API) workspaceAgentDeleteDevcontainer(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + workspaceAgent := httpmw.WorkspaceAgentParam(r) + workspace := httpmw.WorkspaceParam(r) + + if !api.Authorize(r, policy.ActionUpdate, workspace) { + httpapi.Forbidden(rw) + return + } + + devcontainer := chi.URLParam(r, "devcontainer") + if devcontainer == "" { + httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ + Message: "Devcontainer ID is required.", + Validations: []codersdk.ValidationError{ + {Field: "devcontainer", Detail: "Devcontainer ID 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(ctx, rw, http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error dialing workspace agent.", + Detail: err.Error(), + }) + return + } + defer release() + + if err = agentConn.DeleteDevcontainer(ctx, devcontainer); err != nil { + if errors.Is(err, context.Canceled) { + httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{ + Message: "Failed to delete 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 deleting devcontainer.", + Detail: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusNoContent, nil) +} + // @Summary Recreate devcontainer for workspace agent // @ID recreate-devcontainer-for-workspace-agent // @Security CoderSessionToken diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 1166e1ab77..d71fcd0985 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1571,6 +1571,158 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }) } +func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) { + t.Parallel() + + const ( + workspaceFolder = "/home/coder/coder" + ) + configFile := filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json") + + setupDevcontainerMocks := func(t *testing.T) ( + *gomock.Controller, + *acmock.MockContainerCLI, + *acmock.MockDevcontainerCLI, + codersdk.WorkspaceAgentContainer, + codersdk.WorkspaceAgentDevcontainer, + []agentcontainers.Option, + ) { + devcontainerID := uuid.New() + devContainer := codersdk.WorkspaceAgentContainer{ + ID: uuid.NewString(), + CreatedAt: dbtime.Now(), + FriendlyName: testutil.GetRandomName(t), + Image: "busybox:latest", + Labels: map[string]string{ + agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder, + agentcontainers.DevcontainerConfigFileLabel: configFile, + }, + Running: true, + Status: "running", + } + devcontainer := codersdk.WorkspaceAgentDevcontainer{ + ID: devcontainerID, + Name: "test-devcontainer", + WorkspaceFolder: workspaceFolder, + ConfigPath: configFile, + Status: codersdk.WorkspaceAgentDevcontainerStatusRunning, + Container: &devContainer, + } + + mCtrl := gomock.NewController(t) + mCCLI := acmock.NewMockContainerCLI(mCtrl) + mDCCLI := acmock.NewMockDevcontainerCLI(mCtrl) + + mCCLI.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{devContainer}, + }, nil).AnyTimes() + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() + mDCCLI.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).AnyTimes() + + devcontainerAPIOptions := []agentcontainers.Option{ + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(mDCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithDevcontainers([]codersdk.WorkspaceAgentDevcontainer{devcontainer}, nil), + } + + return mCtrl, mCCLI, mDCCLI, devContainer, devcontainer, devcontainerAPIOptions + } + + tests := []struct { + name string + startAgent bool + useAnotherUser bool + expectError bool + expectedStatus int + }{ + { + name: "OK", + startAgent: true, + useAnotherUser: false, + expectError: false, + }, + { + name: "Forbidden", + startAgent: true, + useAnotherUser: true, + expectError: true, + expectedStatus: http.StatusNotFound, + }, + { + name: "AgentNotConnected", + startAgent: false, + useAnotherUser: false, + expectError: true, + expectedStatus: http.StatusBadRequest, + }, + } + + for _, tc := range tests { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + ctx := testutil.Context(t, testutil.WaitLong) + logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + 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() + + _, mCCLI, _, devContainer, devcontainer, devcontainerAPIOptions := setupDevcontainerMocks(t) + + var agentID uuid.UUID + if tc.startAgent { + _ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) { + o.Logger = logger.Named("agent") + o.Devcontainers = true + o.DevcontainerAPIOptions = devcontainerAPIOptions + }) + 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 + + if !tc.expectError { + // Set up expectations for Stop and Remove when expecting success. + mCCLI.EXPECT().Stop(gomock.Any(), devContainer.ID).Return(nil).Times(1) + mCCLI.EXPECT().Remove(gomock.Any(), devContainer.ID).Return(nil).Times(1) + } + } else { + // When not starting an agent, get the agent ID from the workspace resources. + ws, err := client.Workspace(ctx, r.Workspace.ID) + require.NoError(t, err, "failed to get workspace") + require.Len(t, ws.LatestBuild.Resources, 1, "expected one resource") + require.Len(t, ws.LatestBuild.Resources[0].Agents, 1, "expected one agent") + agentID = ws.LatestBuild.Resources[0].Agents[0].ID + } + + testClient := client + if tc.useAnotherUser { + testClient, _ = coderdtest.CreateAnotherUser(t, client, user.OrganizationID) + } + + err := testClient.WorkspaceAgentDeleteDevcontainer(ctx, agentID, devcontainer.ID.String()) + + if tc.expectError { + require.Error(t, err) + var sdkErr *codersdk.Error + require.ErrorAs(t, err, &sdkErr) + require.Equal(t, tc.expectedStatus, sdkErr.StatusCode()) + } else { + require.NoError(t, err, "failed to delete 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 657493eadf..d37629a3fe 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -588,6 +588,19 @@ func (c *Client) WatchWorkspaceAgentContainers(ctx context.Context, agentID uuid return d.Chan(), d, nil } +// WorkspaceAgentDeleteDevcontainer deletes the devcontainer with the given ID. +func (c *Client) WorkspaceAgentDeleteDevcontainer(ctx context.Context, agentID uuid.UUID, devcontainerID string) error { + res, err := c.Request(ctx, http.MethodDelete, fmt.Sprintf("/api/v2/workspaceagents/%s/containers/devcontainers/%s", agentID, devcontainerID), nil) + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return ReadBodyAsError(res) + } + return 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 dbfb833e44..559dbb8ff0 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -60,6 +60,7 @@ type AgentConn interface { Ping(ctx context.Context) (time.Duration, bool, *ipnstate.PingResult, error) PrometheusMetrics(ctx context.Context) ([]byte, error) ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error) + DeleteDevcontainer(ctx context.Context, devcontainerID string) error RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error) LS(ctx context.Context, path string, req LSRequest) (LSResponse, error) ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error) @@ -461,6 +462,22 @@ func (c *agentConn) WatchContainers(ctx context.Context, logger slog.Logger) (<- return d.Chan(), d, nil } +// DeleteDevcontainer deletes the provided devcontainer. +// This is a blocking call and will wait for the container to be deleted. +func (c *agentConn) DeleteDevcontainer(ctx context.Context, devcontainerID string) error { + ctx, span := tracing.StartSpan(ctx) + defer span.End() + res, err := c.apiRequest(ctx, http.MethodDelete, "/api/v0/containers/devcontainers/"+devcontainerID, nil) + if err != nil { + return xerrors.Errorf("do request: %w", err) + } + defer res.Body.Close() + if res.StatusCode != http.StatusNoContent { + return codersdk.ReadBodyAsError(res) + } + return 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/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index cf6b4c72be..f1e300dfe0 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -126,6 +126,20 @@ func (mr *MockAgentConnMockRecorder) DebugManifest(ctx any) *gomock.Call { return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DebugManifest", reflect.TypeOf((*MockAgentConn)(nil).DebugManifest), ctx) } +// DeleteDevcontainer mocks base method. +func (m *MockAgentConn) DeleteDevcontainer(ctx context.Context, devcontainerID string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "DeleteDevcontainer", ctx, devcontainerID) + ret0, _ := ret[0].(error) + return ret0 +} + +// DeleteDevcontainer indicates an expected call of DeleteDevcontainer. +func (mr *MockAgentConnMockRecorder) DeleteDevcontainer(ctx, devcontainerID any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteDevcontainer", reflect.TypeOf((*MockAgentConn)(nil).DeleteDevcontainer), ctx, devcontainerID) +} + // DialContext mocks base method. func (m *MockAgentConn) DialContext(ctx context.Context, network, addr string) (net.Conn, error) { m.ctrl.T.Helper() diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 06c91b9a7a..75b495fcfb 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -855,6 +855,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). +## Delete devcontainer for workspace agent + +### Code samples + +```shell +# Example request using curl +curl -X DELETE http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer} \ + -H 'Coder-Session-Token: API_KEY' +``` + +`DELETE /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}` + +### Parameters + +| Name | In | Type | Required | Description | +|------------------|------|--------------|----------|--------------------| +| `workspaceagent` | path | string(uuid) | true | Workspace agent ID | +| `devcontainer` | path | string | true | Devcontainer ID | + +### 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). + ## Recreate devcontainer for workspace agent ### Code samples