Skip to content

Commit 265aeb8

Browse files
feat(coderd): support deleting dev containers
1 parent 0fcab3f commit 265aeb8

File tree

10 files changed

+476
-0
lines changed

10 files changed

+476
-0
lines changed

coderd/apidoc/docs.go

Lines changed: 36 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/apidoc/swagger.json

Lines changed: 34 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/coderd.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1440,6 +1440,7 @@ func New(options *Options) *API {
14401440
r.Get("/connection", api.workspaceAgentConnection)
14411441
r.Get("/containers", api.workspaceAgentListContainers)
14421442
r.Get("/containers/watch", api.watchWorkspaceAgentContainers)
1443+
r.Delete("/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer)
14431444
r.Post("/containers/devcontainers/{devcontainer}/recreate", api.workspaceAgentRecreateDevcontainer)
14441445
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
14451446

coderd/workspaceagents.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1122,6 +1122,90 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req
11221122
httpapi.Write(ctx, rw, http.StatusOK, cts)
11231123
}
11241124

1125+
// @Summary Delete devcontainer for workspace agent
1126+
// @ID delete-devcontainer-for-workspace-agent
1127+
// @Security CoderSessionToken
1128+
// @Tags Agents
1129+
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
1130+
// @Param devcontainer path string true "Devcontainer ID"
1131+
// @Success 204
1132+
// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer} [delete]
1133+
func (api *API) workspaceAgentDeleteDevcontainer(rw http.ResponseWriter, r *http.Request) {
1134+
ctx := r.Context()
1135+
workspaceAgent := httpmw.WorkspaceAgentParam(r)
1136+
1137+
devcontainer := chi.URLParam(r, "devcontainer")
1138+
if devcontainer == "" {
1139+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
1140+
Message: "Devcontainer ID is required.",
1141+
Validations: []codersdk.ValidationError{
1142+
{Field: "devcontainer", Detail: "Devcontainer ID is required."},
1143+
},
1144+
})
1145+
return
1146+
}
1147+
1148+
apiAgent, err := db2sdk.WorkspaceAgent(
1149+
api.DERPMap(),
1150+
*api.TailnetCoordinator.Load(),
1151+
workspaceAgent,
1152+
nil,
1153+
nil,
1154+
nil,
1155+
api.AgentInactiveDisconnectTimeout,
1156+
api.DeploymentValues.AgentFallbackTroubleshootingURL.String(),
1157+
)
1158+
if err != nil {
1159+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1160+
Message: "Internal error reading workspace agent.",
1161+
Detail: err.Error(),
1162+
})
1163+
return
1164+
}
1165+
if apiAgent.Status != codersdk.WorkspaceAgentConnected {
1166+
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
1167+
Message: fmt.Sprintf("Agent state is %q, it must be in the %q state.", apiAgent.Status, codersdk.WorkspaceAgentConnected),
1168+
})
1169+
return
1170+
}
1171+
1172+
// If the agent is unreachable, the request will hang. Assume that if we
1173+
// don't get a response after 30s that the agent is unreachable.
1174+
dialCtx, dialCancel := context.WithTimeout(ctx, 30*time.Second)
1175+
defer dialCancel()
1176+
agentConn, release, err := api.agentProvider.AgentConn(dialCtx, workspaceAgent.ID)
1177+
if err != nil {
1178+
httpapi.Write(dialCtx, rw, http.StatusInternalServerError, codersdk.Response{
1179+
Message: "Internal error dialing workspace agent.",
1180+
Detail: err.Error(),
1181+
})
1182+
return
1183+
}
1184+
defer release()
1185+
1186+
if err = agentConn.DeleteDevcontainer(ctx, devcontainer); err != nil {
1187+
if errors.Is(err, context.Canceled) {
1188+
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
1189+
Message: "Failed to delete devcontainer from agent.",
1190+
Detail: "Request timed out.",
1191+
})
1192+
return
1193+
}
1194+
// If the agent returns a codersdk.Error, we can return that directly.
1195+
if cerr, ok := codersdk.AsError(err); ok {
1196+
httpapi.Write(ctx, rw, cerr.StatusCode(), cerr.Response)
1197+
return
1198+
}
1199+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
1200+
Message: "Internal error deleting devcontainer.",
1201+
Detail: err.Error(),
1202+
})
1203+
return
1204+
}
1205+
1206+
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
1207+
}
1208+
11251209
// @Summary Recreate devcontainer for workspace agent
11261210
// @ID recreate-devcontainer-for-workspace-agent
11271211
// @Security CoderSessionToken

coderd/workspaceagents_internal_test.go

Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"bytes"
55
"context"
66
"database/sql"
7+
"encoding/json"
78
"fmt"
89
"io"
910
"net/http"
@@ -17,6 +18,7 @@ import (
1718
"github.com/google/uuid"
1819
"github.com/stretchr/testify/require"
1920
"go.uber.org/mock/gomock"
21+
"golang.org/x/xerrors"
2022

2123
"cdr.dev/slog"
2224
"cdr.dev/slog/sloggers/slogtest"
@@ -319,3 +321,152 @@ func TestWatchAgentContainers(t *testing.T) {
319321
}
320322
})
321323
}
324+
325+
func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) {
326+
t.Parallel()
327+
328+
tests := []struct {
329+
name string
330+
agentConnected bool // Controls FirstConnectedAt/LastConnectedAt validity
331+
agentConnError error // Error returned by fakeAgentProvider.AgentConn (nil = success)
332+
deleteError error // Error returned by DeleteDevcontainer mock (nil = success)
333+
expectedStatusCode int
334+
}{
335+
{
336+
name: "OK",
337+
agentConnected: true,
338+
agentConnError: nil,
339+
deleteError: nil,
340+
expectedStatusCode: http.StatusNoContent,
341+
},
342+
{
343+
name: "AgentNotConnected",
344+
agentConnected: false,
345+
expectedStatusCode: http.StatusBadRequest,
346+
},
347+
{
348+
name: "DevcontainerNotFound",
349+
agentConnected: true,
350+
deleteError: func() error {
351+
body, _ := json.Marshal(codersdk.Response{
352+
Message: "Devcontainer not found.",
353+
})
354+
return codersdk.ReadBodyAsError(&http.Response{
355+
StatusCode: http.StatusNotFound,
356+
Body: io.NopCloser(bytes.NewReader(body)),
357+
Request: &http.Request{URL: &url.URL{}},
358+
})
359+
}(),
360+
expectedStatusCode: http.StatusNotFound,
361+
},
362+
{
363+
name: "AgentConnectionFailure",
364+
agentConnected: true,
365+
agentConnError: xerrors.New("connection failed"),
366+
expectedStatusCode: http.StatusInternalServerError,
367+
},
368+
{
369+
name: "InternalError",
370+
agentConnected: true,
371+
deleteError: xerrors.New("internal error"),
372+
expectedStatusCode: http.StatusInternalServerError,
373+
},
374+
}
375+
376+
for _, tc := range tests {
377+
t.Run(tc.name, func(t *testing.T) {
378+
t.Parallel()
379+
380+
var (
381+
ctx = testutil.Context(t, testutil.WaitShort)
382+
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd")
383+
384+
mCtrl = gomock.NewController(t)
385+
mDB = dbmock.NewMockStore(mCtrl)
386+
mCoordinator = tailnettest.NewMockCoordinator(mCtrl)
387+
388+
agentID = uuid.New()
389+
resourceID = uuid.New()
390+
jobID = uuid.New()
391+
buildID = uuid.New()
392+
workspaceID = uuid.New()
393+
devcontainerID = uuid.NewString()
394+
395+
r = chi.NewMux()
396+
397+
api = API{
398+
ctx: ctx,
399+
Options: &Options{
400+
AgentInactiveDisconnectTimeout: testutil.WaitShort,
401+
Database: mDB,
402+
Logger: logger,
403+
DeploymentValues: &codersdk.DeploymentValues{},
404+
TailnetCoordinator: tailnettest.NewFakeCoordinator(),
405+
},
406+
}
407+
)
408+
409+
var tailnetCoordinator tailnet.Coordinator = mCoordinator
410+
api.TailnetCoordinator.Store(&tailnetCoordinator)
411+
412+
// Setup agent provider based on test case.
413+
if tc.agentConnected && tc.agentConnError == nil {
414+
mAgentConn := agentconnmock.NewMockAgentConn(mCtrl)
415+
mAgentConn.EXPECT().DeleteDevcontainer(gomock.Any(), devcontainerID).Return(tc.deleteError)
416+
api.agentProvider = fakeAgentProvider{
417+
agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
418+
return mAgentConn, func() {}, nil
419+
},
420+
}
421+
} else if tc.agentConnError != nil {
422+
api.agentProvider = fakeAgentProvider{
423+
agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) {
424+
return nil, nil, tc.agentConnError
425+
},
426+
}
427+
}
428+
429+
// Setup database mocks for ExtractWorkspaceAgentParam middleware.
430+
mDB.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(database.WorkspaceAgent{
431+
ID: agentID,
432+
ResourceID: resourceID,
433+
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
434+
FirstConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()},
435+
LastConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()},
436+
}, nil)
437+
mDB.EXPECT().GetWorkspaceResourceByID(gomock.Any(), resourceID).Return(database.WorkspaceResource{
438+
ID: resourceID,
439+
JobID: jobID,
440+
}, nil)
441+
mDB.EXPECT().GetProvisionerJobByID(gomock.Any(), jobID).Return(database.ProvisionerJob{
442+
ID: jobID,
443+
Type: database.ProvisionerJobTypeWorkspaceBuild,
444+
}, nil)
445+
mDB.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), jobID).Return(database.WorkspaceBuild{
446+
WorkspaceID: workspaceID,
447+
ID: buildID,
448+
}, nil)
449+
450+
// Allow db2sdk.WorkspaceAgent to complete.
451+
mCoordinator.EXPECT().Node(gomock.Any()).Return(nil)
452+
453+
// Mount the HTTP handler and create the test server.
454+
r.With(httpmw.ExtractWorkspaceAgentParam(mDB)).
455+
Delete("/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer)
456+
457+
srv := httptest.NewServer(r)
458+
defer srv.Close()
459+
460+
// Send the DELETE request using the test server's client.
461+
req, err := http.NewRequestWithContext(ctx, http.MethodDelete,
462+
fmt.Sprintf("%s/workspaceagents/%s/containers/devcontainers/%s", srv.URL, agentID, devcontainerID), nil)
463+
require.NoError(t, err)
464+
465+
resp, err := srv.Client().Do(req)
466+
require.NoError(t, err)
467+
defer resp.Body.Close()
468+
469+
require.Equal(t, tc.expectedStatusCode, resp.StatusCode)
470+
})
471+
}
472+
}

0 commit comments

Comments
 (0)