feat(coderd/provisionerdserver): insert sub agent resource (#21699)

Update provisionerdserver to handle the changes introduced to
provisionerd in https://github.com/coder/coder/pull/21602

We now create a relationship between `workspace_agent_devcontainers` and
`workspace_agents` with the newly created `subagent_id`.
This commit is contained in:
Danielle Maywood
2026-01-30 17:19:19 +00:00
committed by GitHub
parent 14b4650d6c
commit 37aecda165
11 changed files with 898 additions and 198 deletions
+1
View File
@@ -394,6 +394,7 @@ func WorkspaceAgentDevcontainer(t testing.TB, db database.Store, orig database.W
Name: []string{takeFirst(orig.Name, testutil.GetRandomName(t))},
WorkspaceFolder: []string{takeFirst(orig.WorkspaceFolder, "/workspace")},
ConfigPath: []string{takeFirst(orig.ConfigPath, "")},
SubagentID: []uuid.UUID{orig.SubagentID.UUID},
})
require.NoError(t, err, "insert workspace agent devcontainer")
return devcontainers[0]
+5 -1
View File
@@ -2457,7 +2457,8 @@ CREATE TABLE workspace_agent_devcontainers (
created_at timestamp with time zone DEFAULT now() NOT NULL,
workspace_folder text NOT NULL,
config_path text NOT NULL,
name text NOT NULL
name text NOT NULL,
subagent_id uuid
);
COMMENT ON TABLE workspace_agent_devcontainers IS 'Workspace agent devcontainer configuration';
@@ -3737,6 +3738,9 @@ ALTER TABLE ONLY user_status_changes
ALTER TABLE ONLY webpush_subscriptions
ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_agent_devcontainers
ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ALTER TABLE ONLY workspace_agent_devcontainers
ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -72,6 +72,7 @@ const (
ForeignKeyUserSecretsUserID ForeignKeyConstraint = "user_secrets_user_id_fkey" // ALTER TABLE ONLY user_secrets ADD CONSTRAINT user_secrets_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyUserStatusChangesUserID ForeignKeyConstraint = "user_status_changes_user_id_fkey" // ALTER TABLE ONLY user_status_changes ADD CONSTRAINT user_status_changes_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id);
ForeignKeyWebpushSubscriptionsUserID ForeignKeyConstraint = "webpush_subscriptions_user_id_fkey" // ALTER TABLE ONLY webpush_subscriptions ADD CONSTRAINT webpush_subscriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersSubagentID ForeignKeyConstraint = "workspace_agent_devcontainers_subagent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_subagent_id_fkey FOREIGN KEY (subagent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentDevcontainersWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_devcontainers_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_devcontainers ADD CONSTRAINT workspace_agent_devcontainers_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentLogSourcesWorkspaceAgentID ForeignKeyConstraint = "workspace_agent_log_sources_workspace_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_log_sources ADD CONSTRAINT workspace_agent_log_sources_workspace_agent_id_fkey FOREIGN KEY (workspace_agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
ForeignKeyWorkspaceAgentMemoryResourceMonitorsAgentID ForeignKeyConstraint = "workspace_agent_memory_resource_monitors_agent_id_fkey" // ALTER TABLE ONLY workspace_agent_memory_resource_monitors ADD CONSTRAINT workspace_agent_memory_resource_monitors_agent_id_fkey FOREIGN KEY (agent_id) REFERENCES workspace_agents(id) ON DELETE CASCADE;
@@ -0,0 +1,2 @@
ALTER TABLE workspace_agent_devcontainers
DROP COLUMN subagent_id;
@@ -0,0 +1,2 @@
ALTER TABLE workspace_agent_devcontainers
ADD COLUMN subagent_id UUID REFERENCES workspace_agents(id) ON DELETE CASCADE;
+2 -1
View File
@@ -4771,7 +4771,8 @@ type WorkspaceAgentDevcontainer struct {
// Path to devcontainer.json.
ConfigPath string `db:"config_path" json:"config_path"`
// The name of the Dev Container.
Name string `db:"name" json:"name"`
Name string `db:"name" json:"name"`
SubagentID uuid.NullUUID `db:"subagent_id" json:"subagent_id"`
}
type WorkspaceAgentLog struct {
+102
View File
@@ -7,7 +7,9 @@ import (
"errors"
"fmt"
"net"
"slices"
"sort"
"strings"
"testing"
"time"
@@ -8482,3 +8484,103 @@ func TestGetAuthenticatedWorkspaceAgentAndBuildByAuthToken_ShutdownScripts(t *te
require.ErrorIs(t, err, sql.ErrNoRows, "agent should not authenticate when latest build is not STOP")
})
}
// Our `InsertWorkspaceAgentDevcontainers` query should ideally be `[]uuid.NullUUID` but unfortunately
// sqlc infers it as `[]uuid.UUID`. To ensure we don't insert a `uuid.Nil`, the query inserts NULL when
// passed with `uuid.Nil`. This test ensures we keep this behavior without regression.
func TestInsertWorkspaceAgentDevcontainers(t *testing.T) {
t.Parallel()
testCases := []struct {
name string
validSubagent []bool
}{
{"BothValid", []bool{true, true}},
{"FirstValidSecondInvalid", []bool{true, false}},
{"FirstInvalidSecondValid", []bool{false, true}},
{"BothInvalid", []bool{false, false}},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
var (
db, _ = dbtestutil.NewDB(t)
org = dbgen.Organization(t, db, database.Organization{})
job = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{
Type: database.ProvisionerJobTypeTemplateVersionImport,
OrganizationID: org.ID,
})
resource = dbgen.WorkspaceResource(t, db, database.WorkspaceResource{JobID: job.ID})
agent = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ResourceID: resource.ID})
)
ids := make([]uuid.UUID, len(tc.validSubagent))
names := make([]string, len(tc.validSubagent))
workspaceFolders := make([]string, len(tc.validSubagent))
configPaths := make([]string, len(tc.validSubagent))
subagentIDs := make([]uuid.UUID, len(tc.validSubagent))
for i, valid := range tc.validSubagent {
ids[i] = uuid.New()
names[i] = fmt.Sprintf("test-devcontainer-%d", i)
workspaceFolders[i] = fmt.Sprintf("/workspace%d", i)
configPaths[i] = fmt.Sprintf("/workspace%d/.devcontainer/devcontainer.json", i)
if valid {
subagentIDs[i] = dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{
ResourceID: resource.ID,
ParentID: uuid.NullUUID{UUID: agent.ID, Valid: true},
}).ID
} else {
subagentIDs[i] = uuid.Nil
}
}
ctx := testutil.Context(t, testutil.WaitShort)
// Given: We insert multiple devcontainer records.
devcontainers, err := db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{
WorkspaceAgentID: agent.ID,
CreatedAt: dbtime.Now(),
ID: ids,
Name: names,
WorkspaceFolder: workspaceFolders,
ConfigPath: configPaths,
SubagentID: subagentIDs,
})
require.NoError(t, err)
require.Len(t, devcontainers, len(tc.validSubagent))
// Then: Verify each devcontainer has the correct SubagentID validity.
// - When we pass `uuid.Nil`, we get a `uuid.NullUUID{Valid: false}`
// - When we pass a valid UUID, we get a `uuid.NullUUID{Valid: true}`
for i, valid := range tc.validSubagent {
require.Equal(t, valid, devcontainers[i].SubagentID.Valid, "devcontainer %d: subagent_id validity mismatch", i)
if valid {
require.Equal(t, subagentIDs[i], devcontainers[i].SubagentID.UUID, "devcontainer %d: subagent_id UUID mismatch", i)
}
}
// Perform the same check on data returned by
// `GetWorkspaceAgentDevcontainersByAgentID` to ensure the fix is at
// the data storage layer, instead of just at a query level.
fetched, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID)
require.NoError(t, err)
require.Len(t, fetched, len(tc.validSubagent))
// Sort fetched by name to ensure consistent ordering for comparison.
slices.SortFunc(fetched, func(a, b database.WorkspaceAgentDevcontainer) int {
return strings.Compare(a.Name, b.Name)
})
for i, valid := range tc.validSubagent {
require.Equal(t, valid, fetched[i].SubagentID.Valid, "fetched devcontainer %d: subagent_id validity mismatch", i)
if valid {
require.Equal(t, subagentIDs[i], fetched[i].SubagentID.UUID, "fetched devcontainer %d: subagent_id UUID mismatch", i)
}
}
})
}
}
+9 -4
View File
@@ -17213,7 +17213,7 @@ func (q *sqlQuerier) ValidateUserIDs(ctx context.Context, userIds []uuid.UUID) (
const getWorkspaceAgentDevcontainersByAgentID = `-- name: GetWorkspaceAgentDevcontainersByAgentID :many
SELECT
id, workspace_agent_id, created_at, workspace_folder, config_path, name
id, workspace_agent_id, created_at, workspace_folder, config_path, name, subagent_id
FROM
workspace_agent_devcontainers
WHERE
@@ -17238,6 +17238,7 @@ func (q *sqlQuerier) GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context
&i.WorkspaceFolder,
&i.ConfigPath,
&i.Name,
&i.SubagentID,
); err != nil {
return nil, err
}
@@ -17254,15 +17255,16 @@ func (q *sqlQuerier) GetWorkspaceAgentDevcontainersByAgentID(ctx context.Context
const insertWorkspaceAgentDevcontainers = `-- name: InsertWorkspaceAgentDevcontainers :many
INSERT INTO
workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path)
workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path, subagent_id)
SELECT
$1::uuid AS workspace_agent_id,
$2::timestamptz AS created_at,
unnest($3::uuid[]) AS id,
unnest($4::text[]) AS name,
unnest($5::text[]) AS workspace_folder,
unnest($6::text[]) AS config_path
RETURNING workspace_agent_devcontainers.id, workspace_agent_devcontainers.workspace_agent_id, workspace_agent_devcontainers.created_at, workspace_agent_devcontainers.workspace_folder, workspace_agent_devcontainers.config_path, workspace_agent_devcontainers.name
unnest($6::text[]) AS config_path,
NULLIF(unnest($7::uuid[]), '00000000-0000-0000-0000-000000000000')::uuid AS subagent_id
RETURNING workspace_agent_devcontainers.id, workspace_agent_devcontainers.workspace_agent_id, workspace_agent_devcontainers.created_at, workspace_agent_devcontainers.workspace_folder, workspace_agent_devcontainers.config_path, workspace_agent_devcontainers.name, workspace_agent_devcontainers.subagent_id
`
type InsertWorkspaceAgentDevcontainersParams struct {
@@ -17272,6 +17274,7 @@ type InsertWorkspaceAgentDevcontainersParams struct {
Name []string `db:"name" json:"name"`
WorkspaceFolder []string `db:"workspace_folder" json:"workspace_folder"`
ConfigPath []string `db:"config_path" json:"config_path"`
SubagentID []uuid.UUID `db:"subagent_id" json:"subagent_id"`
}
func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg InsertWorkspaceAgentDevcontainersParams) ([]WorkspaceAgentDevcontainer, error) {
@@ -17282,6 +17285,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg
pq.Array(arg.Name),
pq.Array(arg.WorkspaceFolder),
pq.Array(arg.ConfigPath),
pq.Array(arg.SubagentID),
)
if err != nil {
return nil, err
@@ -17297,6 +17301,7 @@ func (q *sqlQuerier) InsertWorkspaceAgentDevcontainers(ctx context.Context, arg
&i.WorkspaceFolder,
&i.ConfigPath,
&i.Name,
&i.SubagentID,
); err != nil {
return nil, err
}
@@ -1,13 +1,14 @@
-- name: InsertWorkspaceAgentDevcontainers :many
INSERT INTO
workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path)
workspace_agent_devcontainers (workspace_agent_id, created_at, id, name, workspace_folder, config_path, subagent_id)
SELECT
@workspace_agent_id::uuid AS workspace_agent_id,
@created_at::timestamptz AS created_at,
unnest(@id::uuid[]) AS id,
unnest(@name::text[]) AS name,
unnest(@workspace_folder::text[]) AS workspace_folder,
unnest(@config_path::text[]) AS config_path
unnest(@config_path::text[]) AS config_path,
NULLIF(unnest(@subagent_id::uuid[]), '00000000-0000-0000-0000-000000000000')::uuid AS subagent_id
RETURNING workspace_agent_devcontainers.*;
-- name: GetWorkspaceAgentDevcontainersByAgentID :many
+335 -158
View File
@@ -2031,6 +2031,23 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro
appIDs = append(appIDs, app.GetId())
agentIDByAppID[app.GetId()] = agentID
}
// Subagents in devcontainers can also have apps that need
// tracking for task linking, just like the parent agent's
// apps above.
for _, dc := range protoAgent.GetDevcontainers() {
dc.Id = uuid.New().String()
if dc.GetSubagentId() != "" {
subAgentID := uuid.New()
dc.SubagentId = subAgentID.String()
for _, app := range dc.GetApps() {
appIDs = append(appIDs, app.GetId())
agentIDByAppID[app.GetId()] = subAgentID
}
}
}
}
err = InsertWorkspaceResource(
@@ -2859,33 +2876,7 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
}
}
logSourceIDs := make([]uuid.UUID, 0, len(prAgent.Scripts))
logSourceDisplayNames := make([]string, 0, len(prAgent.Scripts))
logSourceIcons := make([]string, 0, len(prAgent.Scripts))
scriptIDs := make([]uuid.UUID, 0, len(prAgent.Scripts))
scriptDisplayName := make([]string, 0, len(prAgent.Scripts))
scriptLogPaths := make([]string, 0, len(prAgent.Scripts))
scriptSources := make([]string, 0, len(prAgent.Scripts))
scriptCron := make([]string, 0, len(prAgent.Scripts))
scriptTimeout := make([]int32, 0, len(prAgent.Scripts))
scriptStartBlocksLogin := make([]bool, 0, len(prAgent.Scripts))
scriptRunOnStart := make([]bool, 0, len(prAgent.Scripts))
scriptRunOnStop := make([]bool, 0, len(prAgent.Scripts))
for _, script := range prAgent.Scripts {
logSourceIDs = append(logSourceIDs, uuid.New())
logSourceDisplayNames = append(logSourceDisplayNames, script.DisplayName)
logSourceIcons = append(logSourceIcons, script.Icon)
scriptIDs = append(scriptIDs, uuid.New())
scriptDisplayName = append(scriptDisplayName, script.DisplayName)
scriptLogPaths = append(scriptLogPaths, script.LogPath)
scriptSources = append(scriptSources, script.Script)
scriptCron = append(scriptCron, script.Cron)
scriptTimeout = append(scriptTimeout, script.TimeoutSeconds)
scriptStartBlocksLogin = append(scriptStartBlocksLogin, script.StartBlocksLogin)
scriptRunOnStart = append(scriptRunOnStart, script.RunOnStart)
scriptRunOnStop = append(scriptRunOnStop, script.RunOnStop)
}
scriptsParams := agentScriptsFromProto(prAgent.Scripts)
// Dev Containers require a script and log/source, so we do this before
// the logs insert below.
@@ -2895,32 +2886,46 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
devcontainerNames = make([]string, 0, len(devcontainers))
devcontainerWorkspaceFolders = make([]string, 0, len(devcontainers))
devcontainerConfigPaths = make([]string, 0, len(devcontainers))
devcontainerSubagentIDs = make([]uuid.UUID, 0, len(devcontainers))
)
for _, dc := range devcontainers {
id := uuid.New()
if opts.useAgentIDsFromProto {
id, err = uuid.Parse(dc.GetId())
if err != nil {
return xerrors.Errorf("invalid devcontainer ID format; must be uuid: %w", err)
}
}
subAgentID, err := insertDevcontainerSubagent(ctx, db, dc, dbAgent, resource.ID, appSlugs, snapshot, opts)
if err != nil {
return xerrors.Errorf("insert devcontainer %q subagent: %w", dc.GetName(), err)
}
devcontainerIDs = append(devcontainerIDs, id)
devcontainerNames = append(devcontainerNames, dc.Name)
devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.WorkspaceFolder)
devcontainerConfigPaths = append(devcontainerConfigPaths, dc.ConfigPath)
devcontainerNames = append(devcontainerNames, dc.GetName())
devcontainerWorkspaceFolders = append(devcontainerWorkspaceFolders, dc.GetWorkspaceFolder())
devcontainerConfigPaths = append(devcontainerConfigPaths, dc.GetConfigPath())
devcontainerSubagentIDs = append(devcontainerSubagentIDs, subAgentID)
// Add a log source and script for each devcontainer so we can
// track logs and timings for each devcontainer.
displayName := fmt.Sprintf("Dev Container (%s)", dc.Name)
logSourceIDs = append(logSourceIDs, uuid.New())
logSourceDisplayNames = append(logSourceDisplayNames, displayName)
logSourceIcons = append(logSourceIcons, "/emojis/1f4e6.png") // Emoji package. Or perhaps /icon/container.svg?
scriptIDs = append(scriptIDs, id) // Re-use the devcontainer ID as the script ID for identification.
scriptDisplayName = append(scriptDisplayName, displayName)
scriptLogPaths = append(scriptLogPaths, "")
scriptSources = append(scriptSources, `echo "WARNING: Dev Containers are early access. If you're seeing this message then Dev Containers haven't been enabled for your workspace yet. To enable, the agent needs to run with the environment variable CODER_AGENT_DEVCONTAINERS_ENABLE=true set."`)
scriptCron = append(scriptCron, "")
scriptTimeout = append(scriptTimeout, 0)
scriptStartBlocksLogin = append(scriptStartBlocksLogin, false)
displayName := fmt.Sprintf("Dev Container (%s)", dc.GetName())
scriptsParams.LogSourceIDs = append(scriptsParams.LogSourceIDs, uuid.New())
scriptsParams.LogSourceDisplayNames = append(scriptsParams.LogSourceDisplayNames, displayName)
scriptsParams.LogSourceIcons = append(scriptsParams.LogSourceIcons, "/emojis/1f4e6.png") // Emoji package. Or perhaps /icon/container.svg?
scriptsParams.ScriptIDs = append(scriptsParams.ScriptIDs, id) // Re-use the devcontainer ID as the script ID for identification.
scriptsParams.ScriptDisplayNames = append(scriptsParams.ScriptDisplayNames, displayName)
scriptsParams.ScriptLogPaths = append(scriptsParams.ScriptLogPaths, "")
scriptsParams.ScriptSources = append(scriptsParams.ScriptSources, `echo "WARNING: Dev Containers are early access. If you're seeing this message then Dev Containers haven't been enabled for your workspace yet. To enable, the agent needs to run with the environment variable CODER_AGENT_DEVCONTAINERS_ENABLE=true set."`)
scriptsParams.ScriptCron = append(scriptsParams.ScriptCron, "")
scriptsParams.ScriptTimeout = append(scriptsParams.ScriptTimeout, 0)
scriptsParams.ScriptStartBlocksLogin = append(scriptsParams.ScriptStartBlocksLogin, false)
// Run on start to surface the warning message in case the
// terraform resource is used, but the experiment hasn't
// been enabled.
scriptRunOnStart = append(scriptRunOnStart, true)
scriptRunOnStop = append(scriptRunOnStop, false)
scriptsParams.ScriptRunOnStart = append(scriptsParams.ScriptRunOnStart, true)
scriptsParams.ScriptRunOnStop = append(scriptsParams.ScriptRunOnStop, false)
}
_, err = db.InsertWorkspaceAgentDevcontainers(ctx, database.InsertWorkspaceAgentDevcontainersParams{
@@ -2930,131 +2935,21 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid.
Name: devcontainerNames,
WorkspaceFolder: devcontainerWorkspaceFolders,
ConfigPath: devcontainerConfigPaths,
SubagentID: devcontainerSubagentIDs,
})
if err != nil {
return xerrors.Errorf("insert agent devcontainer: %w", err)
}
}
_, err = db.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{
WorkspaceAgentID: agentID,
ID: logSourceIDs,
CreatedAt: dbtime.Now(),
DisplayName: logSourceDisplayNames,
Icon: logSourceIcons,
})
if err != nil {
return xerrors.Errorf("insert agent log sources: %w", err)
}
_, err = db.InsertWorkspaceAgentScripts(ctx, database.InsertWorkspaceAgentScriptsParams{
WorkspaceAgentID: agentID,
LogSourceID: logSourceIDs,
LogPath: scriptLogPaths,
CreatedAt: dbtime.Now(),
Script: scriptSources,
Cron: scriptCron,
TimeoutSeconds: scriptTimeout,
StartBlocksLogin: scriptStartBlocksLogin,
RunOnStart: scriptRunOnStart,
RunOnStop: scriptRunOnStop,
DisplayName: scriptDisplayName,
ID: scriptIDs,
})
if err != nil {
return xerrors.Errorf("insert agent scripts: %w", err)
if err := insertAgentScriptsAndLogSources(ctx, db, agentID, scriptsParams); err != nil {
return xerrors.Errorf("insert agent scripts and log sources: %w", err)
}
for _, app := range prAgent.Apps {
// Similar logic is duplicated in terraform/resources.go.
slug := app.Slug
if slug == "" {
return xerrors.Errorf("app must have a slug or name set")
if err := insertAgentApp(ctx, db, dbAgent.ID, app, appSlugs, snapshot); err != nil {
return xerrors.Errorf("insert agent app: %w", err)
}
// Contrary to agent names above, app slugs were never permitted to
// contain uppercase letters or underscores.
if !provisioner.AppSlugRegex.MatchString(slug) {
return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String())
}
if _, exists := appSlugs[slug]; exists {
return xerrors.Errorf("duplicate app slug, must be unique per template: %q", slug)
}
appSlugs[slug] = struct{}{}
health := database.WorkspaceAppHealthDisabled
if app.Healthcheck == nil {
app.Healthcheck = &sdkproto.Healthcheck{}
}
if app.Healthcheck.Url != "" {
health = database.WorkspaceAppHealthInitializing
}
sharingLevel := database.AppSharingLevelOwner
switch app.SharingLevel {
case sdkproto.AppSharingLevel_AUTHENTICATED:
sharingLevel = database.AppSharingLevelAuthenticated
case sdkproto.AppSharingLevel_PUBLIC:
sharingLevel = database.AppSharingLevelPublic
}
displayGroup := sql.NullString{
Valid: app.Group != "",
String: app.Group,
}
openIn := database.WorkspaceAppOpenInSlimWindow
switch app.OpenIn {
case sdkproto.AppOpenIn_TAB:
openIn = database.WorkspaceAppOpenInTab
case sdkproto.AppOpenIn_SLIM_WINDOW:
openIn = database.WorkspaceAppOpenInSlimWindow
}
var appID string
if app.Id == "" || app.Id == uuid.Nil.String() {
appID = uuid.NewString()
} else {
appID = app.Id
}
id, err := uuid.Parse(appID)
if err != nil {
return xerrors.Errorf("parse app uuid: %w", err)
}
// If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert.
dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: id,
CreatedAt: dbtime.Now(),
AgentID: dbAgent.ID,
Slug: slug,
DisplayName: app.DisplayName,
Icon: app.Icon,
Command: sql.NullString{
String: app.Command,
Valid: app.Command != "",
},
Url: sql.NullString{
String: app.Url,
Valid: app.Url != "",
},
External: app.External,
Subdomain: app.Subdomain,
SharingLevel: sharingLevel,
HealthcheckUrl: app.Healthcheck.Url,
HealthcheckInterval: app.Healthcheck.Interval,
HealthcheckThreshold: app.Healthcheck.Threshold,
Health: health,
// #nosec G115 - Order represents a display order value that's always small and fits in int32
DisplayOrder: int32(app.Order),
DisplayGroup: displayGroup,
Hidden: app.Hidden,
OpenIn: openIn,
Tooltip: app.Tooltip,
})
if err != nil {
return xerrors.Errorf("upsert app: %w", err)
}
snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp))
}
}
@@ -3360,3 +3255,285 @@ func convertDisplayApps(apps *sdkproto.DisplayApps) []database.DisplayApp {
}
return dapps
}
// insertDevcontainerSubagent creates a workspace agent for a devcontainer's
// subagent if one is defined. It returns the subagent ID (zero UUID if no
// subagent is defined).
func insertDevcontainerSubagent(
ctx context.Context,
db database.Store,
dc *sdkproto.Devcontainer,
parentAgent database.WorkspaceAgent,
resourceID uuid.UUID,
appSlugs map[string]struct{},
snapshot *telemetry.Snapshot,
opts *insertWorkspaceResourceOptions,
) (uuid.UUID, error) {
// If there are no attached resources, we don't need to pre-create the
// subagent. This preserves backwards compatibility where devcontainers
// without resources can have their agents recreated dynamically.
if len(dc.GetApps()) == 0 && len(dc.GetScripts()) == 0 && len(dc.GetEnvs()) == 0 {
return uuid.UUID{}, nil
}
subAgentID := uuid.New()
if opts.useAgentIDsFromProto {
var err error
subAgentID, err = uuid.Parse(dc.GetSubagentId())
if err != nil {
return uuid.UUID{}, xerrors.Errorf("parse subagent id: %w", err)
}
}
envJSON, err := encodeSubagentEnvs(dc.GetEnvs())
if err != nil {
return uuid.UUID{}, err
}
_, err = db.InsertWorkspaceAgent(ctx, database.InsertWorkspaceAgentParams{
ID: subAgentID,
ParentID: uuid.NullUUID{Valid: true, UUID: parentAgent.ID},
CreatedAt: dbtime.Now(),
UpdatedAt: dbtime.Now(),
ResourceID: resourceID,
Name: dc.GetName(),
AuthToken: uuid.New(),
AuthInstanceID: parentAgent.AuthInstanceID,
Architecture: parentAgent.Architecture,
EnvironmentVariables: envJSON,
Directory: dc.GetWorkspaceFolder(),
InstanceMetadata: pqtype.NullRawMessage{},
ResourceMetadata: pqtype.NullRawMessage{},
OperatingSystem: parentAgent.OperatingSystem,
ConnectionTimeoutSeconds: parentAgent.ConnectionTimeoutSeconds,
TroubleshootingURL: parentAgent.TroubleshootingURL,
MOTDFile: "",
DisplayApps: []database.DisplayApp{},
DisplayOrder: 0,
APIKeyScope: parentAgent.APIKeyScope,
})
if err != nil {
return uuid.UUID{}, xerrors.Errorf("insert subagent: %w", err)
}
for _, app := range dc.GetApps() {
if err := insertAgentApp(ctx, db, subAgentID, app, appSlugs, snapshot); err != nil {
return uuid.UUID{}, xerrors.Errorf("insert agent app: %w", err)
}
}
if err := insertAgentScriptsAndLogSources(ctx, db, subAgentID, agentScriptsFromProto(dc.GetScripts())); err != nil {
return uuid.UUID{}, xerrors.Errorf("insert agent scripts and log sources: %w", err)
}
return subAgentID, nil
}
func encodeSubagentEnvs(envs []*sdkproto.Env) (pqtype.NullRawMessage, error) {
if len(envs) == 0 {
return pqtype.NullRawMessage{}, nil
}
subAgentEnvs := make(map[string]string, len(envs))
for _, env := range envs {
subAgentEnvs[env.GetName()] = env.GetValue()
}
data, err := json.Marshal(subAgentEnvs)
if err != nil {
return pqtype.NullRawMessage{}, xerrors.Errorf("marshal env: %w", err)
}
return pqtype.NullRawMessage{Valid: true, RawMessage: data}, nil
}
// agentScriptsParams holds the parameters for inserting agent scripts and
// their associated log sources.
type agentScriptsParams struct {
LogSourceIDs []uuid.UUID
LogSourceDisplayNames []string
LogSourceIcons []string
ScriptIDs []uuid.UUID
ScriptDisplayNames []string
ScriptLogPaths []string
ScriptSources []string
ScriptCron []string
ScriptTimeout []int32
ScriptStartBlocksLogin []bool
ScriptRunOnStart []bool
ScriptRunOnStop []bool
}
// agentScriptsFromProto converts a slice of proto scripts into the
// agentScriptsParams struct needed for database insertion.
func agentScriptsFromProto(scripts []*sdkproto.Script) agentScriptsParams {
params := agentScriptsParams{
LogSourceIDs: make([]uuid.UUID, 0, len(scripts)),
LogSourceDisplayNames: make([]string, 0, len(scripts)),
LogSourceIcons: make([]string, 0, len(scripts)),
ScriptIDs: make([]uuid.UUID, 0, len(scripts)),
ScriptDisplayNames: make([]string, 0, len(scripts)),
ScriptLogPaths: make([]string, 0, len(scripts)),
ScriptSources: make([]string, 0, len(scripts)),
ScriptCron: make([]string, 0, len(scripts)),
ScriptTimeout: make([]int32, 0, len(scripts)),
ScriptStartBlocksLogin: make([]bool, 0, len(scripts)),
ScriptRunOnStart: make([]bool, 0, len(scripts)),
ScriptRunOnStop: make([]bool, 0, len(scripts)),
}
for _, script := range scripts {
params.LogSourceIDs = append(params.LogSourceIDs, uuid.New())
params.LogSourceDisplayNames = append(params.LogSourceDisplayNames, script.GetDisplayName())
params.LogSourceIcons = append(params.LogSourceIcons, script.GetIcon())
params.ScriptIDs = append(params.ScriptIDs, uuid.New())
params.ScriptDisplayNames = append(params.ScriptDisplayNames, script.GetDisplayName())
params.ScriptLogPaths = append(params.ScriptLogPaths, script.GetLogPath())
params.ScriptSources = append(params.ScriptSources, script.GetScript())
params.ScriptCron = append(params.ScriptCron, script.GetCron())
params.ScriptTimeout = append(params.ScriptTimeout, script.GetTimeoutSeconds())
params.ScriptStartBlocksLogin = append(params.ScriptStartBlocksLogin, script.GetStartBlocksLogin())
params.ScriptRunOnStart = append(params.ScriptRunOnStart, script.GetRunOnStart())
params.ScriptRunOnStop = append(params.ScriptRunOnStop, script.GetRunOnStop())
}
return params
}
// insertAgentScriptsAndLogSources inserts log sources and scripts for an agent (or
// subagent). It expects the caller to have built the agentScriptsParams,
// allowing for additional entries to be appended before insertion (e.g. for
// devcontainers). Returns nil if there are no log sources to insert.
func insertAgentScriptsAndLogSources(ctx context.Context, db database.Store, agentID uuid.UUID, params agentScriptsParams) error {
if len(params.LogSourceIDs) == 0 {
return nil
}
_, err := db.InsertWorkspaceAgentLogSources(ctx, database.InsertWorkspaceAgentLogSourcesParams{
WorkspaceAgentID: agentID,
ID: params.LogSourceIDs,
CreatedAt: dbtime.Now(),
DisplayName: params.LogSourceDisplayNames,
Icon: params.LogSourceIcons,
})
if err != nil {
return xerrors.Errorf("insert log sources: %w", err)
}
_, err = db.InsertWorkspaceAgentScripts(ctx, database.InsertWorkspaceAgentScriptsParams{
WorkspaceAgentID: agentID,
LogSourceID: params.LogSourceIDs,
ID: params.ScriptIDs,
LogPath: params.ScriptLogPaths,
CreatedAt: dbtime.Now(),
Script: params.ScriptSources,
Cron: params.ScriptCron,
TimeoutSeconds: params.ScriptTimeout,
StartBlocksLogin: params.ScriptStartBlocksLogin,
RunOnStart: params.ScriptRunOnStart,
RunOnStop: params.ScriptRunOnStop,
DisplayName: params.ScriptDisplayNames,
})
if err != nil {
return xerrors.Errorf("insert scripts: %w", err)
}
return nil
}
func insertAgentApp(ctx context.Context, db database.Store, agentID uuid.UUID, app *sdkproto.App, appSlugs map[string]struct{}, snapshot *telemetry.Snapshot) error {
// Similar logic is duplicated in terraform/resources.go.
slug := app.Slug
if slug == "" {
return xerrors.Errorf("app must have a slug or name set")
}
// Unlike agent names, app slugs were never permitted to contain uppercase
// letters or underscores.
if !provisioner.AppSlugRegex.MatchString(slug) {
return xerrors.Errorf("app slug %q does not match regex %q", slug, provisioner.AppSlugRegex.String())
}
if _, exists := appSlugs[slug]; exists {
return xerrors.Errorf("duplicate app slug, must be unique per template: %q", slug)
}
appSlugs[slug] = struct{}{}
health := database.WorkspaceAppHealthDisabled
if app.Healthcheck == nil {
app.Healthcheck = &sdkproto.Healthcheck{}
}
if app.Healthcheck.Url != "" {
health = database.WorkspaceAppHealthInitializing
}
sharingLevel := database.AppSharingLevelOwner
switch app.SharingLevel {
case sdkproto.AppSharingLevel_AUTHENTICATED:
sharingLevel = database.AppSharingLevelAuthenticated
case sdkproto.AppSharingLevel_PUBLIC:
sharingLevel = database.AppSharingLevelPublic
}
displayGroup := sql.NullString{
Valid: app.Group != "",
String: app.Group,
}
openIn := database.WorkspaceAppOpenInSlimWindow
switch app.OpenIn {
case sdkproto.AppOpenIn_TAB:
openIn = database.WorkspaceAppOpenInTab
case sdkproto.AppOpenIn_SLIM_WINDOW:
openIn = database.WorkspaceAppOpenInSlimWindow
}
var appID string
if app.Id == "" || app.Id == uuid.Nil.String() {
appID = uuid.NewString()
} else {
appID = app.Id
}
id, err := uuid.Parse(appID)
if err != nil {
return xerrors.Errorf("parse app uuid: %w", err)
}
// If workspace apps are "persistent", the ID will not be regenerated across workspace builds, so we have to upsert.
dbApp, err := db.UpsertWorkspaceApp(ctx, database.UpsertWorkspaceAppParams{
ID: id,
CreatedAt: dbtime.Now(),
AgentID: agentID,
Slug: slug,
DisplayName: app.DisplayName,
Icon: app.Icon,
Command: sql.NullString{
String: app.Command,
Valid: app.Command != "",
},
Url: sql.NullString{
String: app.Url,
Valid: app.Url != "",
},
External: app.External,
Subdomain: app.Subdomain,
SharingLevel: sharingLevel,
HealthcheckUrl: app.Healthcheck.Url,
HealthcheckInterval: app.Healthcheck.Interval,
HealthcheckThreshold: app.Healthcheck.Threshold,
Health: health,
// #nosec G115 - Order represents a display order value that's always small and fits in int32
DisplayOrder: int32(app.Order),
DisplayGroup: displayGroup,
Hidden: app.Hidden,
OpenIn: openIn,
Tooltip: app.Tooltip,
})
if err != nil {
return xerrors.Errorf("upsert app: %w", err)
}
snapshot.WorkspaceApps = append(snapshot.WorkspaceApps, telemetry.ConvertWorkspaceApp(dbApp))
return nil
}
@@ -2981,6 +2981,46 @@ func TestCompleteJob(t *testing.T) {
expectHasAiTask: true,
expectUsageEvent: true,
},
{
name: "ai task linked to subagent app in devcontainer",
transition: database.WorkspaceTransitionStart,
input: &proto.CompletedJob_WorkspaceBuild{
AiTasks: []*sdkproto.AITask{
{
Id: uuid.NewString(),
AppId: sidebarAppID.String(),
},
},
Resources: []*sdkproto.Resource{
{
Agents: []*sdkproto.Agent{
{
Id: uuid.NewString(),
Name: "parent-agent",
Devcontainers: []*sdkproto.Devcontainer{
{
Name: "dev",
WorkspaceFolder: "/workspace",
SubagentId: uuid.NewString(),
Apps: []*sdkproto.App{
{
Id: sidebarAppID.String(),
Slug: "subagent-app",
},
},
},
},
},
},
},
},
},
isTask: true,
expectTaskStatus: database.TaskStatusInitializing,
expectAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: true},
expectHasAiTask: true,
expectUsageEvent: true,
},
// Checks regression for https://github.com/coder/coder/issues/18776
{
name: "non-existing app",
@@ -3386,6 +3426,9 @@ func TestInsertWorkspaceResource(t *testing.T) {
insert := func(db database.Store, jobID uuid.UUID, resource *sdkproto.Resource) error {
return provisionerdserver.InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, &telemetry.Snapshot{})
}
insertWithProtoIDs := func(db database.Store, jobID uuid.UUID, resource *sdkproto.Resource) error {
return provisionerdserver.InsertWorkspaceResource(ctx, db, jobID, database.WorkspaceTransitionStart, resource, &telemetry.Snapshot{}, provisionerdserver.InsertWorkspaceResourceWithAgentIDsFromProto())
}
t.Run("NoAgents", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
@@ -3722,39 +3765,400 @@ func TestInsertWorkspaceResource(t *testing.T) {
t.Run("Devcontainers", func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{})
err := insert(db, job.ID, &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{
{Name: "foo", WorkspaceFolder: "/workspace1"},
{Name: "bar", WorkspaceFolder: "/workspace2", ConfigPath: "/workspace2/.devcontainer/devcontainer.json"},
agentID := uuid.New()
subAgentID := uuid.New()
devcontainerID := uuid.New()
devcontainerID2 := uuid.New()
tests := []struct {
name string
resource *sdkproto.Resource
wantErr string
protoIDsOnly bool // when true, only run with insertWithProtoIDs (e.g., for UUID parsing error tests)
expectSubAgentCount int
check func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, useProtoIDs bool)
}{
{
name: "OK",
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{
{Id: devcontainerID.String(), Name: "foo", WorkspaceFolder: "/workspace1"},
{Id: devcontainerID2.String(), Name: "bar", WorkspaceFolder: "/workspace2", ConfigPath: "/workspace2/.devcontainer/devcontainer.json"},
},
}},
},
}},
})
require.NoError(t, err)
resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID)
require.NoError(t, err)
require.Len(t, resources, 1)
agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID})
require.NoError(t, err)
require.Len(t, agents, 1)
agent := agents[0]
devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, agent.ID)
sort.Slice(devcontainers, func(i, j int) bool {
return devcontainers[i].Name > devcontainers[j].Name
})
require.NoError(t, err)
require.Len(t, devcontainers, 2)
require.Equal(t, "foo", devcontainers[0].Name)
require.Equal(t, "/workspace1", devcontainers[0].WorkspaceFolder)
require.Equal(t, "", devcontainers[0].ConfigPath)
require.Equal(t, "bar", devcontainers[1].Name)
require.Equal(t, "/workspace2", devcontainers[1].WorkspaceFolder)
require.Equal(t, "/workspace2/.devcontainer/devcontainer.json", devcontainers[1].ConfigPath)
expectSubAgentCount: 0,
check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, _ []database.WorkspaceAgent, useProtoIDs bool) {
require.Equal(t, "dev", parentAgent.Name)
devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID)
require.NoError(t, err)
sort.Slice(devcontainers, func(i, j int) bool {
return devcontainers[i].Name > devcontainers[j].Name
})
require.Len(t, devcontainers, 2)
if useProtoIDs {
assert.Equal(t, devcontainerID, devcontainers[0].ID)
assert.Equal(t, devcontainerID2, devcontainers[1].ID)
} else {
assert.NotEqual(t, uuid.Nil, devcontainers[0].ID)
assert.NotEqual(t, uuid.Nil, devcontainers[1].ID)
}
assert.Equal(t, "foo", devcontainers[0].Name)
assert.Equal(t, "/workspace1", devcontainers[0].WorkspaceFolder)
assert.Equal(t, "", devcontainers[0].ConfigPath)
assert.False(t, devcontainers[0].SubagentID.Valid)
assert.Equal(t, "bar", devcontainers[1].Name)
assert.Equal(t, "/workspace2", devcontainers[1].WorkspaceFolder)
assert.Equal(t, "/workspace2/.devcontainer/devcontainer.json", devcontainers[1].ConfigPath)
assert.False(t, devcontainers[1].SubagentID.Valid)
},
},
{
name: "SubAgentWithAllResources",
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Architecture: "amd64",
OperatingSystem: "linux",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "full-subagent",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "code-server", DisplayName: "VS Code", Url: "http://localhost:8080"},
},
Scripts: []*sdkproto.Script{
{DisplayName: "Startup", Script: "echo start", RunOnStart: true},
},
Envs: []*sdkproto.Env{
{Name: "EDITOR", Value: "vim"},
},
}},
}},
},
expectSubAgentCount: 1,
check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, useProtoIDs bool) {
require.Len(t, subAgents, 1)
subAgent := subAgents[0]
if useProtoIDs {
require.Equal(t, subAgentID, subAgent.ID)
} else {
require.NotEqual(t, uuid.Nil, subAgent.ID)
}
assert.Equal(t, parentAgent.ID, subAgent.ParentID.UUID)
assert.Equal(t, parentAgent.Architecture, subAgent.Architecture)
assert.Equal(t, parentAgent.OperatingSystem, subAgent.OperatingSystem)
apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID)
require.NoError(t, err)
require.Len(t, apps, 1)
assert.Equal(t, "code-server", apps[0].Slug)
scripts, err := db.GetWorkspaceAgentScriptsByAgentIDs(ctx, []uuid.UUID{subAgent.ID})
require.NoError(t, err)
require.Len(t, scripts, 1)
assert.Equal(t, "Startup", scripts[0].DisplayName)
var envVars map[string]string
err = json.Unmarshal(subAgent.EnvironmentVariables.RawMessage, &envVars)
require.NoError(t, err)
assert.Equal(t, "vim", envVars["EDITOR"])
devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID)
require.NoError(t, err)
require.Len(t, devcontainers, 1)
assert.True(t, devcontainers[0].SubagentID.Valid)
if useProtoIDs {
assert.Equal(t, subAgentID, devcontainers[0].SubagentID.UUID)
} else {
assert.Equal(t, subAgent.ID, devcontainers[0].SubagentID.UUID)
}
},
},
{
name: "MultipleDevcontainersWithSubagents",
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{
{
Id: devcontainerID.String(),
Name: "frontend",
WorkspaceFolder: "/workspace/frontend",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "frontend-app", DisplayName: "Frontend"},
},
},
{
Id: devcontainerID2.String(),
Name: "backend",
WorkspaceFolder: "/workspace/backend",
SubagentId: uuid.New().String(),
Apps: []*sdkproto.App{
{Slug: "backend-app", DisplayName: "Backend"},
},
},
},
}},
},
expectSubAgentCount: 2,
check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, subAgents []database.WorkspaceAgent, _ bool) {
for _, subAgent := range subAgents {
apps, err := db.GetWorkspaceAppsByAgentID(ctx, subAgent.ID)
require.NoError(t, err)
require.Len(t, apps, 1, "each subagent should have exactly one app")
}
devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID)
require.NoError(t, err)
require.Len(t, devcontainers, 2)
for _, dc := range devcontainers {
assert.True(t, dc.SubagentID.Valid, "devcontainer %s should have subagent", dc.Name)
}
},
},
{
name: "SubAgentDuplicateAppSlugs",
wantErr: `duplicate app slug, must be unique per template: "my-app"`,
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "with-dup-apps",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "my-app", DisplayName: "App 1"},
{Slug: "my-app", DisplayName: "App 2"},
},
}},
}},
},
},
{
name: "SubAgentInvalidAppSlug",
wantErr: `app slug "Invalid_Slug" does not match regex`,
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "with-invalid-app",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "Invalid_Slug", DisplayName: "Bad App"},
},
}},
}},
},
},
{
name: "SubAgentAppSlugConflictsWithParentAgent",
wantErr: `duplicate app slug, must be unique per template: "shared-app"`,
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Apps: []*sdkproto.App{
{Slug: "shared-app", DisplayName: "Parent App"},
},
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "dc",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "shared-app", DisplayName: "Child App"},
},
}},
}},
},
},
{
name: "SubAgentAppSlugConflictsBetweenSubagents",
wantErr: `duplicate app slug, must be unique per template: "conflicting-app"`,
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{
{
Id: devcontainerID.String(),
Name: "dc1",
WorkspaceFolder: "/workspace1",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{
{Slug: "conflicting-app", DisplayName: "App in DC1"},
},
},
{
Id: devcontainerID2.String(),
Name: "dc2",
WorkspaceFolder: "/workspace2",
SubagentId: uuid.New().String(),
Apps: []*sdkproto.App{
{Slug: "conflicting-app", DisplayName: "App in DC2"},
},
},
},
}},
},
},
{
name: "SubAgentInvalidSubagentID",
wantErr: "parse subagent id",
protoIDsOnly: true, // UUID parsing errors only occur with proto IDs
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "invalid-subagent",
WorkspaceFolder: "/workspace",
SubagentId: "not-a-valid-uuid",
Apps: []*sdkproto.App{{Slug: "app", DisplayName: "App"}},
}},
}},
},
},
{
name: "SubAgentInvalidAppID",
wantErr: "parse app uuid",
protoIDsOnly: true, // UUID parsing errors only occur with proto IDs
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "with-invalid-app-id",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
Apps: []*sdkproto.App{{Id: "not-a-uuid", Slug: "my-app", DisplayName: "App"}},
}},
}},
},
},
{
// This test verifies the backward-compatibility behavior where a
// devcontainer with a SubagentId but no apps, scripts, or envs does
// NOT create a subagent.
name: "SubAgentBackwardCompatNoResources",
resource: &sdkproto.Resource{
Name: "something",
Type: "aws_instance",
Agents: []*sdkproto.Agent{{
Id: agentID.String(),
Name: "dev",
Devcontainers: []*sdkproto.Devcontainer{{
Id: devcontainerID.String(),
Name: "no-resources",
WorkspaceFolder: "/workspace",
SubagentId: subAgentID.String(),
// Intentionally no Apps, Scripts, or Envs.
}},
}},
},
expectSubAgentCount: 0,
check: func(t *testing.T, db database.Store, parentAgent database.WorkspaceAgent, _ []database.WorkspaceAgent, _ bool) {
devcontainers, err := db.GetWorkspaceAgentDevcontainersByAgentID(ctx, parentAgent.ID)
require.NoError(t, err)
require.Len(t, devcontainers, 1)
assert.Equal(t, "no-resources", devcontainers[0].Name)
assert.False(t, devcontainers[0].SubagentID.Valid,
"devcontainer with SubagentId but no apps/scripts/envs should not have a subagent (backward compatibility)")
},
},
}
for _, tt := range tests {
for _, useProtoIDs := range []bool{false, true} {
if tt.protoIDsOnly && !useProtoIDs {
continue
}
name := tt.name
if useProtoIDs {
name += "/WithProtoIDs"
} else {
name += "/WithoutProtoIDs"
}
t.Run(name, func(t *testing.T) {
t.Parallel()
db, _ := dbtestutil.NewDB(t)
job := dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{})
var err error
if useProtoIDs {
err = insertWithProtoIDs(db, job.ID, tt.resource)
} else {
err = insert(db, job.ID, tt.resource)
}
if tt.wantErr != "" {
require.ErrorContains(t, err, tt.wantErr)
return
}
require.NoError(t, err)
resources, err := db.GetWorkspaceResourcesByJobID(ctx, job.ID)
require.NoError(t, err)
require.Len(t, resources, 1)
agents, err := db.GetWorkspaceAgentsByResourceIDs(ctx, []uuid.UUID{resources[0].ID})
require.NoError(t, err)
var parentAgent database.WorkspaceAgent
var subAgents []database.WorkspaceAgent
for _, agent := range agents {
if agent.ParentID.Valid {
subAgents = append(subAgents, agent)
} else {
parentAgent = agent
}
}
require.NotEqual(t, uuid.Nil, parentAgent.ID)
require.Len(t, subAgents, tt.expectSubAgentCount, "expected %d subagents", tt.expectSubAgentCount)
tt.check(t, db, parentAgent, subAgents, useProtoIDs)
})
}
}
})
}