Compare commits

...

1 Commits

Author SHA1 Message Date
Danielle Maywood cd3a49ffa4 feat(agent): populate subagent ID for terraform-defined devcontainers
Update the manifest API in coderd to populate the subagent_id field for
devcontainers, enabling the agent to receive devcontainer resource
definitions with pre-assigned IDs and use them when creating sub-agents.

This allows Terraform-defined devcontainers to:
- Have stable subagent IDs across workspace rebuilds
- Prevent accidental deletion/recreation of managed subagents
- Prevent UI-initiated rebuilds of terraform-managed containers
2026-02-03 15:29:16 +00:00
27 changed files with 853 additions and 55 deletions
+27 -5
View File
@@ -1405,6 +1405,14 @@ func (api *API) handleDevcontainerRecreate(w http.ResponseWriter, r *http.Reques
httperror.WriteResponseError(ctx, w, err)
return
}
if dc.SubagentID.Valid {
api.mu.Unlock()
httpapi.Write(ctx, w, http.StatusForbidden, codersdk.Response{
Message: "Cannot rebuild Terraform-defined devcontainer",
Detail: fmt.Sprintf("Devcontainer %q has resources defined in Terraform and cannot be rebuilt from the UI. Update the workspace template to modify this devcontainer.", dc.Name),
})
return
}
if dc.Status.Transitioning() {
api.mu.Unlock()
@@ -1627,16 +1635,25 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
api.mu.Lock()
defer api.mu.Unlock()
injected := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs))
// Collect all subagent IDs that should be kept:
// 1. Subagents currently tracked by injectedSubAgentProcs
// 2. Subagents referenced by known devcontainers from the manifest
keep := make(map[uuid.UUID]bool, len(api.injectedSubAgentProcs)+len(api.knownDevcontainers))
for _, proc := range api.injectedSubAgentProcs {
injected[proc.agent.ID] = true
keep[proc.agent.ID] = true
}
for _, dc := range api.knownDevcontainers {
if dc.SubagentID.Valid {
keep[dc.SubagentID.UUID] = true
}
}
ctx, cancel := context.WithTimeout(ctx, defaultOperationTimeout)
defer cancel()
var errs []error
for _, agent := range agents {
if injected[agent.ID] {
if keep[agent.ID] {
continue
}
client := *api.subAgentClient.Load()
@@ -1647,10 +1664,11 @@ func (api *API) cleanupSubAgents(ctx context.Context) error {
slog.F("agent_id", agent.ID),
slog.F("agent_name", agent.Name),
)
errs = append(errs, xerrors.Errorf("delete agent %s (%s): %w", agent.Name, agent.ID, err))
}
}
return nil
return errors.Join(errs...)
}
// maybeInjectSubAgentIntoContainerLocked injects a subagent into a dev
@@ -2001,7 +2019,11 @@ func (api *API) maybeInjectSubAgentIntoContainerLocked(ctx context.Context, dc c
// logger.Warn(ctx, "set CAP_NET_ADMIN on agent binary failed", slog.Error(err))
// }
deleteSubAgent := proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
// Only delete and recreate subagents that were dynamically created
// (ID == uuid.Nil). Terraform-defined subagents (subAgentConfig.ID !=
// uuid.Nil) must not be deleted because they have attached resources
// managed by terraform.
deleteSubAgent := subAgentConfig.ID == uuid.Nil && proc.agent.ID != uuid.Nil && maybeRecreateSubAgent && !proc.agent.EqualConfig(subAgentConfig)
if deleteSubAgent {
logger.Debug(ctx, "deleting existing subagent for recreation", slog.F("agent_id", proc.agent.ID))
client := *api.subAgentClient.Load()
+29 -1
View File
@@ -437,7 +437,11 @@ func (m *fakeSubAgentClient) Create(ctx context.Context, agent agentcontainers.S
}
}
agent.ID = uuid.New()
// Only generate a new ID if one wasn't provided. Terraform-defined
// subagents have pre-existing IDs that should be preserved.
if agent.ID == uuid.Nil {
agent.ID = uuid.New()
}
agent.AuthToken = uuid.New()
if m.agents == nil {
m.agents = make(map[uuid.UUID]agentcontainers.SubAgent)
@@ -1035,6 +1039,30 @@ func TestAPI(t *testing.T) {
wantStatus: []int{http.StatusAccepted, http.StatusConflict},
wantBody: []string{"Devcontainer recreation initiated", "is currently starting and cannot be restarted"},
},
{
name: "Terraform-defined devcontainer cannot be rebuilt",
devcontainerID: devcontainerID1.String(),
setupDevcontainers: []codersdk.WorkspaceAgentDevcontainer{
{
ID: devcontainerID1,
Name: "test-devcontainer-terraform",
WorkspaceFolder: workspaceFolder1,
ConfigPath: configPath1,
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Container: &devContainer1,
SubagentID: uuid.NullUUID{UUID: uuid.New(), Valid: true},
},
},
lister: &fakeContainerCLI{
containers: codersdk.WorkspaceAgentListContainersResponse{
Containers: []codersdk.WorkspaceAgentContainer{devContainer1},
},
arch: "<none>",
},
devcontainerCLI: &fakeDevcontainerCLI{},
wantStatus: []int{http.StatusForbidden},
wantBody: []string{"Cannot rebuild Terraform-defined devcontainer"},
},
}
for _, tt := range tests {
+10 -2
View File
@@ -24,10 +24,12 @@ type SubAgent struct {
DisplayApps []codersdk.DisplayApp
}
// CloneConfig makes a copy of SubAgent without ID and AuthToken. The
// name is inherited from the devcontainer.
// CloneConfig makes a copy of SubAgent using configuration from the
// devcontainer. The ID is inherited from dc.SubagentID if present, and
// the name is inherited from the devcontainer. AuthToken is not copied.
func (s SubAgent) CloneConfig(dc codersdk.WorkspaceAgentDevcontainer) SubAgent {
return SubAgent{
ID: dc.SubagentID.UUID,
Name: dc.Name,
Directory: s.Directory,
Architecture: s.Architecture,
@@ -190,6 +192,11 @@ func (a *subAgentAPIClient) List(ctx context.Context) ([]SubAgent, error) {
func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAgent, err error) {
a.logger.Debug(ctx, "creating sub agent", slog.F("name", agent.Name), slog.F("directory", agent.Directory))
var id []byte
if agent.ID != uuid.Nil {
id = agent.ID[:]
}
displayApps := make([]agentproto.CreateSubAgentRequest_DisplayApp, 0, len(agent.DisplayApps))
for _, displayApp := range agent.DisplayApps {
var app agentproto.CreateSubAgentRequest_DisplayApp
@@ -228,6 +235,7 @@ func (a *subAgentAPIClient) Create(ctx context.Context, agent SubAgent) (_ SubAg
OperatingSystem: agent.OperatingSystem,
DisplayApps: displayApps,
Apps: apps,
Id: id,
})
if err != nil {
return SubAgent{}, err
+99
View File
@@ -306,3 +306,102 @@ func TestSubAgentClient_CreateWithDisplayApps(t *testing.T) {
}
})
}
func TestSubAgent_CloneConfig(t *testing.T) {
t.Parallel()
t.Run("CopiesIDFromDevcontainer", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
expectedID := uuid.MustParse("550e8400-e29b-41d4-a716-446655440000")
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{UUID: expectedID, Valid: true},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, expectedID, cloned.ID)
assert.Equal(t, dc.Name, cloned.Name)
assert.Equal(t, subAgent.Directory, cloned.Directory)
assert.Equal(t, uuid.Nil, cloned.AuthToken, "AuthToken should not be copied")
})
t.Run("HandlesNilSubagentID", func(t *testing.T) {
t.Parallel()
subAgent := agentcontainers.SubAgent{
ID: uuid.New(),
Name: "original-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
dc := codersdk.WorkspaceAgentDevcontainer{
Name: "devcontainer-name",
SubagentID: uuid.NullUUID{Valid: false},
}
cloned := subAgent.CloneConfig(dc)
assert.Equal(t, uuid.Nil, cloned.ID)
})
}
func TestSubAgent_EqualConfig(t *testing.T) {
t.Parallel()
t.Run("TrueWhenFieldsMatch", func(t *testing.T) {
t.Parallel()
a := agentcontainers.SubAgent{
ID: uuid.MustParse("550e8400-e29b-41d4-a716-446655440000"),
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
// Different ID but same config fields.
b := agentcontainers.SubAgent{
ID: uuid.MustParse("660e8400-e29b-41d4-a716-446655440000"),
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []codersdk.DisplayApp{codersdk.DisplayAppVSCodeDesktop},
Apps: []agentcontainers.SubAgentApp{{Slug: "app1"}},
}
assert.True(t, a.EqualConfig(b), "EqualConfig compares config fields, not ID")
})
t.Run("FalseWhenFieldsDiffer", func(t *testing.T) {
t.Parallel()
a := agentcontainers.SubAgent{
Name: "test-agent",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
b := agentcontainers.SubAgent{
Name: "different-name",
Directory: "/workspace",
Architecture: "amd64",
OperatingSystem: "linux",
}
assert.False(t, a.EqualConfig(b))
})
}
+6
View File
@@ -249,11 +249,17 @@ func dbAppToProto(dbApp database.WorkspaceApp, agent database.WorkspaceAgent, ow
func dbAgentDevcontainersToProto(devcontainers []database.WorkspaceAgentDevcontainer) []*agentproto.WorkspaceAgentDevcontainer {
ret := make([]*agentproto.WorkspaceAgentDevcontainer, len(devcontainers))
for i, dc := range devcontainers {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
ret[i] = &agentproto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
return ret
+56 -19
View File
@@ -37,25 +37,6 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
//nolint:gocritic // This gives us only the permissions required to do the job.
ctx = dbauthz.AsSubAgentAPI(ctx, a.OrganizationID, a.OwnerID)
parentAgent, err := a.AgentFn(ctx)
if err != nil {
return nil, xerrors.Errorf("get parent agent: %w", err)
}
agentName := req.Name
if agentName == "" {
return nil, codersdk.ValidationError{
Field: "name",
Detail: "agent name cannot be empty",
}
}
if !provisioner.AgentNameRegex.MatchString(agentName) {
return nil, codersdk.ValidationError{
Field: "name",
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
}
}
createdAt := a.Clock.Now()
displayApps := make([]database.DisplayApp, 0, len(req.DisplayApps))
@@ -83,6 +64,62 @@ func (a *SubAgentAPI) CreateSubAgent(ctx context.Context, req *agentproto.Create
displayApps = append(displayApps, app)
}
parentAgent, err := a.AgentFn(ctx)
if err != nil {
return nil, xerrors.Errorf("get parent agent: %w", err)
}
// An ID is only given in the request when it is a terraform-defined devcontainer
// that has attached resources. These subagents are pre-provisioned by terraform
// (the agent record already exists), so we update configurable fields like
// display_apps rather than creating a new agent.
if req.Id != nil {
id, err := uuid.FromBytes(req.Id)
if err != nil {
return nil, xerrors.Errorf("parse id: %w", err)
}
subAgent, err := a.Database.GetWorkspaceAgentByID(ctx, id)
if err != nil {
return nil, xerrors.Errorf("get workspace agent by id: %w", err)
}
// Validate that the subagent belongs to the current parent agent to
// prevent updating subagents from other agents within the same workspace.
if !subAgent.ParentID.Valid || subAgent.ParentID.UUID != parentAgent.ID {
return nil, xerrors.Errorf("subagent does not belong to this parent agent")
}
if err := a.Database.UpdateWorkspaceAgentDisplayAppsByID(ctx, database.UpdateWorkspaceAgentDisplayAppsByIDParams{
ID: id,
DisplayApps: displayApps,
UpdatedAt: createdAt,
}); err != nil {
return nil, xerrors.Errorf("update workspace agent display apps: %w", err)
}
return &agentproto.CreateSubAgentResponse{
Agent: &agentproto.SubAgent{
Name: subAgent.Name,
Id: subAgent.ID[:],
AuthToken: subAgent.AuthToken[:],
},
}, nil
}
agentName := req.Name
if agentName == "" {
return nil, codersdk.ValidationError{
Field: "name",
Detail: "agent name cannot be empty",
}
}
if !provisioner.AgentNameRegex.MatchString(agentName) {
return nil, codersdk.ValidationError{
Field: "name",
Detail: fmt.Sprintf("agent name %q does not match regex %q", agentName, provisioner.AgentNameRegex),
}
}
subAgent, err := a.Database.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
ID: uuid.New(),
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID},
+230
View File
@@ -1132,6 +1132,236 @@ func TestSubAgentAPI(t *testing.T) {
require.Equal(t, "Custom App", apps[0].DisplayName)
})
t.Run("CreateSubAgent_UpdateExisting", func(t *testing.T) {
t.Parallel()
t.Run("OK_UpdateDisplayApps", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An existing child agent with some display apps.
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
ResourceID: agent.ResourceID,
Name: "existing-child-agent",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
})
// When: We call CreateSubAgent with the existing agent's ID and new display apps.
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
proto.CreateSubAgentRequest_SSH_HELPER,
},
})
require.NoError(t, err)
// Then: The response contains the existing agent's details.
require.NotNil(t, createResp.Agent)
require.Equal(t, childAgent.Name, createResp.Agent.Name)
require.Equal(t, childAgent.ID[:], createResp.Agent.Id)
require.Equal(t, childAgent.AuthToken[:], createResp.Agent.AuthToken)
// And: The database agent's display apps are updated.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
require.NoError(t, err)
require.Len(t, updatedAgent.DisplayApps, 2)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppWebTerminal)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppSSHHelper)
})
t.Run("Error_MalformedID", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// When: We call CreateSubAgent with malformed ID bytes (not 16 bytes).
// uuid.FromBytes requires exactly 16 bytes, so we provide fewer.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: []byte("short"),
})
// Then: We expect an error about parsing the ID.
require.Error(t, err)
require.Contains(t, err.Error(), "parse id")
})
t.Run("Error_AgentNotFound", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// When: We call CreateSubAgent with a non-existent agent ID.
nonExistentID := uuid.New()
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: nonExistentID[:],
})
// Then: We expect an error about the agent not being found.
require.Error(t, err)
require.Contains(t, err.Error(), "get workspace agent by id")
})
t.Run("Error_ParentMismatch", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Create a second agent (sibling) within the same workspace/resource.
// This sibling has a different parent ID (or no parent).
siblingAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: false}, // No parent - it's a top-level agent
ResourceID: agent.ResourceID,
Name: "sibling-agent",
Directory: "/workspaces/sibling",
Architecture: "amd64",
OperatingSystem: "linux",
})
// Create a child of the sibling agent (not our agent).
childOfSibling := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: siblingAgent.ID},
ResourceID: agent.ResourceID,
Name: "child-of-sibling",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
})
// When: Our API (which is for `agent`) tries to update the child of `siblingAgent`.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childOfSibling.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
})
// Then: We expect an error about the parent mismatch.
require.Error(t, err)
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
})
t.Run("OK_OtherFieldsNotModified", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An existing child agent with specific properties.
originalName := "original-child-agent"
originalDir := "/workspaces/original"
originalArch := "amd64"
originalOS := "linux"
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
ResourceID: agent.ResourceID,
Name: originalName,
Directory: originalDir,
Architecture: originalArch,
OperatingSystem: originalOS,
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
})
// When: We call CreateSubAgent with different values for name, directory, arch, and OS.
createResp, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
Name: "different-name",
Directory: "/different/path",
Architecture: "arm64",
OperatingSystem: "darwin",
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
},
})
require.NoError(t, err)
// Then: The response contains the original agent name, not the new one.
require.NotNil(t, createResp.Agent)
require.Equal(t, originalName, createResp.Agent.Name)
// And: The database agent's other fields are unchanged.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), childAgent.ID)
require.NoError(t, err)
require.Equal(t, originalName, updatedAgent.Name)
require.Equal(t, originalDir, updatedAgent.Directory)
require.Equal(t, originalArch, updatedAgent.Architecture)
require.Equal(t, originalOS, updatedAgent.OperatingSystem)
// But display apps should be updated.
require.Len(t, updatedAgent.DisplayApps, 1)
require.Equal(t, database.DisplayAppWebTerminal, updatedAgent.DisplayApps[0])
})
t.Run("Error_NoParentID", func(t *testing.T) {
t.Parallel()
log := testutil.Logger(t)
ctx := testutil.Context(t, testutil.WaitLong)
clock := quartz.NewMock(t)
db, org := newDatabaseWithOrg(t)
user, agent := newUserWithWorkspaceAgent(t, db, org)
api := newAgentAPI(t, log, db, clock, user, org, agent)
// Given: An agent without a parent (a top-level agent).
topLevelAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: false}, // No parent
ResourceID: agent.ResourceID,
Name: "top-level-agent",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
})
// When: We try to update this agent as if it were a subagent.
_, err := api.CreateSubAgent(ctx, &proto.CreateSubAgentRequest{
Id: topLevelAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
})
// Then: We expect an error because the agent has no parent.
require.Error(t, err)
require.Contains(t, err.Error(), "subagent does not belong to this parent agent")
})
})
t.Run("ListSubAgents", func(t *testing.T) {
t.Parallel()
+8
View File
@@ -20774,6 +20774,14 @@ const docTemplate = `{
}
]
},
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": {
"type": "string"
}
+8
View File
@@ -19082,6 +19082,14 @@
}
]
},
"subagent_id": {
"format": "uuid",
"allOf": [
{
"$ref": "#/definitions/uuid.NullUUID"
}
]
},
"workspace_folder": {
"type": "string"
}
+13
View File
@@ -5726,6 +5726,19 @@ func (q *querier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg da
return q.db.UpdateWorkspaceAgentConnectionByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
if err != nil {
return err
}
if err := q.authorizeContext(ctx, policy.ActionUpdate, workspace); err != nil {
return err
}
return q.db.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
}
func (q *querier) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
workspace, err := q.db.GetWorkspaceByAgentID(ctx, arg.ID)
if err != nil {
+11
View File
@@ -1930,6 +1930,17 @@ func (s *MethodTestSuite) TestWorkspace() {
dbm.EXPECT().UpdateWorkspaceAgentStartupByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
}))
s.Run("UpdateWorkspaceAgentDisplayAppsByID", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
w := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
arg := database.UpdateWorkspaceAgentDisplayAppsByIDParams{
ID: agt.ID,
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
}
dbm.EXPECT().GetWorkspaceByAgentID(gomock.Any(), agt.ID).Return(w, nil).AnyTimes()
dbm.EXPECT().UpdateWorkspaceAgentDisplayAppsByID(gomock.Any(), arg).Return(nil).AnyTimes()
check.Args(arg).Asserts(w, policy.ActionUpdate).Returns()
}))
s.Run("GetWorkspaceAgentLogsAfter", s.Mocked(func(dbm *dbmock.MockStore, faker *gofakeit.Faker, check *expects) {
ws := testutil.Fake(s.T(), faker, database.Workspace{})
agt := testutil.Fake(s.T(), faker, database.WorkspaceAgent{})
@@ -3909,6 +3909,14 @@ func (m queryMetricsStore) UpdateWorkspaceAgentConnectionByID(ctx context.Contex
return r0
}
func (m queryMetricsStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceAgentDisplayAppsByID(ctx, arg)
m.queryLatencies.WithLabelValues("UpdateWorkspaceAgentDisplayAppsByID").Observe(time.Since(start).Seconds())
m.queryCounts.WithLabelValues(httpmw.ExtractHTTPRoute(ctx), httpmw.ExtractHTTPMethod(ctx), "UpdateWorkspaceAgentDisplayAppsByID").Inc()
return r0
}
func (m queryMetricsStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
start := time.Now()
r0 := m.s.UpdateWorkspaceAgentLifecycleStateByID(ctx, arg)
+14
View File
@@ -7321,6 +7321,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentConnectionByID(ctx, arg any
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentConnectionByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentConnectionByID), ctx, arg)
}
// UpdateWorkspaceAgentDisplayAppsByID mocks base method.
func (m *MockStore) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg database.UpdateWorkspaceAgentDisplayAppsByIDParams) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpdateWorkspaceAgentDisplayAppsByID", ctx, arg)
ret0, _ := ret[0].(error)
return ret0
}
// UpdateWorkspaceAgentDisplayAppsByID indicates an expected call of UpdateWorkspaceAgentDisplayAppsByID.
func (mr *MockStoreMockRecorder) UpdateWorkspaceAgentDisplayAppsByID(ctx, arg any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspaceAgentDisplayAppsByID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspaceAgentDisplayAppsByID), ctx, arg)
}
// UpdateWorkspaceAgentLifecycleStateByID mocks base method.
func (m *MockStore) UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg database.UpdateWorkspaceAgentLifecycleStateByIDParams) error {
m.ctrl.T.Helper()
+1
View File
@@ -738,6 +738,7 @@ type sqlcQuerier interface {
UpdateWorkspace(ctx context.Context, arg UpdateWorkspaceParams) (WorkspaceTable, error)
UpdateWorkspaceACLByID(ctx context.Context, arg UpdateWorkspaceACLByIDParams) error
UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg UpdateWorkspaceAgentConnectionByIDParams) error
UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error
UpdateWorkspaceAgentLifecycleStateByID(ctx context.Context, arg UpdateWorkspaceAgentLifecycleStateByIDParams) error
UpdateWorkspaceAgentLogOverflowByID(ctx context.Context, arg UpdateWorkspaceAgentLogOverflowByIDParams) error
UpdateWorkspaceAgentMetadata(ctx context.Context, arg UpdateWorkspaceAgentMetadataParams) error
+20
View File
@@ -19306,6 +19306,26 @@ func (q *sqlQuerier) UpdateWorkspaceAgentConnectionByID(ctx context.Context, arg
return err
}
const updateWorkspaceAgentDisplayAppsByID = `-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
UPDATE
workspace_agents
SET
display_apps = $2, updated_at = $3
WHERE
id = $1
`
type UpdateWorkspaceAgentDisplayAppsByIDParams struct {
ID uuid.UUID `db:"id" json:"id"`
DisplayApps []DisplayApp `db:"display_apps" json:"display_apps"`
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
}
func (q *sqlQuerier) UpdateWorkspaceAgentDisplayAppsByID(ctx context.Context, arg UpdateWorkspaceAgentDisplayAppsByIDParams) error {
_, err := q.db.ExecContext(ctx, updateWorkspaceAgentDisplayAppsByID, arg.ID, pq.Array(arg.DisplayApps), arg.UpdatedAt)
return err
}
const updateWorkspaceAgentLifecycleStateByID = `-- name: UpdateWorkspaceAgentLifecycleStateByID :exec
UPDATE
workspace_agents
@@ -180,6 +180,14 @@ SET
WHERE
id = $1;
-- name: UpdateWorkspaceAgentDisplayAppsByID :exec
UPDATE
workspace_agents
SET
display_apps = $2, updated_at = $3
WHERE
id = $1;
-- name: GetWorkspaceAgentLogsAfter :many
SELECT
*
+15
View File
@@ -425,11 +425,20 @@ func DevcontainerFromProto(pdc *proto.WorkspaceAgentDevcontainer) (codersdk.Work
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse id: %w", err)
}
var subagentID uuid.NullUUID
if pdc.SubagentId != nil {
subagentID.Valid = true
subagentID.UUID, err = uuid.FromBytes(pdc.SubagentId)
if err != nil {
return codersdk.WorkspaceAgentDevcontainer{}, xerrors.Errorf("parse subagent id: %w", err)
}
}
return codersdk.WorkspaceAgentDevcontainer{
ID: id,
Name: pdc.Name,
WorkspaceFolder: pdc.WorkspaceFolder,
ConfigPath: pdc.ConfigPath,
SubagentID: subagentID,
}, nil
}
@@ -442,10 +451,16 @@ func ProtoFromDevcontainers(dcs []codersdk.WorkspaceAgentDevcontainer) []*proto.
}
func ProtoFromDevcontainer(dc codersdk.WorkspaceAgentDevcontainer) *proto.WorkspaceAgentDevcontainer {
var subagentID []byte
if dc.SubagentID.Valid {
subagentID = dc.SubagentID.UUID[:]
}
return &proto.WorkspaceAgentDevcontainer{
Id: dc.ID[:],
Name: dc.Name,
WorkspaceFolder: dc.WorkspaceFolder,
ConfigPath: dc.ConfigPath,
SubagentId: subagentID,
}
}
+1
View File
@@ -136,6 +136,7 @@ func TestManifest(t *testing.T) {
ID: uuid.New(),
WorkspaceFolder: "/home/coder/coder",
ConfigPath: "/home/coder/coder/.devcontainer/devcontainer.json",
SubagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
},
},
}
+12 -4
View File
@@ -440,10 +440,11 @@ func (s WorkspaceAgentDevcontainerStatus) Transitioning() bool {
// WorkspaceAgentDevcontainer defines the location of a devcontainer
// configuration in a workspace that is visible to the workspace agent.
type WorkspaceAgentDevcontainer struct {
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
WorkspaceFolder string `json:"workspace_folder"`
ConfigPath string `json:"config_path,omitempty"`
ID uuid.UUID `json:"id" format:"uuid"`
Name string `json:"name"`
WorkspaceFolder string `json:"workspace_folder"`
ConfigPath string `json:"config_path,omitempty"`
SubagentID uuid.NullUUID `json:"subagent_id,omitempty" format:"uuid"`
// Additional runtime fields.
Status WorkspaceAgentDevcontainerStatus `json:"status"`
@@ -458,6 +459,7 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
return d.ID == other.ID &&
d.Name == other.Name &&
d.WorkspaceFolder == other.WorkspaceFolder &&
d.SubagentID == other.SubagentID &&
d.Status == other.Status &&
d.Dirty == other.Dirty &&
(d.Container == nil && other.Container == nil ||
@@ -467,6 +469,12 @@ func (d WorkspaceAgentDevcontainer) Equals(other WorkspaceAgentDevcontainer) boo
d.Error == other.Error
}
// IsTerraformDefined returns true if this devcontainer has resources defined
// in Terraform.
func (d WorkspaceAgentDevcontainer) IsTerraformDefined() bool {
return d.SubagentID.Valid
}
// WorkspaceAgentDevcontainerAgent represents the sub agent for a
// devcontainer.
type WorkspaceAgentDevcontainerAgent struct {
+170
View File
@@ -110,3 +110,173 @@ func TestWorkspaceAgentLogTextSpecialChars(t *testing.T) {
result := log.Text("main", "startup_script")
require.Equal(t, "2024-01-28T10:30:00Z [debug] [agent.main|startup_script] \033[31mError!\033[0m 🚀 Unicode: 日本語", result)
}
func TestWorkspaceAgentDevcontainerEquals(t *testing.T) {
t.Parallel()
baseID := uuid.New()
subagentID := uuid.New()
containerID := "container-123"
agentID := uuid.New()
base := codersdk.WorkspaceAgentDevcontainer{
ID: baseID,
Name: "test-dc",
WorkspaceFolder: "/workspace",
Status: codersdk.WorkspaceAgentDevcontainerStatusRunning,
Dirty: false,
Container: &codersdk.WorkspaceAgentContainer{ID: containerID},
Agent: &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-1"},
Error: "",
}
tests := []struct {
name string
modify func(codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer
expectEqual bool
}{
{
name: "identical",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer { return d },
expectEqual: true,
},
{
name: "different ID",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.ID = uuid.New()
return d
},
expectEqual: false,
},
{
name: "different Name",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Name = "other-dc"
return d
},
expectEqual: false,
},
{
name: "different WorkspaceFolder",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.WorkspaceFolder = "/other"
return d
},
expectEqual: false,
},
{
name: "different SubagentID (one valid, one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: subagentID}
return d
},
expectEqual: false,
},
{
name: "different SubagentID UUIDs",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.SubagentID = uuid.NullUUID{Valid: true, UUID: uuid.New()}
return d
},
expectEqual: false,
},
{
name: "different Status",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Status = codersdk.WorkspaceAgentDevcontainerStatusStopped
return d
},
expectEqual: false,
},
{
name: "different Dirty",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Dirty = true
return d
},
expectEqual: false,
},
{
name: "different Container (one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Container = nil
return d
},
expectEqual: false,
},
{
name: "different Container IDs",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Container = &codersdk.WorkspaceAgentContainer{ID: "different-container"}
return d
},
expectEqual: false,
},
{
name: "different Agent (one nil)",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Agent = nil
return d
},
expectEqual: false,
},
{
name: "different Agent values",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Agent = &codersdk.WorkspaceAgentDevcontainerAgent{ID: agentID, Name: "agent-2"}
return d
},
expectEqual: false,
},
{
name: "different Error",
modify: func(d codersdk.WorkspaceAgentDevcontainer) codersdk.WorkspaceAgentDevcontainer {
d.Error = "some error"
return d
},
expectEqual: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
other := tt.modify(base)
require.Equal(t, tt.expectEqual, base.Equals(other))
})
}
}
func TestWorkspaceAgentDevcontainerIsTerraformDefined(t *testing.T) {
t.Parallel()
tests := []struct {
name string
subagentID uuid.NullUUID
expectIsTerraformDefined bool
}{
{
name: "false when SubagentID is not valid",
subagentID: uuid.NullUUID{},
expectIsTerraformDefined: false,
},
{
name: "true when SubagentID is valid",
subagentID: uuid.NullUUID{Valid: true, UUID: uuid.New()},
expectIsTerraformDefined: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
dc := codersdk.WorkspaceAgentDevcontainer{
ID: uuid.New(),
Name: "test-dc",
WorkspaceFolder: "/workspace",
SubagentID: tt.subagentID,
}
require.Equal(t, tt.expectIsTerraformDefined, dc.IsTerraformDefined())
})
}
}
+8
View File
@@ -838,6 +838,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
@@ -1015,6 +1019,10 @@ curl -X GET http://coder-server:8080/api/v2/workspaceagents/{workspaceagent}/con
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
+9
View File
@@ -10514,6 +10514,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
```
@@ -10530,6 +10534,7 @@ If the schedule is empty, the user will be updated to use the default schedule.|
| `id` | string | false | | |
| `name` | string | false | | |
| `status` | [codersdk.WorkspaceAgentDevcontainerStatus](#codersdkworkspaceagentdevcontainerstatus) | false | | Additional runtime fields. |
| `subagent_id` | [uuid.NullUUID](#uuidnulluuid) | false | | |
| `workspace_folder` | string | false | | |
## codersdk.WorkspaceAgentDevcontainerAgent
@@ -10661,6 +10666,10 @@ If the schedule is empty, the user will be updated to use the default schedule.|
"id": "497f6eca-6276-4993-bfeb-53cbbbba6f08",
"name": "string",
"status": "running",
"subagent_id": {
"uuid": "string",
"valid": true
},
"workspace_folder": "string"
}
],
+1
View File
@@ -6290,6 +6290,7 @@ export interface WorkspaceAgentDevcontainer {
readonly name: string;
readonly workspace_folder: string;
readonly config_path?: string;
readonly subagent_id?: string;
/**
* Additional runtime fields.
*/
@@ -20,7 +20,7 @@ import {
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { getPreferredProxy } from "contexts/ProxyContext";
import { spyOn, userEvent, within } from "storybook/test";
import { screen, spyOn, userEvent, within } from "storybook/test";
import { AgentDevcontainerCard } from "./AgentDevcontainerCard";
const meta: Meta<typeof AgentDevcontainerCard> = {
@@ -185,6 +185,37 @@ export const WithPortForwarding: Story = {
],
};
export const PrecreatedSubAgent: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByTestId("precreated-subagent-rebuild-trigger");
await userEvent.hover(trigger);
await screen.findByRole("tooltip");
},
};
export const PrecreatedSubAgentDirty: Story = {
args: {
devcontainer: {
...MockWorkspaceAgentDevcontainer,
subagent_id: "precreated-subagent-id",
dirty: true,
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const outdatedStatus = canvas.getByText("Outdated");
await userEvent.hover(outdatedStatus);
await screen.findByRole("tooltip");
},
};
export const WithDeleteError: Story = {
beforeEach: () => {
spyOn(API, "deleteDevContainer").mockRejectedValue(
@@ -42,6 +42,7 @@ import { AgentButton } from "./AgentButton";
import { AgentDevcontainerMoreActions } from "./AgentDevcontainerMoreActions";
import { AgentLatency } from "./AgentLatency";
import { DevcontainerStatus } from "./AgentStatus";
import { isTerraformDefined } from "./devcontainerUtils";
import { PortForwardButton } from "./PortForwardButton";
import { AgentSSHButton } from "./SSHButton/SSHButton";
import { SubAgentOutdatedTooltip } from "./SubAgentOutdatedTooltip";
@@ -162,6 +163,8 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
const showSubAgentAppsPlaceholders =
devcontainer.status === "starting" || subAgent?.status === "connecting";
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
const handleRebuildDevcontainer = () => {
rebuildDevcontainerMutation.mutate();
};
@@ -232,16 +235,31 @@ export const AgentDevcontainerCard: FC<AgentDevcontainerCardProps> = ({
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={isTransitioning}
>
<Spinner loading={isTransitioning} />
{rebuildButtonLabel(devcontainer)}
</Button>
{hasPrecreatedSubagent ? (
<Tooltip>
<TooltipTrigger asChild>
<span data-testid="precreated-subagent-rebuild-trigger">
<Button variant="outline" size="sm" disabled>
{rebuildButtonLabel(devcontainer)}
</Button>
</span>
</TooltipTrigger>
<TooltipContent>
This dev container is defined in Terraform and cannot be rebuilt
from the UI.
</TooltipContent>
</Tooltip>
) : (
<Button
variant="outline"
size="sm"
onClick={handleRebuildDevcontainer}
disabled={isTransitioning}
>
<Spinner loading={isTransitioning} />
{rebuildButtonLabel(devcontainer)}
</Button>
)}
{showDevcontainerControls && displayApps.includes("ssh_helper") && (
<AgentSSHButton
@@ -10,10 +10,10 @@ import {
HelpTooltipText,
HelpTooltipTitle,
} from "components/HelpTooltip/HelpTooltip";
import { Stack } from "components/Stack/Stack";
import { TooltipTrigger } from "components/Tooltip/Tooltip";
import { RotateCcwIcon } from "lucide-react";
import type { FC } from "react";
import { isTerraformDefined } from "./devcontainerUtils";
type SubAgentOutdatedTooltipProps = {
devcontainer: WorkspaceAgentDevcontainer;
@@ -33,9 +33,13 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
return null;
}
const hasPrecreatedSubagent = isTerraformDefined(devcontainer);
const title = "Dev Container Outdated";
const opener = "This Dev Container is outdated.";
const text = `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
const text = hasPrecreatedSubagent
? `${opener} This dev container is managed by your template. Update the template to apply changes.`
: `${opener} This can happen if you modify your devcontainer.json file after the Dev Container has been created. To fix this, you can rebuild the Dev Container.`;
return (
<HelpTooltip>
@@ -45,22 +49,24 @@ export const SubAgentOutdatedTooltip: FC<SubAgentOutdatedTooltipProps> = ({
</span>
</TooltipTrigger>
<HelpTooltipContent>
<Stack spacing={1}>
<div className="flex flex-col gap-2">
<div>
<HelpTooltipTitle>{title}</HelpTooltipTitle>
<HelpTooltipText>{text}</HelpTooltipText>
</div>
<HelpTooltipLinksGroup>
<HelpTooltipAction
icon={RotateCcwIcon}
onClick={onUpdate}
ariaLabel="Rebuild Dev Container"
>
Rebuild Dev Container
</HelpTooltipAction>
</HelpTooltipLinksGroup>
</Stack>
{!hasPrecreatedSubagent && (
<HelpTooltipLinksGroup>
<HelpTooltipAction
icon={RotateCcwIcon}
onClick={onUpdate}
ariaLabel="Rebuild Dev Container"
>
Rebuild Dev Container
</HelpTooltipAction>
</HelpTooltipLinksGroup>
)}
</div>
</HelpTooltipContent>
</HelpTooltip>
);
@@ -0,0 +1,10 @@
import type { WorkspaceAgentDevcontainer } from "api/typesGenerated";
/**
* Returns true if this devcontainer has resources defined in Terraform.
*/
export function isTerraformDefined(
devcontainer: WorkspaceAgentDevcontainer,
): boolean {
return Boolean(devcontainer.subagent_id);
}