feat(coderd/agentapi): support terraform-defined subagent ids (#21837)

Update `coderd/agentapi` to handle pre-created sub agents
This commit is contained in:
Danielle Maywood
2026-02-04 15:33:48 +00:00
committed by GitHub
parent 29b1aea736
commit af0e171595
27 changed files with 432 additions and 40 deletions
+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 agent 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},
+219
View File
@@ -1132,6 +1132,225 @@ func TestSubAgentAPI(t *testing.T) {
require.Equal(t, "Custom App", apps[0].DisplayName)
})
t.Run("CreateSubAgentUpdatesExisting", func(t *testing.T) {
t.Parallel()
baseChildAgent := database.WorkspaceAgent{
Name: "existing-child-agent",
Directory: "/workspaces/test",
Architecture: "amd64",
OperatingSystem: "linux",
DisplayApps: []database.DisplayApp{database.DisplayAppVscode},
}
type testCase struct {
name string
setup func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest
wantErr string
check func(t *testing.T, ctx context.Context, db database.Store, resp *proto.CreateSubAgentResponse, agent database.WorkspaceAgent)
}
tests := []testCase{
{
name: "OK",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// 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: baseChildAgent.Name,
Directory: baseChildAgent.Directory,
Architecture: baseChildAgent.Architecture,
OperatingSystem: baseChildAgent.OperatingSystem,
DisplayApps: baseChildAgent.DisplayApps,
})
// When: We call CreateSubAgent with the existing agent's ID and new display apps.
return &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
proto.CreateSubAgentRequest_SSH_HELPER,
},
}
},
check: func(t *testing.T, ctx context.Context, db database.Store, resp *proto.CreateSubAgentResponse, agent database.WorkspaceAgent) {
// Then: The response contains the existing agent's details.
require.NotNil(t, resp.Agent)
require.Equal(t, baseChildAgent.Name, resp.Agent.Name)
agentID, err := uuid.FromBytes(resp.Agent.Id)
require.NoError(t, err)
// And: The database agent's display apps are updated.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
require.Len(t, updatedAgent.DisplayApps, 2)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppWebTerminal)
require.Contains(t, updatedAgent.DisplayApps, database.DisplayAppSSHHelper)
},
},
{
name: "OK_OtherFieldsNotModified",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// Given: An existing child agent with specific properties.
childAgent := dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ParentID: uuid.NullUUID{Valid: true, UUID: agent.ID},
ResourceID: agent.ResourceID,
Name: baseChildAgent.Name,
Directory: baseChildAgent.Directory,
Architecture: baseChildAgent.Architecture,
OperatingSystem: baseChildAgent.OperatingSystem,
DisplayApps: baseChildAgent.DisplayApps,
})
// When: We call CreateSubAgent with different values for name, directory, arch, and OS.
return &proto.CreateSubAgentRequest{
Id: childAgent.ID[:],
Name: "different-name",
Directory: "/different/path",
Architecture: "arm64",
OperatingSystem: "darwin",
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_WEB_TERMINAL,
},
}
},
check: func(t *testing.T, ctx context.Context, db database.Store, resp *proto.CreateSubAgentResponse, agent database.WorkspaceAgent) {
// Then: The response contains the original agent name, not the new one.
require.NotNil(t, resp.Agent)
require.Equal(t, baseChildAgent.Name, resp.Agent.Name)
agentID, err := uuid.FromBytes(resp.Agent.Id)
require.NoError(t, err)
// And: The database agent's other fields are unchanged.
updatedAgent, err := db.GetWorkspaceAgentByID(dbauthz.AsSystemRestricted(ctx), agentID)
require.NoError(t, err)
require.Equal(t, baseChildAgent.Name, updatedAgent.Name)
require.Equal(t, baseChildAgent.Directory, updatedAgent.Directory)
require.Equal(t, baseChildAgent.Architecture, updatedAgent.Architecture)
require.Equal(t, baseChildAgent.OperatingSystem, updatedAgent.OperatingSystem)
// But display apps should be updated.
require.Len(t, updatedAgent.DisplayApps, 1)
require.Equal(t, database.DisplayAppWebTerminal, updatedAgent.DisplayApps[0])
},
},
{
name: "Error/MalformedID",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// When: We call CreateSubAgent with malformed ID bytes (not 16 bytes).
// uuid.FromBytes requires exactly 16 bytes, so we provide fewer.
return &proto.CreateSubAgentRequest{
Id: []byte("short"),
}
},
wantErr: "parse agent id",
},
{
name: "Error/AgentNotFound",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// When: We call CreateSubAgent with a non-existent agent ID.
nonExistentID := uuid.New()
return &proto.CreateSubAgentRequest{
Id: nonExistentID[:],
}
},
wantErr: "get workspace agent by id",
},
{
name: "Error/ParentMismatch",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// 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`.
return &proto.CreateSubAgentRequest{
Id: childOfSibling.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
}
},
wantErr: "subagent does not belong to this parent agent",
},
{
name: "Error/NoParentID",
setup: func(t *testing.T, db database.Store, agent database.WorkspaceAgent) *proto.CreateSubAgentRequest {
// 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.
return &proto.CreateSubAgentRequest{
Id: topLevelAgent.ID[:],
DisplayApps: []proto.CreateSubAgentRequest_DisplayApp{
proto.CreateSubAgentRequest_VSCODE,
},
}
},
wantErr: "subagent does not belong to this parent agent",
},
}
for _, tc := range tests {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
log = testutil.Logger(t)
clock = quartz.NewMock(t)
db, org = newDatabaseWithOrg(t)
user, agent = newUserWithWorkspaceAgent(t, db, org)
api = newAgentAPI(t, log, db, clock, user, org, agent)
)
req := tc.setup(t, db, agent)
ctx := testutil.Context(t, testutil.WaitShort)
resp, err := api.CreateSubAgent(ctx, req)
if tc.wantErr != "" {
require.Error(t, err)
require.Contains(t, err.Error(), tc.wantErr)
return
}
require.NoError(t, err)
if tc.check != nil {
tc.check(t, ctx, db, resp, agent)
}
})
}
})
t.Run("ListSubAgents", func(t *testing.T) {
t.Parallel()