diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index e0d804328b2d3..4bbbaba667c7e 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -316,7 +316,7 @@ func getNextTransition( error, ) { switch { - case isEligibleForAutostop(ws, latestBuild, latestJob, currentTick): + case isEligibleForAutostop(user, ws, latestBuild, latestJob, currentTick): return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil case isEligibleForAutostart(user, ws, latestBuild, latestJob, templateSchedule, currentTick): return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil @@ -376,8 +376,8 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat return !currentTick.Before(nextTransition) } -// isEligibleForAutostart returns true if the workspace should be autostopped. -func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, job database.ProvisionerJob, currentTick time.Time) bool { +// isEligibleForAutostop returns true if the workspace should be autostopped. +func isEligibleForAutostop(user database.User, ws database.Workspace, build database.WorkspaceBuild, job database.ProvisionerJob, currentTick time.Time) bool { if job.JobStatus == database.ProvisionerJobStatusFailed { return false } @@ -387,6 +387,10 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, return false } + if build.Transition == database.WorkspaceTransitionStart && user.Status == database.UserStatusSuspended { + return true + } + // A workspace must be started in order for it to be auto-stopped. return build.Transition == database.WorkspaceTransitionStart && !build.Deadline.IsZero() && diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 54ceb53254680..bc480b97e4aa2 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -563,6 +563,52 @@ func TestExecutorWorkspaceAutostopBeforeDeadline(t *testing.T) { assert.Len(t, stats.Transitions, 0) } +func TestExecuteAutostopSuspendedUser(t *testing.T) { + t.Parallel() + + var ( + ctx = testutil.Context(t, testutil.WaitShort) + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + }) + ) + + admin := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID) + userClient, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID) + workspace := coderdtest.CreateWorkspace(t, userClient, admin.OrganizationID, template.ID) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID) + + // Given: workspace is running, and the user is suspended. + workspace = coderdtest.MustWorkspace(t, userClient, workspace.ID) + require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status) + _, err := client.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended) + require.NoError(t, err, "update user status") + + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- time.Unix(0, 0) // the exact time is not important + close(tickCh) + }() + + // Then: the workspace should be stopped + stats := <-statsCh + assert.Len(t, stats.Errors, 0) + assert.Len(t, stats.Transitions, 1) + assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop) + + // Wait for stop to complete + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + assert.Equal(t, codersdk.WorkspaceStatusStopped, workspaceBuild.Status) +} + func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) { t.Parallel() diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index d19d218556b8d..ec7becdfd39c9 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -5844,6 +5844,15 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no workspaces = append(workspaces, workspace) continue } + + user, err := q.getUserByIDNoLock(workspace.OwnerID) + if err != nil { + return nil, xerrors.Errorf("get user by ID: %w", err) + } + if user.Status == database.UserStatusSuspended && build.Transition == database.WorkspaceTransitionStart { + workspaces = append(workspaces, workspace) + continue + } } return workspaces, nil diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index ff7b7f6f955bd..cd48412c2ff40 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -13426,6 +13426,8 @@ INNER JOIN provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id INNER JOIN templates ON workspaces.template_id = templates.id +INNER JOIN + users ON workspaces.owner_id = users.id WHERE workspace_builds.build_number = ( SELECT @@ -13477,6 +13479,12 @@ WHERE ( templates.time_til_dormant_autodelete > 0 AND workspaces.dormant_at IS NOT NULL + ) OR + + -- If the user account is suspended, and the workspace is running. + ( + users.status = 'suspended'::user_status AND + workspace_builds.transition = 'start'::workspace_transition ) ) AND workspaces.deleted = 'false' ` diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 616e83a2bae16..ec8767e1f2be5 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -557,6 +557,8 @@ INNER JOIN provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id INNER JOIN templates ON workspaces.template_id = templates.id +INNER JOIN + users ON workspaces.owner_id = users.id WHERE workspace_builds.build_number = ( SELECT @@ -608,6 +610,12 @@ WHERE ( templates.time_til_dormant_autodelete > 0 AND workspaces.dormant_at IS NOT NULL + ) OR + + -- If the user account is suspended, and the workspace is running. + ( + users.status = 'suspended'::user_status AND + workspace_builds.transition = 'start'::workspace_transition ) ) AND workspaces.deleted = 'false';