diff --git a/coderd/database/dbfake/dbfake.go b/coderd/database/dbfake/dbfake.go index 82b66f504aa..d2d0834df66 100644 --- a/coderd/database/dbfake/dbfake.go +++ b/coderd/database/dbfake/dbfake.go @@ -512,8 +512,10 @@ func (b WorkspaceBuildBuilder) doInTX() WorkspaceResponse { workspaceAgentID := uuid.NullUUID{} workspaceAppID := uuid.NullUUID{} - // Workspace agent and app are only properly set upon job completion - if b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning { + // Workspace agent and app are only properly set upon job completion, and + // only start builds have agents. + isStart := b.seed.Transition == "" || b.seed.Transition == database.WorkspaceTransitionStart + if isStart && b.jobStatus != database.ProvisionerJobStatusPending && b.jobStatus != database.ProvisionerJobStatusRunning { app := mustWorkspaceAppByWorkspaceAndBuildAndAppID(ownerCtx, b.t, b.db, resp.Workspace.ID, resp.Build.BuildNumber, b.taskAppID) workspaceAgentID = uuid.NullUUID{UUID: app.AgentID, Valid: true} workspaceAppID = uuid.NullUUID{UUID: app.ID, Valid: true} diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index 4bfe0b9acd1..261885d0772 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -2345,7 +2345,8 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro taskAppID uuid.NullUUID taskAgentID uuid.NullUUID ) - if tasks := jobType.WorkspaceBuild.GetAiTasks(); len(tasks) > 0 { + // Agents and their apps are only inserted when the workspace is running. + if tasks := jobType.WorkspaceBuild.GetAiTasks(); len(tasks) > 0 && workspaceBuild.Transition == database.WorkspaceTransitionStart { task := tasks[0] if task == nil { return xerrors.Errorf("update ai task: task is nil") @@ -3035,7 +3036,15 @@ func InsertWorkspaceResource(ctx context.Context, db database.Store, jobID uuid. agentNames = make(map[string]struct{}) appSlugs = make(map[string]struct{}) ) - for _, prAgent := range protoResource.Agents { + + // Agents can't connect to compute that these transitions tore down, so any + // agent Terraform still reports for them would only surface as unhealthy. + protoAgents := protoResource.Agents + if transition == database.WorkspaceTransitionStop || transition == database.WorkspaceTransitionDelete { + protoAgents = nil + } + + for _, prAgent := range protoAgents { // Similar logic is duplicated in terraform/resources.go. if prAgent.Name == "" { return xerrors.Errorf("agent name cannot be empty") diff --git a/coderd/provisionerdserver/provisionerdserver_test.go b/coderd/provisionerdserver/provisionerdserver_test.go index fb28ad2ecd1..dc8e3956a8e 100644 --- a/coderd/provisionerdserver/provisionerdserver_test.go +++ b/coderd/provisionerdserver/provisionerdserver_test.go @@ -3608,7 +3608,8 @@ func TestCompleteJob(t *testing.T) { }, isTask: true, expectTaskStatus: database.TaskStatusPaused, - expectAppID: uuid.NullUUID{UUID: sidebarAppID, Valid: true}, + // Stop builds don't create agents or apps. + expectAppID: uuid.NullUUID{}, expectHasAiTask: true, expectUsageEvent: false, }, diff --git a/coderd/templateversions_test.go b/coderd/templateversions_test.go index ec631d36d1a..c61d171815b 100644 --- a/coderd/templateversions_test.go +++ b/coderd/templateversions_test.go @@ -29,6 +29,7 @@ import ( "github.com/coder/coder/v2/coderd/provisionerdserver" "github.com/coder/coder/v2/coderd/rbac" "github.com/coder/coder/v2/coderd/rbac/policy" + "github.com/coder/coder/v2/coderd/util/slice" "github.com/coder/coder/v2/codersdk" "github.com/coder/coder/v2/examples" "github.com/coder/coder/v2/provisioner/echo" @@ -1197,10 +1198,23 @@ func TestTemplateVersionResources(t *testing.T) { resources, err := client.TemplateVersionResources(ctx, version.ID) require.NoError(t, err) require.NotNil(t, resources) + // An import plans both transitions, so every resource is recorded twice. + // Resources are only sorted by name, and the two "some" rows tie, so + // they're matched on transition rather than by index. require.Len(t, resources, 4) - require.Equal(t, "some", resources[2].Name) - require.Equal(t, "example", resources[2].Type) - require.Len(t, resources[2].Agents, 1) + start, ok := slice.Find(resources, func(r codersdk.WorkspaceResource) bool { + return r.Name == "some" && r.Transition == codersdk.WorkspaceTransitionStart + }) + require.True(t, ok) + require.Equal(t, "example", start.Type) + require.Len(t, start.Agents, 1) + + stop, ok := slice.Find(resources, func(r codersdk.WorkspaceResource) bool { + return r.Name == "some" && r.Transition == codersdk.WorkspaceTransitionStop + }) + require.True(t, ok) + // A stopped workspace has no agents, so the stop plan doesn't report any. + require.Empty(t, stop.Agents) }) } diff --git a/coderd/workspacebuilds_test.go b/coderd/workspacebuilds_test.go index ca18cdc4002..62f28896aa9 100644 --- a/coderd/workspacebuilds_test.go +++ b/coderd/workspacebuilds_test.go @@ -902,6 +902,41 @@ func TestWorkspaceBuildResources(t *testing.T) { assertWorkspaceResource(t, workspace.LatestBuild.Resources[3], "fourth_resource", "example", 0) // resource has no agents, sorted by name assertWorkspaceResource(t, workspace.LatestBuild.Resources[4], "third_resource", "example", 0) // resource is the last one }) + t.Run("StopBuildHasNoAgents", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + user := coderdtest.CreateFirstUser(t, client) + // The same responses are replayed for every transition, mimicking a + // template where the agent is bound to a resource that persists across + // stop. + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionGraph: echo.ProvisionGraphWithAgent(uuid.NewString()), + }) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + workspace := coderdtest.CreateWorkspace(t, client, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + workspace, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + require.Len(t, workspace.LatestBuild.Resources, 1) + require.Len(t, workspace.LatestBuild.Resources[0].Agents, 1) + + build := coderdtest.CreateWorkspaceBuild(t, client, workspace, database.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, build.ID) + + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err) + // The resource is still reported, but a stopped workspace has no agents + // and so cannot be unhealthy because of one. + require.Len(t, workspace.LatestBuild.Resources, 1) + require.Empty(t, workspace.LatestBuild.Resources[0].Agents) + require.True(t, workspace.Health.Healthy) + require.Empty(t, workspace.Health.FailingAgents) + }) } func TestWorkspaceBuildWithUpdatedTemplateVersionSendsNotification(t *testing.T) {