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)