feat: show devcontainer dirty status and allow recreate (#17880)

Updates #16424
This commit is contained in:
Mathias Fredriksson
2025-05-19 12:56:10 +03:00
committed by GitHub
parent c775ea8411
commit 98e2ec4417
15 changed files with 598 additions and 198 deletions

40
coderd/apidoc/docs.go generated
View File

@ -8606,6 +8606,42 @@ const docTemplate = `{
}
}
},
"/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": [
"Agents"
],
"summary": "Recreate devcontainer for workspace agent",
"operationId": "recreate-devcontainer-for-workspace-agent",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace agent ID",
"name": "workspaceagent",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Container ID or name",
"name": "container",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaceagents/{workspaceagent}/coordinate": {
"get": {
"security": [
@ -17134,6 +17170,10 @@ const docTemplate = `{
"type": "string",
"format": "date-time"
},
"devcontainer_dirty": {
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
"type": "boolean"
},
"id": {
"description": "ID is the unique identifier of the container.",
"type": "string"

View File

@ -7605,6 +7605,40 @@
}
}
},
"/workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate": {
"post": {
"security": [
{
"CoderSessionToken": []
}
],
"tags": ["Agents"],
"summary": "Recreate devcontainer for workspace agent",
"operationId": "recreate-devcontainer-for-workspace-agent",
"parameters": [
{
"type": "string",
"format": "uuid",
"description": "Workspace agent ID",
"name": "workspaceagent",
"in": "path",
"required": true
},
{
"type": "string",
"description": "Container ID or name",
"name": "container",
"in": "path",
"required": true
}
],
"responses": {
"204": {
"description": "No Content"
}
}
}
},
"/workspaceagents/{workspaceagent}/coordinate": {
"get": {
"security": [
@ -15643,6 +15677,10 @@
"type": "string",
"format": "date-time"
},
"devcontainer_dirty": {
"description": "DevcontainerDirty is true if the devcontainer configuration has changed\nsince the container was created. This is used to determine if the\ncontainer needs to be rebuilt.",
"type": "boolean"
},
"id": {
"description": "ID is the unique identifier of the container.",
"type": "string"

View File

@ -1326,6 +1326,7 @@ func New(options *Options) *API {
r.Get("/listening-ports", api.workspaceAgentListeningPorts)
r.Get("/connection", api.workspaceAgentConnection)
r.Get("/containers", api.workspaceAgentListContainers)
r.Post("/containers/devcontainers/container/{container}/recreate", api.workspaceAgentRecreateDevcontainer)
r.Get("/coordinate", api.workspaceAgentClientCoordinate)
// PTY is part of workspaceAppServer.

View File

@ -15,6 +15,7 @@ import (
"strings"
"time"
"github.com/go-chi/chi/v5"
"github.com/google/uuid"
"github.com/sqlc-dev/pqtype"
"golang.org/x/exp/maps"
@ -893,6 +894,91 @@ func (api *API) workspaceAgentListContainers(rw http.ResponseWriter, r *http.Req
httpapi.Write(ctx, rw, http.StatusOK, cts)
}
// @Summary Recreate devcontainer for workspace agent
// @ID recreate-devcontainer-for-workspace-agent
// @Security CoderSessionToken
// @Tags Agents
// @Param workspaceagent path string true "Workspace agent ID" format(uuid)
// @Param container path string true "Container ID or name"
// @Success 204
// @Router /workspaceagents/{workspaceagent}/containers/devcontainers/container/{container}/recreate [post]
func (api *API) workspaceAgentRecreateDevcontainer(rw http.ResponseWriter, r *http.Request) {
ctx := r.Context()
workspaceAgent := httpmw.WorkspaceAgentParam(r)
container := chi.URLParam(r, "container")
if container == "" {
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
Message: "Container ID or name is required.",
Validations: []codersdk.ValidationError{
{Field: "container", Detail: "Container ID or name 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()
err = agentConn.RecreateDevcontainer(ctx, container)
if err != nil {
if errors.Is(err, context.Canceled) {
httpapi.Write(ctx, rw, http.StatusRequestTimeout, codersdk.Response{
Message: "Failed to recreate 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 recreating devcontainer.",
Detail: err.Error(),
})
return
}
httpapi.Write(ctx, rw, http.StatusNoContent, nil)
}
// @Summary Get connection info for workspace agent
// @ID get-connection-info-for-workspace-agent
// @Security CoderSessionToken

View File

@ -8,6 +8,7 @@ import (
"net"
"net/http"
"os"
"path/filepath"
"runtime"
"strconv"
"strings"
@ -36,6 +37,7 @@ import (
"github.com/coder/coder/v2/agent"
"github.com/coder/coder/v2/agent/agentcontainers"
"github.com/coder/coder/v2/agent/agentcontainers/acmock"
"github.com/coder/coder/v2/agent/agentcontainers/watcher"
"github.com/coder/coder/v2/agent/agenttest"
agentproto "github.com/coder/coder/v2/agent/proto"
"github.com/coder/coder/v2/coderd/coderdtest"
@ -1347,6 +1349,115 @@ func TestWorkspaceAgentContainers(t *testing.T) {
})
}
func TestWorkspaceAgentRecreateDevcontainer(t *testing.T) {
t.Parallel()
t.Run("Mock", func(t *testing.T) {
t.Parallel()
var (
workspaceFolder = t.TempDir()
configFile = filepath.Join(workspaceFolder, ".devcontainer", "devcontainer.json")
dcLabels = map[string]string{
agentcontainers.DevcontainerLocalFolderLabel: workspaceFolder,
agentcontainers.DevcontainerConfigFileLabel: configFile,
}
devContainer = codersdk.WorkspaceAgentContainer{
ID: uuid.NewString(),
CreatedAt: dbtime.Now(),
FriendlyName: testutil.GetRandomName(t),
Image: "busybox:latest",
Labels: dcLabels,
Running: true,
Status: "running",
DevcontainerDirty: true,
}
plainContainer = codersdk.WorkspaceAgentContainer{
ID: uuid.NewString(),
CreatedAt: dbtime.Now(),
FriendlyName: testutil.GetRandomName(t),
Image: "busybox:latest",
Labels: map[string]string{},
Running: true,
Status: "running",
}
)
for _, tc := range []struct {
name string
setupMock func(*acmock.MockLister, *acmock.MockDevcontainerCLI) (status int)
}{
{
name: "Recreate",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer},
}, nil).Times(1)
mdccli.EXPECT().Up(gomock.Any(), workspaceFolder, configFile, gomock.Any()).Return("someid", nil).Times(1)
return 0
},
},
{
name: "Container does not exist",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{}, nil).Times(1)
return http.StatusNotFound
},
},
{
name: "Not a devcontainer",
setupMock: func(mcl *acmock.MockLister, mdccli *acmock.MockDevcontainerCLI) int {
mcl.EXPECT().List(gomock.Any()).Return(codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{plainContainer},
}, nil).Times(1)
return http.StatusNotFound
},
},
} {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
ctrl := gomock.NewController(t)
mcl := acmock.NewMockLister(ctrl)
mdccli := acmock.NewMockDevcontainerCLI(ctrl)
wantStatus := tc.setupMock(mcl, mdccli)
client, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{})
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()
_ = agenttest.New(t, client.URL, r.AgentToken, func(o *agent.Options) {
o.ExperimentalDevcontainersEnabled = true
o.ContainerAPIOptions = append(
o.ContainerAPIOptions,
agentcontainers.WithLister(mcl),
agentcontainers.WithDevcontainerCLI(mdccli),
agentcontainers.WithWatcher(watcher.NewNoop()),
)
})
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
ctx := testutil.Context(t, testutil.WaitLong)
err := client.WorkspaceAgentRecreateDevcontainer(ctx, agentID, devContainer.ID)
if wantStatus > 0 {
cerr, ok := codersdk.AsError(err)
require.True(t, ok, "expected error to be a coder error")
assert.Equal(t, wantStatus, cerr.StatusCode())
} else {
require.NoError(t, err, "failed to recreate devcontainer")
}
})
}
})
}
func TestWorkspaceAgentAppHealth(t *testing.T) {
t.Parallel()
client, db := coderdtest.NewWithDatabase(t, nil)