Skip to content

Commit 7c41f95

Browse files
authored
feat: autostop workspaces owned by suspended users (#13790)
1 parent c2d44d1 commit 7c41f95

File tree

5 files changed

+78
-3
lines changed

5 files changed

+78
-3
lines changed

coderd/autobuild/lifecycle_executor.go

+7-3
Original file line numberDiff line numberDiff line change
@@ -316,7 +316,7 @@ func getNextTransition(
316316
error,
317317
) {
318318
switch {
319-
case isEligibleForAutostop(ws, latestBuild, latestJob, currentTick):
319+
case isEligibleForAutostop(user, ws, latestBuild, latestJob, currentTick):
320320
return database.WorkspaceTransitionStop, database.BuildReasonAutostop, nil
321321
case isEligibleForAutostart(user, ws, latestBuild, latestJob, templateSchedule, currentTick):
322322
return database.WorkspaceTransitionStart, database.BuildReasonAutostart, nil
@@ -376,8 +376,8 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat
376376
return !currentTick.Before(nextTransition)
377377
}
378378

379-
// isEligibleForAutostart returns true if the workspace should be autostopped.
380-
func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild, job database.ProvisionerJob, currentTick time.Time) bool {
379+
// isEligibleForAutostop returns true if the workspace should be autostopped.
380+
func isEligibleForAutostop(user database.User, ws database.Workspace, build database.WorkspaceBuild, job database.ProvisionerJob, currentTick time.Time) bool {
381381
if job.JobStatus == database.ProvisionerJobStatusFailed {
382382
return false
383383
}
@@ -387,6 +387,10 @@ func isEligibleForAutostop(ws database.Workspace, build database.WorkspaceBuild,
387387
return false
388388
}
389389

390+
if build.Transition == database.WorkspaceTransitionStart && user.Status == database.UserStatusSuspended {
391+
return true
392+
}
393+
390394
// A workspace must be started in order for it to be auto-stopped.
391395
return build.Transition == database.WorkspaceTransitionStart &&
392396
!build.Deadline.IsZero() &&

coderd/autobuild/lifecycle_executor_test.go

+46
Original file line numberDiff line numberDiff line change
@@ -563,6 +563,52 @@ func TestExecutorWorkspaceAutostopBeforeDeadline(t *testing.T) {
563563
assert.Len(t, stats.Transitions, 0)
564564
}
565565

566+
func TestExecuteAutostopSuspendedUser(t *testing.T) {
567+
t.Parallel()
568+
569+
var (
570+
ctx = testutil.Context(t, testutil.WaitShort)
571+
tickCh = make(chan time.Time)
572+
statsCh = make(chan autobuild.Stats)
573+
client = coderdtest.New(t, &coderdtest.Options{
574+
AutobuildTicker: tickCh,
575+
IncludeProvisionerDaemon: true,
576+
AutobuildStats: statsCh,
577+
})
578+
)
579+
580+
admin := coderdtest.CreateFirstUser(t, client)
581+
version := coderdtest.CreateTemplateVersion(t, client, admin.OrganizationID, nil)
582+
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
583+
template := coderdtest.CreateTemplate(t, client, admin.OrganizationID, version.ID)
584+
userClient, user := coderdtest.CreateAnotherUser(t, client, admin.OrganizationID)
585+
workspace := coderdtest.CreateWorkspace(t, userClient, admin.OrganizationID, template.ID)
586+
coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, workspace.LatestBuild.ID)
587+
588+
// Given: workspace is running, and the user is suspended.
589+
workspace = coderdtest.MustWorkspace(t, userClient, workspace.ID)
590+
require.Equal(t, codersdk.WorkspaceStatusRunning, workspace.LatestBuild.Status)
591+
_, err := client.UpdateUserStatus(ctx, user.ID.String(), codersdk.UserStatusSuspended)
592+
require.NoError(t, err, "update user status")
593+
594+
// When: the autobuild executor ticks after the scheduled time
595+
go func() {
596+
tickCh <- time.Unix(0, 0) // the exact time is not important
597+
close(tickCh)
598+
}()
599+
600+
// Then: the workspace should be stopped
601+
stats := <-statsCh
602+
assert.Len(t, stats.Errors, 0)
603+
assert.Len(t, stats.Transitions, 1)
604+
assert.Equal(t, stats.Transitions[workspace.ID], database.WorkspaceTransitionStop)
605+
606+
// Wait for stop to complete
607+
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
608+
workspaceBuild := coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
609+
assert.Equal(t, codersdk.WorkspaceStatusStopped, workspaceBuild.Status)
610+
}
611+
566612
func TestExecutorWorkspaceAutostopNoWaitChangedMyMind(t *testing.T) {
567613
t.Parallel()
568614

coderd/database/dbmem/dbmem.go

+9
Original file line numberDiff line numberDiff line change
@@ -5844,6 +5844,15 @@ func (q *FakeQuerier) GetWorkspacesEligibleForTransition(ctx context.Context, no
58445844
workspaces = append(workspaces, workspace)
58455845
continue
58465846
}
5847+
5848+
user, err := q.getUserByIDNoLock(workspace.OwnerID)
5849+
if err != nil {
5850+
return nil, xerrors.Errorf("get user by ID: %w", err)
5851+
}
5852+
if user.Status == database.UserStatusSuspended && build.Transition == database.WorkspaceTransitionStart {
5853+
workspaces = append(workspaces, workspace)
5854+
continue
5855+
}
58475856
}
58485857

58495858
return workspaces, nil

coderd/database/queries.sql.go

+8
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

+8
Original file line numberDiff line numberDiff line change
@@ -557,6 +557,8 @@ INNER JOIN
557557
provisioner_jobs ON workspace_builds.job_id = provisioner_jobs.id
558558
INNER JOIN
559559
templates ON workspaces.template_id = templates.id
560+
INNER JOIN
561+
users ON workspaces.owner_id = users.id
560562
WHERE
561563
workspace_builds.build_number = (
562564
SELECT
@@ -608,6 +610,12 @@ WHERE
608610
(
609611
templates.time_til_dormant_autodelete > 0 AND
610612
workspaces.dormant_at IS NOT NULL
613+
) OR
614+
615+
-- If the user account is suspended, and the workspace is running.
616+
(
617+
users.status = 'suspended'::user_status AND
618+
workspace_builds.transition = 'start'::workspace_transition
611619
)
612620
) AND workspaces.deleted = 'false';
613621

0 commit comments

Comments
 (0)