Compare commits

...

4 Commits

Author SHA1 Message Date
Danielle Maywood
1fcd2a2ed9 chore: fix formatting 2025-11-21 19:03:11 +00:00
Danielle Maywood
b42b136360 fix: bad formatting 2025-11-21 16:13:31 +00:00
Danielle Maywood
5edf08f8a4 test(agent): add delete and recreate devcontainer test
Add comprehensive test that validates the delete and recreate flow:
1. Verifies devcontainer exists initially
2. Deletes the devcontainer via DELETE endpoint
3. Confirms it's removed from the list
4. Simulates container reappearing (e.g., manually recreated)
5. Triggers updater loop to rediscover the container
6. Verifies devcontainer is recreated with new ID

Also fixes existing delete tests to properly control the updater loop
using ticker traps to prevent race conditions.
2025-11-21 15:56:37 +00:00
Danielle Maywood
5fdac48e37 feat(agent): add devcontainer delete endpoint
Adds DELETE /api/v0/containers/devcontainers/{devcontainer} endpoint
to the agent API for removing devcontainers.

Changes:
- Add handleDevcontainerDelete handler that:
  - Validates devcontainer ID and prevents deletion while starting
  - Stops sub-agent process and cleans up all internal state
  - Broadcasts update to connected clients
- Update AgentConn interface with DeleteDevcontainer method
- Add comprehensive tests covering all scenarios

This is the agent-side foundation for the devcontainer delete feature.
The coderd API layer will be added in a follow-up change.
2025-11-21 15:16:50 +00:00
4 changed files with 412 additions and 0 deletions

View File

@@ -743,6 +743,7 @@ func (api *API) Routes() http.Handler {
// /-route was dropped. We can drop the /devcontainers prefix here too.
r.Route("/devcontainers/{devcontainer}", func(r chi.Router) {
r.Post("/recreate", api.handleDevcontainerRecreate)
r.Delete("/", api.handleDevcontainerDelete)
})
return r
@@ -1282,6 +1283,107 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
})
}
// handleDevcontainerDelete handles the HTTP request to delete a
// devcontainer by stopping the sub-agent and removing the container.
func (api *API) handleDevcontainerDelete(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
devcontainerID := chi.URLParam(r, "devcontainer")
if devcontainerID == "" {
httpapi.Write(ctx, w, http.StatusBadRequest, codersdk.Response{
Message: "Missing devcontainer ID",
Detail: "Devcontainer ID is required to delete a devcontainer.",
})
return
}
api.mu.Lock()
defer api.mu.Unlock()
// Find the devcontainer by ID
var dc codersdk.WorkspaceAgentDevcontainer
var workspaceFolder string
for folder, knownDC := range api.knownDevcontainers {
if knownDC.ID.String() == devcontainerID {
dc = knownDC
workspaceFolder = folder
break
}
}
if dc.ID == uuid.Nil {
httpapi.Write(ctx, w, http.StatusNotFound, codersdk.Response{
Message: "Devcontainer not found.",
Detail: fmt.Sprintf("Could not find devcontainer with ID: %q", devcontainerID),
})
return
}
// Cannot delete while starting
if dc.Status == codersdk.WorkspaceAgentDevcontainerStatusStarting {
httpapi.Write(ctx, w, http.StatusConflict, codersdk.Response{
Message: "Cannot delete devcontainer while starting",
Detail: fmt.Sprintf("Devcontainer %q is currently starting. Wait for it to finish before deleting.", dc.Name),
})
return
}
logger := api.logger.With(
slog.F("devcontainer_id", dc.ID),
slog.F("devcontainer_name", dc.Name),
slog.F("workspace_folder", dc.WorkspaceFolder),
)
logger.Info(ctx, "deleting devcontainer")
// Stop the sub-agent if it's running
if proc, ok := api.injectedSubAgentProcs[workspaceFolder]; ok {
logger.Debug(ctx, "stopping sub-agent process")
proc.stop()
// Delete the sub-agent from the backend if it was registered
if proc.agent.ID != uuid.Nil {
// Unlock while doing the delete operation
api.mu.Unlock()
client := *api.subAgentClient.Load()
if err := client.Delete(ctx, proc.agent.ID); err != nil {
api.mu.Lock()
logger.Error(ctx, "failed to delete sub-agent", slog.Error(err))
httpapi.Write(ctx, w, http.StatusInternalServerError, codersdk.Response{
Message: "Failed to delete sub-agent.",
Detail: err.Error(),
})
return
}
api.mu.Lock()
logger.Debug(ctx, "sub-agent deleted successfully")
}
// Clean up the sub-agent process from the map
delete(api.injectedSubAgentProcs, workspaceFolder)
}
// Remove the devcontainer from all tracking maps
delete(api.knownDevcontainers, workspaceFolder)
delete(api.devcontainerLogSourceIDs, workspaceFolder)
delete(api.configFileModifiedTimes, dc.ConfigPath)
delete(api.recreateSuccessTimes, workspaceFolder)
delete(api.recreateErrorTimes, workspaceFolder)
delete(api.ignoredDevcontainers, workspaceFolder)
delete(api.usingWorkspaceFolderName, workspaceFolder)
delete(api.devcontainerNames, dc.Name)
// Broadcast the update so clients know the devcontainer is gone
api.broadcastUpdatesLocked()
logger.Info(ctx, "devcontainer deleted successfully")
httpapi.Write(ctx, w, http.StatusOK, codersdk.Response{
Message: "Devcontainer deleted successfully",
Detail: fmt.Sprintf("Devcontainer %q has been deleted. The container and sub-agent have been stopped and removed.", dc.Name),
})
}
// createDevcontainer should run in its own goroutine and is responsible for
// recreating a devcontainer based on the provided devcontainer configuration.
// It updates the devcontainer status and logs the process. The configPath is

View File

@@ -4032,3 +4032,282 @@ func TestDevcontainerPrebuildSupport(t *testing.T) {
// And: We expect this app to have the post-claim URL.
require.Equal(t, userAppURL, secondApp.URL)
}
func TestHandleDevcontainerDeleteAndRecreate(t *testing.T) {
t.Parallel()
// This test validates that after deleting a devcontainer, if the container
// reappears (e.g., manually recreated or discovered), the updater loop will
// detect it and recreate the devcontainer entry.
devcontainerID := uuid.New()
workspaceFolder := "/workspace/test-recreate"
configPath := workspaceFolder + "/.devcontainer/devcontainer.json"
// Create a container that represents a devcontainer
devContainer := codersdk.WorkspaceAgentContainer{
ID: "container-recreate-1",
FriendlyName: "test-container-recreate",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder,
agentcontainers.DevcontainerConfigFileLabel: configPath,
},
}
ctx := testutil.Context(t, testutil.WaitMedium)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(ctx)
// Set up updater ticker trap to control when the updater loop runs
updaterTickerTrap := mClock.Trap().TickerFunc("updaterLoop")
defer updaterTickerTrap.Close()
// Create a fake container CLI that initially has the devcontainer
fakeLister := &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer},
},
arch: "<none>", // Unsupported architecture, don't inject subagent.
}
// Setup router and API with the initial devcontainer
r := chi.NewRouter()
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(fakeLister),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithDevcontainers([]codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID,
Name: "test-devcontainer",
WorkspaceFolder: workspaceFolder,
ConfigPath: configPath,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer,
},
}, nil),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
// Wait for the updater loop to be ready
updaterTickerTrap.MustWait(ctx).MustRelease(ctx)
// STEP 1: Verify the devcontainer exists
req := httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "initial list request should succeed")
var initialResp codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&initialResp)
require.NoError(t, err, "unmarshal initial response failed")
require.Len(t, initialResp.Devcontainers, 1, "should have one devcontainer initially")
require.Equal(t, devcontainerID, initialResp.Devcontainers[0].ID, "devcontainer ID should match")
// STEP 2: Delete the devcontainer
req = httptest.NewRequest(http.MethodDelete, "/devcontainers/"+devcontainerID.String(), nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "delete request should succeed")
assert.Contains(t, rec.Body.String(), "Devcontainer deleted successfully", "delete response body mismatch")
// STEP 3: Verify the devcontainer is gone
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "list after delete should succeed")
var afterDeleteResp codersdk.WorkspaceAgentListContainersResponse
err = json.NewDecoder(rec.Body).Decode(&afterDeleteResp)
require.NoError(t, err, "unmarshal after delete response failed")
require.Len(t, afterDeleteResp.Devcontainers, 0, "devcontainer should be removed after deletion")
// STEP 4: Simulate the container reappearing (e.g., manually recreated)
// Update the fake lister to return the container again
fakeLister.containers = codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer},
}
// STEP 5: Trigger the updater loop to discover the container again
_, aw := mClock.AdvanceNext()
aw.MustWait(ctx)
// STEP 6: Verify the devcontainer has been recreated
req = httptest.NewRequest(http.MethodGet, "/", nil).WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "list after recreate should succeed")
var afterRecreateResp codersdk.WorkspaceAgentListContainersResponse
err = json.NewDecoder(rec.Body).Decode(&afterRecreateResp)
require.NoError(t, err, "unmarshal after recreate response failed")
require.Len(t, afterRecreateResp.Devcontainers, 1, "devcontainer should be rediscovered after updater loop")
// The ID will be different since it's a new devcontainer entry
require.NotEqual(t, devcontainerID, afterRecreateResp.Devcontainers[0].ID,
"recreated devcontainer should have a new ID")
require.Equal(t, workspaceFolder, afterRecreateResp.Devcontainers[0].WorkspaceFolder,
"workspace folder should match")
require.Equal(t, configPath, afterRecreateResp.Devcontainers[0].ConfigPath,
"config path should match")
require.Equal(t, codersdk.WorkspaceAgentDevcontainerStatusRunning, afterRecreateResp.Devcontainers[0].Status,
"recreated devcontainer should be running")
}
func TestHandleDevcontainerDelete(t *testing.T) {
t.Parallel()
devcontainerID1 := uuid.New()
devcontainerID2 := uuid.New()
workspaceFolder1 := "/workspace/test1"
workspaceFolder2 := "/workspace/test2"
configPath1 := "/workspace/test1/.devcontainer/devcontainer.json"
configPath2 := "/workspace/test2/.devcontainer/devcontainer.json"
// Create a container that represents an existing devcontainer
devContainer1 := codersdk.WorkspaceAgentContainer{
ID: "container-1",
FriendlyName: "test-container-1",
Running: true,
Labels: map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder1,
agentcontainers.DevcontainerConfigFileLabel: configPath1,
},
}
tests := []struct {
name string
devcontainerID string
setupDevcontainers []codersdk.WorkspaceAgentDevcontainer
lister *fakeContainerCLI
wantStatus int
wantBody string
}{
{
name: "Missing devcontainer ID",
devcontainerID: "",
lister: &fakeContainerCLI{},
wantStatus: http.StatusNotFound, // Chi router returns 404 for empty path parameter
wantBody: "404 page not found",
},
{
name: "Devcontainer not found",
devcontainerID: uuid.NewString(),
lister: &fakeContainerCLI{
arch: "<none>", // Unsupported architecture, don't inject subagent.
},
wantStatus: http.StatusNotFound,
wantBody: "Devcontainer not found",
},
{
name: "Cannot delete while starting",
devcontainerID: devcontainerID2.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID2,
Name: "test-devcontainer-2",
WorkspaceFolder: workspaceFolder2,
ConfigPath: configPath2,
Status: codersdk.WorkspaceAgentDevcontainerStatusStarting,
Container: nil,
},
},
lister: &fakeContainerCLI{
arch: "<none>",
},
wantStatus: http.StatusConflict,
wantBody: "Cannot delete devcontainer while starting",
},
{
name: "OK - Delete existing devcontainer",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-1",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>", // Unsupported architecture, don't inject subagent.
},
wantStatus: http.StatusOK,
wantBody: "Devcontainer deleted successfully",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
ctx := testutil.Context(t, testutil.WaitShort)
logger := slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
mClock := quartz.NewMock(t)
mClock.Set(time.Now()).MustWait(ctx)
// Set up updater ticker trap to prevent automatic updates
updaterTickerTrap := mClock.Trap().TickerFunc("updaterLoop")
defer updaterTickerTrap.Close()
// Setup router with the handler under test.
r := chi.NewRouter()
api := agentcontainers.NewAPI(
logger,
agentcontainers.WithClock(mClock),
agentcontainers.WithContainerCLI(tt.lister),
agentcontainers.WithDevcontainerCLI(&fakeDevcontainerCLI{}),
agentcontainers.WithWatcher(watcher.NewNoop()),
agentcontainers.WithDevcontainers(tt.setupDevcontainers, nil),
)
api.Start()
defer api.Close()
r.Mount("/", api.Routes())
// Wait for the updater loop to be ready, then hold it so it doesn't interfere
updaterTickerTrap.MustWait(ctx).MustRelease(ctx)
// Simulate HTTP DELETE request to the delete endpoint.
req := httptest.NewRequest(http.MethodDelete, "/devcontainers/"+tt.devcontainerID, nil).
WithContext(ctx)
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
// Check the response status code and body.
require.Equal(t, tt.wantStatus, rec.Code, "status code mismatch")
if tt.wantBody != "" {
assert.Contains(t, rec.Body.String(), tt.wantBody, "response body mismatch")
}
// For successful deletion, verify the devcontainer is no longer in the list
if tt.wantStatus == http.StatusOK {
req = httptest.NewRequest(http.MethodGet, "/", nil).
WithContext(ctx)
rec = httptest.NewRecorder()
r.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, "status code mismatch after delete")
var resp codersdk.WorkspaceAgentListContainersResponse
err := json.NewDecoder(rec.Body).Decode(&resp)
require.NoError(t, err, "unmarshal response failed after delete")
require.Len(t, resp.Devcontainers, 0, "devcontainer should be removed from list after deletion")
}
})
}
}

View File

@@ -61,6 +61,7 @@ type AgentConn interface {
PrometheusMetrics(ctx context.Context) ([]byte, error)
ReconnectingPTY(ctx context.Context, id uuid.UUID, height uint16, width uint16, command string, initOpts ...AgentReconnectingPTYInitOption) (net.Conn, error)
RecreateDevcontainer(ctx context.Context, devcontainerID string) (codersdk.Response, error)
DeleteDevcontainer(ctx context.Context, devcontainerID string) error
LS(ctx context.Context, path string, req LSRequest) (LSResponse, error)
ReadFile(ctx context.Context, path string, offset, limit int64) (io.ReadCloser, string, error)
WriteFile(ctx context.Context, path string, reader io.Reader) error
@@ -481,6 +482,22 @@ func (c *agentConn) RecreateDevcontainer(ctx context.Context, devcontainerID str
return m, nil
}
// DeleteDevcontainer deletes a devcontainer with the given container ID.
// This will stop the sub-agent and remove the container.
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.StatusOK {
return codersdk.ReadBodyAsError(res)
}
return nil
}
type LSRequest struct {
// e.g. [], ["repos", "coder"],
Path []string `json:"path"`

View File

@@ -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()