Skip to content

Commit 4218fbf

Browse files
feat(coderd): support deleting dev containers
1 parent 4504755 commit 4218fbf

File tree

10 files changed

+485
-0
lines changed

10 files changed

+485
-0
lines changed

coderd/apidoc/docs.go

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

coderd/workspaceagents_internal_test.go

Lines changed: 155 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"
@@ -35,6 +37,17 @@ import (
3537
"github.com/coder/websocket"
3638
)
3739

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

0 commit comments

Comments
 (0)