feat(coderd): support deleting dev containers (#21248)

Add an endpoint to coderd to support deleting dev containers
This commit is contained in:
Danielle Maywood
2025-12-24 12:34:39 +00:00
committed by GitHub
parent df6b316772
commit 05529139bc
9 changed files with 384 additions and 0 deletions
+36
View File
@@ -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": [
+34
View File
@@ -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": [
+1
View File
@@ -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)
+90
View File
@@ -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
+152
View File
@@ -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("<none>", 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)
+13
View File
@@ -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)
+17
View File
@@ -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) {
@@ -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()
+27
View File
@@ -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