mirror of
https://github.com/coder/coder.git
synced 2026-09-21 12:44:32 +08:00
feat(coderd): support deleting dev containers (#21248)
Add an endpoint to coderd to support deleting dev containers
This commit is contained in:
Generated
+36
@@ -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": [
|
||||
|
||||
Generated
+34
@@ -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": [
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
|
||||
Generated
+27
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user