diff --git a/coderd/workspacebuilds.go b/coderd/workspacebuilds.go index 0c58b902e2..c5f0b2f7f1 100644 --- a/coderd/workspacebuilds.go +++ b/coderd/workspacebuilds.go @@ -399,6 +399,40 @@ func (api *API) postWorkspaceBuildsInternal( err := api.Database.InTx(func(tx database.Store) error { var err error + // #20925: if the workspace is dormant and we are starting the workspace, + // we need to unset that status before inserting a new build. + // This is done inside the transaction for consistency, but it could also be + // done outside the transaction so that an attempt to start a workspace will + // also unset dormancy. + if workspace.DormantAt.Valid && transition == database.WorkspaceTransitionStart { + if _, err := tx.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: workspace.ID, + DormantAt: sql.NullTime{Valid: false}, + }); err != nil { + return httperror.NewResponseError(http.StatusInternalServerError, codersdk.Response{ + Message: "Internal error unsetting workspace dormant status", + Detail: err.Error(), + }) + } + // We need to audit this change separately. + updatedWorkspace := workspace.WorkspaceTable() + updatedWorkspace.DormantAt = sql.NullTime{Valid: false} + auditor := api.Auditor.Load() + bag := audit.BaggageFromContext(ctx) + audit.BackgroundAudit(ctx, &audit.BackgroundAuditParams[database.WorkspaceTable]{ + Audit: *auditor, + Old: workspace.WorkspaceTable(), + New: updatedWorkspace, + Log: api.Logger, + UserID: apiKey.UserID, + OrganizationID: workspace.OrganizationID, + RequestID: workspace.ID, + IP: bag.IP, + Action: database.AuditActionWrite, + Status: http.StatusOK, + }) + } + previousWorkspaceBuild, err = tx.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID) if err != nil && !xerrors.Is(err, sql.ErrNoRows) { api.Logger.Error(ctx, "failed fetching previous workspace build", slog.F("workspace_id", workspace.ID), slog.Error(err)) diff --git a/coderd/workspaces_test.go b/coderd/workspaces_test.go index d60b742cb2..d27441993a 100644 --- a/coderd/workspaces_test.go +++ b/coderd/workspaces_test.go @@ -4244,10 +4244,16 @@ func TestWorkspaceDormant(t *testing.T) { require.True(t, workspace.LastUsedAt.After(lastUsedAt)) }) - t.Run("CannotStart", func(t *testing.T) { + // #20925: this test originally validated that you could **not** start a dormant workspace. + // The client was required to explicitly update the dormancy status before starting. + // This led to a 'whack-a-mole' situation where various code paths that create a workspace build + // would need to special case dormant workspaces. + // Now, a dormant workspace will automatically 'wake up' on start. + t.Run("StartWakesUpDormantWorkspace", func(t *testing.T) { t.Parallel() var ( - client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true}) + auditor = audit.NewMock() + client = coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: true, Auditor: auditor}) user = coderdtest.CreateFirstUser(t, client) version = coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) _ = coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) @@ -4267,18 +4273,37 @@ func TestWorkspaceDormant(t *testing.T) { // Should be able to stop a workspace while it is dormant. coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) - // Should not be able to start a workspace while it is dormant. - _, err = client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ + // Reset the auditor + auditor.ResetLogs() + // Assert test invariant: workspace is dormant. + workspace, err = client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch dormant workspace") + if assert.NotNil(t, workspace.DormantAt, "workspace must be dormant") { + require.WithinDuration(t, *workspace.DormantAt, time.Now(), 10*time.Second) + } + // Starting a dormant workspace should 'wake' it. + wb, err := client.CreateWorkspaceBuild(ctx, workspace.ID, codersdk.CreateWorkspaceBuildRequest{ TemplateVersionID: template.ActiveVersionID, Transition: codersdk.WorkspaceTransition(database.WorkspaceTransitionStart), }) - require.Error(t, err) - - err = client.UpdateWorkspaceDormancy(ctx, workspace.ID, codersdk.UpdateWorkspaceDormancy{ - Dormant: false, - }) require.NoError(t, err) - coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, wb.ID) + + // After starting, the workspace should no longer be dormant. + updatedWs, err := client.Workspace(ctx, workspace.ID) + require.NoError(t, err, "fetch updated workspace") + require.Nil(t, updatedWs.DormantAt) + + // There should be an audit log for both the dormancy update and the start. + require.Len(t, auditor.AuditLogs(), 2) + require.True(t, auditor.Contains(t, database.AuditLog{ + Action: database.AuditActionWrite, + ResourceType: database.ResourceTypeWorkspace, + })) + require.True(t, auditor.Contains(t, database.AuditLog{ + Action: database.AuditActionStart, + ResourceType: database.ResourceTypeWorkspaceBuild, + })) }) }