From 945545b22586cfab4e7dd518ee7df8af755654cb Mon Sep 17 00:00:00 2001 From: Danielle Maywood Date: Wed, 26 Nov 2025 12:42:36 +0000 Subject: [PATCH] feat(coderd): support deleting dev containers --- coderd/apidoc/docs.go | 36 +++++ coderd/apidoc/swagger.json | 34 ++++ coderd/coderd.go | 1 + coderd/workspaceagents.go | 96 +++++++++++ coderd/workspaceagents_internal_test.go | 151 ++++++++++++++++++ coderd/workspaceagents_test.go | 99 ++++++++++++ codersdk/workspaceagents.go | 13 ++ codersdk/workspacesdk/agentconn.go | 17 ++ .../agentconnmock/agentconnmock.go | 14 ++ docs/reference/api/agents.md | 27 ++++ 10 files changed, 488 insertions(+) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 18361e1d06ff3..a560ad4eed765 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -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": [ diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 386d6d14de6f9..ec4b95c0f27d0 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -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": [ diff --git a/coderd/coderd.go b/coderd/coderd.go index b356f372dc56c..fabfd1c308f31 100644 --- a/coderd/coderd.go +++ b/coderd/coderd.go @@ -1440,6 +1440,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) diff --git a/coderd/workspaceagents.go b/coderd/workspaceagents.go index d3cca07066517..b1ad9f2207859 100644 --- a/coderd/workspaceagents.go +++ b/coderd/workspaceagents.go @@ -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(dialCtx, 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 @@ -1134,6 +1224,12 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req func (api *API) workspaceAgentRecreateDevcontainer(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 == "" { diff --git a/coderd/workspaceagents_internal_test.go b/coderd/workspaceagents_internal_test.go index 90f5d2ab70934..6251b90e049c8 100644 --- a/coderd/workspaceagents_internal_test.go +++ b/coderd/workspaceagents_internal_test.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "database/sql" + "encoding/json" "fmt" "io" "net/http" @@ -17,6 +18,7 @@ import ( "github.com/google/uuid" "github.com/stretchr/testify/require" "go.uber.org/mock/gomock" + "golang.org/x/xerrors" "cdr.dev/slog" "cdr.dev/slog/sloggers/slogtest" @@ -319,3 +321,152 @@ func TestWatchAgentContainers(t *testing.T) { } }) } + +func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agentConnected bool // Controls FirstConnectedAt/LastConnectedAt validity + agentConnError error // Error returned by fakeAgentProvider.AgentConn (nil = success) + deleteError error // Error returned by DeleteDevcontainer mock (nil = success) + expectedStatusCode int + }{ + { + name: "OK", + agentConnected: true, + agentConnError: nil, + deleteError: nil, + expectedStatusCode: http.StatusNoContent, + }, + { + name: "AgentNotConnected", + agentConnected: false, + expectedStatusCode: http.StatusBadRequest, + }, + { + name: "DevcontainerNotFound", + agentConnected: true, + deleteError: func() error { + body, _ := json.Marshal(codersdk.Response{ + Message: "Devcontainer not found.", + }) + return codersdk.ReadBodyAsError(&http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(bytes.NewReader(body)), + Request: &http.Request{URL: &url.URL{}}, + }) + }(), + expectedStatusCode: http.StatusNotFound, + }, + { + name: "AgentConnectionFailure", + agentConnected: true, + agentConnError: xerrors.New("connection failed"), + expectedStatusCode: http.StatusInternalServerError, + }, + { + name: "InternalError", + agentConnected: true, + deleteError: xerrors.New("internal error"), + expectedStatusCode: http.StatusInternalServerError, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug).Named("coderd") + + mCtrl = gomock.NewController(t) + mDB = dbmock.NewMockStore(mCtrl) + mCoordinator = tailnettest.NewMockCoordinator(mCtrl) + + agentID = uuid.New() + resourceID = uuid.New() + jobID = uuid.New() + buildID = uuid.New() + workspaceID = uuid.New() + devcontainerID = uuid.NewString() + + r = chi.NewMux() + + api = API{ + ctx: ctx, + Options: &Options{ + AgentInactiveDisconnectTimeout: testutil.WaitShort, + Database: mDB, + Logger: logger, + DeploymentValues: &codersdk.DeploymentValues{}, + TailnetCoordinator: tailnettest.NewFakeCoordinator(), + }, + } + ) + + var tailnetCoordinator tailnet.Coordinator = mCoordinator + api.TailnetCoordinator.Store(&tailnetCoordinator) + + // Setup agent provider based on test case. + if tc.agentConnected && tc.agentConnError == nil { + mAgentConn := agentconnmock.NewMockAgentConn(mCtrl) + mAgentConn.EXPECT().DeleteDevcontainer(gomock.Any(), devcontainerID).Return(tc.deleteError) + api.agentProvider = fakeAgentProvider{ + agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + return mAgentConn, func() {}, nil + }, + } + } else if tc.agentConnError != nil { + api.agentProvider = fakeAgentProvider{ + agentConn: func(_ context.Context, _ uuid.UUID) (_ workspacesdk.AgentConn, release func(), _ error) { + return nil, nil, tc.agentConnError + }, + } + } + + // Setup database mocks for ExtractWorkspaceAgentParam middleware. + mDB.EXPECT().GetWorkspaceAgentByID(gomock.Any(), agentID).Return(database.WorkspaceAgent{ + ID: agentID, + ResourceID: resourceID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + FirstConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()}, + LastConnectedAt: sql.NullTime{Valid: tc.agentConnected, Time: dbtime.Now()}, + }, nil) + mDB.EXPECT().GetWorkspaceResourceByID(gomock.Any(), resourceID).Return(database.WorkspaceResource{ + ID: resourceID, + JobID: jobID, + }, nil) + mDB.EXPECT().GetProvisionerJobByID(gomock.Any(), jobID).Return(database.ProvisionerJob{ + ID: jobID, + Type: database.ProvisionerJobTypeWorkspaceBuild, + }, nil) + mDB.EXPECT().GetWorkspaceBuildByJobID(gomock.Any(), jobID).Return(database.WorkspaceBuild{ + WorkspaceID: workspaceID, + ID: buildID, + }, nil) + + // Allow db2sdk.WorkspaceAgent to complete. + mCoordinator.EXPECT().Node(gomock.Any()).Return(nil) + + // Mount the HTTP handler and create the test server. + r.With(httpmw.ExtractWorkspaceAgentParam(mDB)). + Delete("/workspaceagents/{workspaceagent}/containers/devcontainers/{devcontainer}", api.workspaceAgentDeleteDevcontainer) + + srv := httptest.NewServer(r) + defer srv.Close() + + // Send the DELETE request using the test server's client. + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, + fmt.Sprintf("%s/workspaceagents/%s/containers/devcontainers/%s", srv.URL, agentID, devcontainerID), nil) + require.NoError(t, err) + + resp, err := srv.Client().Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + require.Equal(t, tc.expectedStatusCode, resp.StatusCode) + }) + } +} diff --git a/coderd/workspaceagents_test.go b/coderd/workspaceagents_test.go index 6c12f91d37388..6f204ce10831d 100644 --- a/coderd/workspaceagents_test.go +++ b/coderd/workspaceagents_test.go @@ -1571,6 +1571,105 @@ func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) { }) } +func TestWorkspaceAgentDeleteDevcontainer(t *testing.T) { + t.Parallel() + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var ( + workspaceFolder = t.TempDir() + configFile = filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json") + devcontainerID = uuid.New() + + // Create a container that would be associated with the devcontainer + 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, + } + ) + + var ( + ctx = testutil.Context(t, testutil.WaitLong) + mCtrl = gomock.NewController(t) + mCCLI = acmock.NewMockContainerCLI(mCtrl) + mDCCLI = acmock.NewMockDevcontainerCLI(mCtrl) + 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.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{ + Containers: []codersdk.WorkspaceAgentContainer{devContainer}, + }, nil).AnyTimes() + + // DetectArchitecture always returns "" for this test to disable agent injection. + mCCLI.EXPECT().DetectArchitecture(gomock.Any(), devContainer.ID).Return("", nil).AnyTimes() + mDCCLI.EXPECT().ReadConfig(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return(agentcontainers.DevcontainerConfig{}, nil).AnyTimes() + + // Expect Stop and Remove to be called when deleting the devcontainer. + deleteCalled := make(chan string, 1) + mCCLI.EXPECT().Stop(gomock.Any(), devContainer.ID).Return(nil).Times(1) + mCCLI.EXPECT().Remove(gomock.Any(), devContainer.ID).DoAndReturn(func(_ context.Context, containerID string) error { + deleteCalled <- containerID + return nil + }).Times(1) + + devcontainerAPIOptions := []agentcontainers.Option{ + agentcontainers.WithContainerCLI(mCCLI), + agentcontainers.WithDevcontainerCLI(mDCCLI), + agentcontainers.WithWatcher(watcher.NewNoop()), + agentcontainers.WithDevcontainers([]codersdk.WorkspaceAgentDevcontainer{devcontainer}, nil), + } + + _ = 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 + + err := client.WorkspaceAgentDeleteDevcontainer(ctx, agentID, devcontainerID.String()) + require.NoError(t, err, "failed to delete devcontainer") + + // Verify the Remove method was called with the correct container ID. + select { + case containerID := <-deleteCalled: + require.Equal(t, devContainer.ID, containerID, "unexpected container ID") + case <-ctx.Done(): + t.Fatal("timed out waiting for Remove to be called") + } + }) +} + func TestWorkspaceAgentAppHealth(t *testing.T) { t.Parallel() client, db := coderdtest.NewWithDatabase(t, nil) diff --git a/codersdk/workspaceagents.go b/codersdk/workspaceagents.go index 371f9c90083e8..2f9974637911f 100644 --- a/codersdk/workspaceagents.go +++ b/codersdk/workspaceagents.go @@ -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) diff --git a/codersdk/workspacesdk/agentconn.go b/codersdk/workspacesdk/agentconn.go index dbfb833e44525..559dbb8ff0af0 100644 --- a/codersdk/workspacesdk/agentconn.go +++ b/codersdk/workspacesdk/agentconn.go @@ -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) { diff --git a/codersdk/workspacesdk/agentconnmock/agentconnmock.go b/codersdk/workspacesdk/agentconnmock/agentconnmock.go index cf6b4c72bea27..f1e300dfe0e68 100644 --- a/codersdk/workspacesdk/agentconnmock/agentconnmock.go +++ b/codersdk/workspacesdk/agentconnmock/agentconnmock.go @@ -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() diff --git a/docs/reference/api/agents.md b/docs/reference/api/agents.md index 6f88f47039278..2ef444d3098d6 100644 --- a/docs/reference/api/agents.md +++ b/docs/reference/api/agents.md @@ -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