From 328f906f0cb7c29a02f9cda4ea8286155df5140f Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 10:35:22 +0000 Subject: [PATCH 1/6] fix: exclude prebuilt workspaces from lifecycle executor --- coderd/apidoc/docs.go | 6 +- coderd/apidoc/swagger.json | 5 +- coderd/autobuild/lifecycle_executor.go | 2 + coderd/autobuild/lifecycle_executor_test.go | 349 +++++++++ coderd/database/dbgen/dbgen.go | 22 + coderd/database/queries.sql.go | 7 +- coderd/database/queries/workspaces.sql | 7 +- coderd/schedule/autostart.go | 2 + codersdk/workspacebuilds.go | 9 +- docs/reference/api/schemas.md | 1 + enterprise/coderd/workspaces_test.go | 794 ++++++++++++++++++++ site/src/api/typesGenerated.ts | 3 +- site/src/utils/workspace.tsx | 3 +- 13 files changed, 1199 insertions(+), 11 deletions(-) diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index e102b6f22fd4a..ec58a12ec6765 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11359,12 +11359,14 @@ const docTemplate = `{ "enum": [ "initiator", "autostart", - "autostop" + "autostop", + "dormancy" ], "x-enum-varnames": [ "BuildReasonInitiator", "BuildReasonAutostart", - "BuildReasonAutostop" + "BuildReasonAutostop", + "BuildReasonDormancy" ] }, "codersdk.ChangePasswordWithOneTimePasscodeRequest": { diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 95a08f2f53c9b..7dc1fb6ce4329 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10102,11 +10102,12 @@ }, "codersdk.BuildReason": { "type": "string", - "enum": ["initiator", "autostart", "autostop"], + "enum": ["initiator", "autostart", "autostop", "dormancy"], "x-enum-varnames": [ "BuildReasonInitiator", "BuildReasonAutostart", - "BuildReasonAutostop" + "BuildReasonAutostop", + "BuildReasonDormancy" ] }, "codersdk.ChangePasswordWithOneTimePasscodeRequest": { diff --git a/coderd/autobuild/lifecycle_executor.go b/coderd/autobuild/lifecycle_executor.go index 1846b1ea18284..d49bf831515d0 100644 --- a/coderd/autobuild/lifecycle_executor.go +++ b/coderd/autobuild/lifecycle_executor.go @@ -520,6 +520,8 @@ func isEligibleForAutostart(user database.User, ws database.Workspace, build dat return false } + // Get the next allowed autostart time after the build's creation time, + // based on the workspace's schedule and the template's allowed days. nextTransition, err := schedule.NextAllowedAutostart(build.CreatedAt, ws.AutostartSchedule.String, templateSchedule) if err != nil { return false diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 3bca6856534fa..0229a907cbb2e 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -2,9 +2,16 @@ package autobuild_test import ( "context" + "database/sql" + "errors" "testing" "time" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/pubsub" + "github.com/coder/coder/v2/coderd/rbac" + "github.com/coder/quartz" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1183,6 +1190,348 @@ func TestNotifications(t *testing.T) { }) } +// TestExecutorPrebuilds verifies AGPL behavior for prebuilt workspaces. +// It ensures that workspace schedules do not trigger while the workspace +// is still in a prebuilt state. Scheduling behavior only applies after the +// workspace has been claimed and becomes a regular user workspace. +// For enterprise-related functionality, see enterprise/coderd/workspaces_test.go. +func TestExecutorPrebuilds(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + // Prebuild workspaces should not be autostopped when the deadline is reached. + // After being claimed, the workspace should stop at the deadline. + t.Run("OnlyStopsAfterClaimed", func(t *testing.T) { + t.Parallel() + + // Setup + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + var ( + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + }) + ) + + // Setup user, template and template version + owner := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Database setup of a preset with a prebuild instance + preset := setupTestDBPreset(t, db, version.ID, int32(1)) + + // Given: a running prebuilt workspace with a deadline and ready to be claimed + dbPrebuild := setupTestDBPrebuiltWorkspace( + ctx, t, clock, db, pb, + owner.OrganizationID, + template.ID, + version.ID, + preset.ID, + ) + prebuild := coderdtest.MustWorkspace(t, client, dbPrebuild.ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + require.NotZero(t, prebuild.LatestBuild.Deadline) + + // When: the autobuild executor ticks *after* the deadline: + go func() { + tickCh <- prebuild.LatestBuild.Deadline.Time.Add(time.Minute) + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: a user claims the prebuilt workspace + dbWorkspace := dbgen.ClaimPrebuild(t, db, user.ID, "claimedWorkspace-autostop", preset.ID) + workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) + + // When: the autobuild executor ticks *after* the deadline: + go func() { + tickCh <- workspace.LatestBuild.Deadline.Time.Add(time.Minute) + close(tickCh) + }() + + // Then: the workspace should be stopped + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonAutostop, workspace.LatestBuild.Reason) + }) + + // Prebuild workspaces should not be autostarted when the autostart scheduled is reached. + // After being claimed, the workspace should autostart at the schedule. + t.Run("OnlyStartsAfterClaimed", func(t *testing.T) { + t.Parallel() + + // Setup + ctx := testutil.Context(t, testutil.WaitShort) + clock := quartz.NewMock(t) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + var ( + tickCh = make(chan time.Time) + statsCh = make(chan autobuild.Stats) + client = coderdtest.New(t, &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + }) + ) + + // Setup user, template and template version + owner := coderdtest.CreateFirstUser(t, client) + _, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, nil) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID) + + // Database setup of a preset with a prebuild instance + preset := setupTestDBPreset(t, db, version.ID, int32(1)) + + // Given: prebuilt workspace is stopped and set to autostart daily at midnight + sched := mustSchedule(t, "CRON_TZ=UTC 0 0 * * *") + autostartSched := sql.NullString{ + String: sched.String(), + Valid: true, + } + dbPrebuild := setupTestDBPrebuiltWorkspace( + ctx, t, clock, db, pb, + owner.OrganizationID, + template.ID, + version.ID, + preset.ID, + WithAutostartSchedule(autostartSched), + WithIsStopped(true), + ) + prebuild := coderdtest.MustWorkspace(t, client, dbPrebuild.ID) + require.Equal(t, codersdk.WorkspaceTransitionStop, prebuild.LatestBuild.Transition) + require.NotNil(t, prebuild.AutostartSchedule) + + // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, + // since the next allowed autostart is calculated starting from that point. + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) + }() + + // Then: the prebuilt workspace should remain in a stop transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStop, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: prebuilt workspace is in a start status + setupTestDBWorkspaceBuild( + ctx, t, clock, db, pb, + owner.OrganizationID, + prebuild.ID, + version.ID, + preset.ID, + database.WorkspaceTransitionStart) + + // Given: a user claims the prebuilt workspace + dbWorkspace := dbgen.ClaimPrebuild(t, db, user.ID, "claimedWorkspace-autostart", preset.ID) + workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) + + // Given: the prebuilt workspace goes to a stop status + setupTestDBWorkspaceBuild( + ctx, t, clock, db, pb, + owner.OrganizationID, + prebuild.ID, + version.ID, + preset.ID, + database.WorkspaceTransitionStop) + + // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, + // since the next allowed autostart is calculated starting from that point. + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- sched.Next(workspace.LatestBuild.CreatedAt).Add(time.Minute) + close(tickCh) + }() + + // Then: the workspace should eventually be started + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStart, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonAutostart, workspace.LatestBuild.Reason) + }) +} + +func setupTestDBPreset( + t *testing.T, + db database.Store, + templateVersionID uuid.UUID, + desiredInstances int32, +) database.TemplateVersionPreset { + t.Helper() + + preset := dbgen.Preset(t, db, database.InsertPresetParams{ + TemplateVersionID: templateVersionID, + Name: "preset-test", + DesiredInstances: sql.NullInt32{ + Valid: true, + Int32: desiredInstances, + }, + }) + dbgen.PresetParameter(t, db, database.InsertPresetParametersParams{ + TemplateVersionPresetID: preset.ID, + Names: []string{"test-name"}, + Values: []string{"test-value"}, + }) + + return preset +} + +type SetupPrebuiltOptions struct { + AutostartSchedule sql.NullString + IsStopped bool +} + +func WithAutostartSchedule(sched sql.NullString) func(*SetupPrebuiltOptions) { + return func(o *SetupPrebuiltOptions) { + o.AutostartSchedule = sched + } +} + +func WithIsStopped(isStopped bool) func(*SetupPrebuiltOptions) { + return func(o *SetupPrebuiltOptions) { + o.IsStopped = isStopped + } +} + +func setupTestDBWorkspaceBuild( + ctx context.Context, + t *testing.T, + clock quartz.Clock, + db database.Store, + ps pubsub.Pubsub, + orgID uuid.UUID, + workspaceID uuid.UUID, + templateVersionID uuid.UUID, + presetID uuid.UUID, + transition database.WorkspaceTransition, +) (database.ProvisionerJob, database.WorkspaceBuild) { + t.Helper() + + var buildNumber int32 = 1 + latestWorkspaceBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspaceID) + if !errors.Is(err, sql.ErrNoRows) { + buildNumber = latestWorkspaceBuild.BuildNumber + 1 + } + + job := dbgen.ProvisionerJob(t, db, ps, database.ProvisionerJob{ + InitiatorID: database.PrebuildsSystemUserID, + CreatedAt: clock.Now().Add(-time.Hour * 2), + StartedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour * 2), Valid: true}, + CompletedAt: sql.NullTime{Time: clock.Now().Add(-time.Hour), Valid: true}, + OrganizationID: orgID, + JobStatus: database.ProvisionerJobStatusSucceeded, + }) + workspaceBuild := dbgen.WorkspaceBuild(t, db, database.WorkspaceBuild{ + WorkspaceID: workspaceID, + InitiatorID: database.PrebuildsSystemUserID, + TemplateVersionID: templateVersionID, + BuildNumber: buildNumber, + JobID: job.ID, + TemplateVersionPresetID: uuid.NullUUID{UUID: presetID, Valid: true}, + Transition: transition, + CreatedAt: clock.Now(), + }) + dbgen.WorkspaceBuildParameters(t, db, []database.WorkspaceBuildParameter{ + { + WorkspaceBuildID: workspaceBuild.ID, + Name: "test", + Value: "test", + }, + }) + + workspaceResource := dbgen.WorkspaceResource(t, db, database.WorkspaceResource{ + JobID: job.ID, + Transition: database.WorkspaceTransitionStart, + Type: "compute", + Name: "main", + }) + + // Workspaces are eligible to be claimed once their agent is marked "ready" + dbgen.WorkspaceAgent(t, db, database.WorkspaceAgent{ + Name: "test", + ResourceID: workspaceResource.ID, + Architecture: "i386", + OperatingSystem: "linux", + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true}, + APIKeyScope: database.AgentKeyScopeEnumAll, + }) + + return job, workspaceBuild +} + +func setupTestDBPrebuiltWorkspace( + ctx context.Context, + t *testing.T, + clock quartz.Clock, + db database.Store, + ps pubsub.Pubsub, + orgID uuid.UUID, + templateID uuid.UUID, + templateVersionID uuid.UUID, + presetID uuid.UUID, + opts ...func(*SetupPrebuiltOptions), +) database.WorkspaceTable { + t.Helper() + + // Optional parameters + options := &SetupPrebuiltOptions{} + for _, opt := range opts { + opt(options) + } + + buildTransition := database.WorkspaceTransitionStart + if options.IsStopped { + buildTransition = database.WorkspaceTransitionStop + } + + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + TemplateID: templateID, + OrganizationID: orgID, + OwnerID: database.PrebuildsSystemUserID, + Deleted: false, + CreatedAt: time.Now().Add(-time.Hour * 2), + AutostartSchedule: options.AutostartSchedule, + }) + setupTestDBWorkspaceBuild(ctx, t, clock, db, ps, orgID, workspace.ID, templateVersionID, presetID, buildTransition) + + return workspace +} + func mustProvisionWorkspace(t *testing.T, client *codersdk.Client, mut ...func(*codersdk.CreateWorkspaceRequest)) codersdk.Workspace { t.Helper() user := coderdtest.CreateFirstUser(t, client) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 0bb7bde403297..00fc3aa006e70 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -204,6 +204,17 @@ func WorkspaceAgent(t testing.TB, db database.Store, orig database.WorkspaceAgen require.NoError(t, err, "update workspace agent first connected at") } + // If the lifecycle state is "ready", update the agent with the corresponding timestamps + if orig.LifecycleState == database.WorkspaceAgentLifecycleStateReady && orig.StartedAt.Valid && orig.ReadyAt.Valid { + err := db.UpdateWorkspaceAgentLifecycleStateByID(genCtx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agt.ID, + LifecycleState: orig.LifecycleState, + StartedAt: orig.StartedAt, + ReadyAt: orig.ReadyAt, + }) + require.NoError(t, err, "update workspace agent lifecycle state") + } + if orig.ParentID.UUID == uuid.Nil { // Add a test antagonist. For every agent we add a deleted sub agent // to discover cases where deletion should be handled. @@ -1352,6 +1363,17 @@ func PresetParameter(t testing.TB, db database.Store, seed database.InsertPreset return parameters } +func ClaimPrebuild(t testing.TB, db database.Store, newUserID uuid.UUID, newName string, presetID uuid.UUID) database.ClaimPrebuiltWorkspaceRow { + claimedWorkspace, err := db.ClaimPrebuiltWorkspace(genCtx, database.ClaimPrebuiltWorkspaceParams{ + NewUserID: newUserID, + NewName: newName, + PresetID: presetID, + }) + require.NoError(t, err, "claim prebuilt workspace") + + return claimedWorkspace +} + func provisionerJobTiming(t testing.TB, db database.Store, seed database.ProvisionerJobTiming) database.ProvisionerJobTiming { timing, err := db.InsertProvisionerJobTimings(genCtx, database.InsertProvisionerJobTimingsParams{ JobID: takeFirst(seed.JobID, uuid.New()), diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 15f4be06a3fa0..7b55c6a4db7b8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19971,7 +19971,12 @@ WHERE provisioner_jobs.completed_at IS NOT NULL AND ($1 :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) ) - ) AND workspaces.deleted = 'false' + ) + AND workspaces.deleted = 'false' + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not be considered by the lifecycle executor, as they are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type GetWorkspacesEligibleForTransitionRow struct { diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 25e4d4f97a46b..f166d16f742cd 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -758,7 +758,12 @@ WHERE provisioner_jobs.completed_at IS NOT NULL AND (@now :: timestamptz) - provisioner_jobs.completed_at > (INTERVAL '1 millisecond' * (templates.failure_ttl / 1000000)) ) - ) AND workspaces.deleted = 'false'; + ) + AND workspaces.deleted = 'false' + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not be considered by the lifecycle executor, as they are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceDormantDeletingAt :one UPDATE diff --git a/coderd/schedule/autostart.go b/coderd/schedule/autostart.go index 0a7f583e4f9b2..538d3dd346fcd 100644 --- a/coderd/schedule/autostart.go +++ b/coderd/schedule/autostart.go @@ -33,6 +33,8 @@ func NextAutostart(at time.Time, wsSchedule string, templateSchedule TemplateSch return zonedTransition, allowed } +// NextAllowedAutostart returns the next valid autostart time after 'at', based on the workspace's +// cron schedule and the template's allowed days. It searches up to 7 days ahead to find a match. func NextAllowedAutostart(at time.Time, wsSchedule string, templateSchedule TemplateScheduleOptions) (time.Time, error) { next := at diff --git a/codersdk/workspacebuilds.go b/codersdk/workspacebuilds.go index 328b8bc26566f..db736d431fd02 100644 --- a/codersdk/workspacebuilds.go +++ b/codersdk/workspacebuilds.go @@ -37,15 +37,18 @@ const ( type BuildReason string const ( - // "initiator" is used when a workspace build is triggered by a user. + // BuildReasonInitiator "initiator" is used when a workspace build is triggered by a user. // Combined with the initiator id/username, it indicates which user initiated the build. BuildReasonInitiator BuildReason = "initiator" - // "autostart" is used when a build to start a workspace is triggered by Autostart. + // BuildReasonAutostart "autostart" is used when a build to start a workspace is triggered by Autostart. // The initiator id/username in this case is the workspace owner and can be ignored. BuildReasonAutostart BuildReason = "autostart" - // "autostop" is used when a build to stop a workspace is triggered by Autostop. + // BuildReasonAutostop "autostop" is used when a build to stop a workspace is triggered by Autostop. // The initiator id/username in this case is the workspace owner and can be ignored. BuildReasonAutostop BuildReason = "autostop" + // BuildReasonDormancy "dormancy" is used when a build to stop a workspace is triggered due to inactivity (dormancy). + // The initiator id/username in this case is the workspace owner and can be ignored. + BuildReasonDormancy BuildReason = "dormancy" ) // WorkspaceBuild is an at-point representation of a workspace state. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 281a3a8a19e61..f20e103933b0e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -1049,6 +1049,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `initiator` | | `autostart` | | `autostop` | +| `dormancy` | ## codersdk.ChangePasswordWithOneTimePasscodeRequest diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 3bed052702637..29d1a7194bb1b 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -10,10 +10,17 @@ import ( "os" "os/exec" "path/filepath" + "strings" "sync/atomic" "testing" "time" + "github.com/prometheus/client_golang/prometheus" + + "github.com/coder/coder/v2/coderd/files" + agplprebuilds "github.com/coder/coder/v2/coderd/prebuilds" + "github.com/coder/coder/v2/enterprise/coderd/prebuilds" + "github.com/google/uuid" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -1713,6 +1720,793 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) { }) } +func TestExecutorPrebuilds(t *testing.T) { + t.Parallel() + + if !dbtestutil.WillUsePostgres() { + t.Skip("this test requires postgres") + } + + getRunningPrebuilds := func( + t *testing.T, + ctx context.Context, + db database.Store, + prebuildInstances int, + ) []database.GetRunningPrebuiltWorkspacesRow { + t.Helper() + + var runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow + require.Eventually(t, func() bool { + rows, err := db.GetRunningPrebuiltWorkspaces(ctx) + if err != nil { + return false + } + + for _, row := range rows { + runningPrebuilds = append(runningPrebuilds, row) + + agents, err := db.GetWorkspaceAgentsInLatestBuildByWorkspaceID(ctx, row.ID) + if err != nil { + return false + } + + for _, agent := range agents { + err = db.UpdateWorkspaceAgentLifecycleStateByID(ctx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{ + ID: agent.ID, + LifecycleState: database.WorkspaceAgentLifecycleStateReady, + StartedAt: sql.NullTime{Time: time.Now().Add(time.Hour), Valid: true}, + ReadyAt: sql.NullTime{Time: time.Now().Add(-1 * time.Hour), Valid: true}, + }) + if err != nil { + return false + } + } + } + + t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), prebuildInstances) + return len(runningPrebuilds) == prebuildInstances + }, testutil.WaitSuperLong, testutil.IntervalSlow) + + return runningPrebuilds + } + + runReconciliationLoop := func( + t *testing.T, + ctx context.Context, + db database.Store, + reconciler *prebuilds.StoreReconciler, + presets []codersdk.Preset, + ) { + t.Helper() + + state, err := reconciler.SnapshotState(ctx, db) + require.NoError(t, err) + ps, err := state.FilterByPreset(presets[0].ID) + require.NoError(t, err) + require.NotNil(t, ps) + actions, err := reconciler.CalculateActions(ctx, *ps) + require.NoError(t, err) + require.NotNil(t, actions) + require.NoError(t, reconciler.ReconcilePreset(ctx, *ps)) + } + + claimPrebuild := func( + t *testing.T, + ctx context.Context, + client *codersdk.Client, + userClient *codersdk.Client, + username string, + version codersdk.TemplateVersion, + presetID uuid.UUID, + ) codersdk.Workspace { + t.Helper() + + workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") + userWorkspace, err := userClient.CreateUserWorkspace(ctx, username, codersdk.CreateWorkspaceRequest{ + TemplateVersionID: version.ID, + Name: workspaceName, + TemplateVersionPresetID: presetID, + }) + require.NoError(t, err) + build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, userWorkspace.LatestBuild.ID) + require.Equal(t, build.Job.Status, codersdk.ProvisionerJobSucceeded) + workspace := coderdtest.MustWorkspace(t, client, userWorkspace.ID) + assert.Equal(t, codersdk.WorkspaceTransitionStart, workspace.LatestBuild.Transition) + + return workspace + } + + // Prebuilt workspaces should not be autostopped based on the default TTL. + // This test ensures that DefaultTTLMillis is ignored while the workspace is in a prebuild state. + // Once the workspace is claimed, the default autostop timer should take effect. + t.Run("DefaultTTLOnlyTriggersAfterClaim", func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + ttlTime := 2 * time.Hour + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + // Set a template level TTL to trigger the autostop + // Template level TTL can only be set if autostop is disabled for users + ctr.AllowUserAutostop = ptr.Ref[bool](false) + ctr.DefaultTTLMillis = ptr.Ref[int64](ttlTime.Milliseconds()) + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + + // Given: Reconciliation loop runs and starts prebuilt workspace + runReconciliationLoop(t, ctx, db, reconciler, presets) + runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + require.Len(t, runningPrebuilds, int(prebuildInstances)) + + // Given: a running prebuilt workspace with a deadline, ready to be claimed + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + require.NotZero(t, prebuild.LatestBuild.Deadline) + + // When: the autobuild executor ticks *after* the deadline + next := prebuild.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) + go func() { + tickCh <- next + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: a user claims the prebuilt workspace sometime later + clock.Set(clock.Now().Add(ttlTime)) + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + // Workspace deadline must be ttlTime from the time it is claimed + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime))) + + // When: the autobuild executor ticks *after* the deadline + next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) + go func() { + tickCh <- next + close(tickCh) + }() + + // Then: the workspace should be stopped + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonAutostop, workspace.LatestBuild.Reason) + }) + + // Prebuild workspaces should not follow the autostop schedule. + // This test verifies that AutostopRequirement (autostop schedule) is ignored while the workspace is a prebuild. + // After being claimed, the workspace should be stopped according to the autostop schedule. + t.Run("AutostopScheduleOnlyTriggersAfterClaim", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + isClaimedBeforeDeadline bool + }{ + // If the prebuild is claimed before the scheduled deadline, + // the claimed workspace should inherit and respect that same deadline. + { + name: "ClaimedBeforeDeadline_UsesSameDeadline", + isClaimedBeforeDeadline: true, + }, + // If the prebuild is claimed after the scheduled deadline, + // the workspace should not stop immediately, but instead respect the next + // valid scheduled deadline (the next day). + { + name: "ClaimedAfterDeadline_SchedulesForNextDay", + isClaimedBeforeDeadline: false, + }, + } + + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + // Set a template level Autostop schedule to trigger the autostop daily + ctr.AutostopRequirement = ptr.Ref[codersdk.TemplateAutostopRequirement]( + codersdk.TemplateAutostopRequirement{ + DaysOfWeek: []string{"monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"}, + Weeks: 1, + }) + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + + // Given: Reconciliation loop runs and starts prebuilt workspace + runReconciliationLoop(t, ctx, db, reconciler, presets) + runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + require.Len(t, runningPrebuilds, int(prebuildInstances)) + + // Given: a running prebuilt workspace with a deadline, ready to be claimed + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + require.NotZero(t, prebuild.LatestBuild.Deadline) + + next := clock.Now() + if tc.isClaimedBeforeDeadline { + // When: the autobuild executor ticks *before* the deadline: + next = next.Add(time.Minute) + } else { + // When: the autobuild executor ticks *after* the deadline: + next = next.Add(24 * time.Hour) + } + + clock.Set(next) + go func() { + tickCh <- next + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: a user claims the prebuilt workspace + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + + if tc.isClaimedBeforeDeadline { + // Then: the claimed workspace should inherit and respect that same deadline. + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(prebuild.LatestBuild.Deadline.Time)) + } else { + // Then: the claimed workspace should respect the next valid scheduled deadline (next day). + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Truncate(24*time.Hour).Add(24*time.Hour))) + } + + // When: the autobuild executor ticks *after* the deadline: + next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) + go func() { + tickCh <- next + close(tickCh) + }() + + // Then: the workspace should be stopped + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonAutostop, workspace.LatestBuild.Reason) + }) + } + }) + + // Prebuild workspaces should not follow the autostart schedule. + // This test verifies that AutostartRequirement (autostart schedule) is ignored while the workspace is a prebuild. + t.Run("AutostartScheduleOnlyTriggersAfterClaim", func(t *testing.T) { + t.Parallel() + + // Set the clock to dbtime.Now() to match the workspace build's CreatedAt + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + // Set a template level Autostart schedule to trigger the autostart daily + ctr.AllowUserAutostart = ptr.Ref[bool](true) + ctr.AutostartRequirement = &codersdk.TemplateAutostartRequirement{DaysOfWeek: codersdk.AllDaysOfWeek} + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + + // Given: Reconciliation loop runs and starts prebuilt workspace + runReconciliationLoop(t, ctx, db, reconciler, presets) + runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + require.Len(t, runningPrebuilds, int(prebuildInstances)) + + // Given: prebuilt workspace has autostart schedule daily at midnight + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + sched, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *") + require.NoError(t, err) + err = client.UpdateWorkspaceAutostart(ctx, prebuild.ID, codersdk.UpdateWorkspaceAutostartRequest{ + Schedule: ptr.Ref(sched.String()), + }) + require.NoError(t, err) + + // Given: prebuilt workspace is stopped + prebuild = coderdtest.MustTransitionWorkspace(t, client, prebuild.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, prebuild.LatestBuild.ID) + + // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, + // since the next allowed autostart is calculated starting from that point. + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) + }() + + // Then: the prebuilt workspace should remain in a stop transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStop, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: a prebuilt workspace that is running and ready to be claimed + prebuild = coderdtest.MustTransitionWorkspace(t, client, prebuild.ID, codersdk.WorkspaceTransitionStop, codersdk.WorkspaceTransitionStart) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, prebuild.LatestBuild.ID) + + // Make sure the workspace's agent is again ready + getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + + // Given: a user claims the prebuilt workspace + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + require.NotNil(t, workspace.NextStartAt) + + // Given: workspace is stopped + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID) + + // Then: the claimed workspace should inherit and respect that same NextStartAt + require.True(t, workspace.NextStartAt.Equal(*prebuild.NextStartAt)) + + // Tick at the next scheduled time after the prebuild’s LatestBuild.CreatedAt, + // since the next allowed autostart is calculated starting from that point. + // When: the autobuild executor ticks after the scheduled time + go func() { + tickCh <- sched.Next(prebuild.LatestBuild.CreatedAt).Add(time.Minute) + }() + + // Then: the workspace should have a NextStartAt equal to the next autostart schedule + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.NotNil(t, workspace.NextStartAt) + require.Equal(t, sched.Next(clock.Now()), workspace.NextStartAt.UTC()) + }) + + // Prebuild workspaces should not transition to dormant when the inactive TTL is reached. + // This test verifies that TimeTilDormantMillis is ignored while the workspace is a prebuild. + // After being claimed, the workspace should become dormant according to the configured inactivity period. + t.Run("DormantOnlyAfterClaimed", func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + inactiveTTL := 2 * time.Hour + userClient, user := coderdtest.CreateAnotherUser(t, client, owner.OrganizationID, rbac.RoleMember()) + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithAgentAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + // Set a template level inactive TTL to trigger dormancy + ctr.TimeTilDormantMillis = ptr.Ref[int64](inactiveTTL.Milliseconds()) + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + + // Given: reconciliation loop runs and starts prebuilt workspace + runReconciliationLoop(t, ctx, db, reconciler, presets) + runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances)) + require.Len(t, runningPrebuilds, int(prebuildInstances)) + + // Given: a running prebuilt workspace, ready to be claimed + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + + // When: the autobuild executor ticks *after* the inactive TTL + go func() { + tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute) + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + + // Given: a user claims the prebuilt workspace sometime later + clock.Set(clock.Now().Add(inactiveTTL)) + workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID) + require.Equal(t, prebuild.ID, workspace.ID) + require.Nil(t, prebuild.DormantAt) + + // When: the autobuild executor ticks *after* the inactive TTL + go func() { + tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute) + close(tickCh) + }() + + // Then: the workspace should transition to stopped state for breaching failure TTL + workspaceStats := <-statsCh + require.Len(t, workspaceStats.Errors, 0) + require.Len(t, workspaceStats.Transitions, 1) + require.Contains(t, workspaceStats.Transitions, workspace.ID) + require.Equal(t, database.WorkspaceTransitionStop, workspaceStats.Transitions[workspace.ID]) + workspace = coderdtest.MustWorkspace(t, client, workspace.ID) + require.Equal(t, codersdk.BuildReasonDormancy, workspace.LatestBuild.Reason) + require.NotNil(t, workspace.DormantAt) + }) + + // Prebuild workspaces should not be deleted when the failure TTL is reached. + // This test verifies that FailureTTLMillis is ignored while the workspace is a prebuild. + t.Run("FailureTTLOnlyAfterClaimed", func(t *testing.T) { + t.Parallel() + + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic + clock := quartz.NewMock(t) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + + // Setup + ctx := testutil.Context(t, testutil.WaitSuperLong) + db, pb := dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure()) + logger := testutil.Logger(t) + tickCh := make(chan time.Time) + statsCh := make(chan autobuild.Stats) + notificationsNoop := notifications.NewNoopEnqueuer() + client, _, api, owner := coderdenttest.NewWithAPI(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + Database: db, + Pubsub: pb, + AutobuildTicker: tickCh, + IncludeProvisionerDaemon: true, + AutobuildStats: statsCh, + Clock: clock, + TemplateScheduleStore: schedule.NewEnterpriseTemplateScheduleStore( + agplUserQuietHoursScheduleStore(), + notificationsNoop, + logger, + clock, + ), + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, + }, + }) + + // Setup Prebuild reconciler + cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{}) + reconciler := prebuilds.NewStoreReconciler( + db, pb, cache, + codersdk.PrebuildsConfig{}, + logger, + clock, + prometheus.NewRegistry(), + notificationsNoop, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) + + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + failureTTL := 2 * time.Hour + version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithFailedResponseAndPresetsWithPrebuilds(prebuildInstances)) + coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID) + template := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + // Set a template level Failure TTL to trigger workspace deletion + ctr.FailureTTLMillis = ptr.Ref[int64](failureTTL.Milliseconds()) + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) + + // Given: reconciliation loop runs and starts prebuilt workspace in failed state + runReconciliationLoop(t, ctx, db, reconciler, presets) + + var failedWorkspaceBuilds []database.GetFailedWorkspaceBuildsByTemplateIDRow + require.Eventually(t, func() bool { + rows, err := db.GetFailedWorkspaceBuildsByTemplateID(ctx, database.GetFailedWorkspaceBuildsByTemplateIDParams{ + TemplateID: template.ID, + }) + if err != nil { + return false + } + + failedWorkspaceBuilds = append(failedWorkspaceBuilds, rows...) + + t.Logf("found %d failed prebuilds so far, want %d", len(failedWorkspaceBuilds), prebuildInstances) + return len(failedWorkspaceBuilds) == int(prebuildInstances) + }, testutil.WaitSuperLong, testutil.IntervalSlow) + require.Len(t, failedWorkspaceBuilds, int(prebuildInstances)) + + // Given: a failed prebuilt workspace + prebuild := coderdtest.MustWorkspace(t, client, failedWorkspaceBuilds[0].WorkspaceID) + require.Equal(t, codersdk.WorkspaceStatusFailed, prebuild.LatestBuild.Status) + + // When: the autobuild executor ticks *after* the failure TTL + go func() { + tickCh <- prebuild.LatestBuild.Job.CompletedAt.Add(failureTTL * 2) + }() + + // Then: the prebuilt workspace should remain in a start transition + prebuildStats := <-statsCh + require.Len(t, prebuildStats.Errors, 0) + require.Len(t, prebuildStats.Transitions, 0) + require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + }) +} + +func templateWithAgentAndPresetsWithPrebuilds(desiredInstances int32) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Presets: []*proto.Preset{ + { + Name: "preset-test", + Parameters: []*proto.PresetParameter{ + { + Name: "k1", + Value: "v1", + }, + }, + Prebuild: &proto.Prebuild{ + Instances: desiredInstances, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: []*proto.Response{ + { + Type: &proto.Response_Apply{ + Apply: &proto.ApplyComplete{ + Resources: []*proto.Resource{ + { + Type: "compute", + Name: "main", + Agents: []*proto.Agent{ + { + Name: "smith", + OperatingSystem: "linux", + Architecture: "i386", + }, + }, + }, + }, + }, + }, + }, + }, + } +} + +func templateWithFailedResponseAndPresetsWithPrebuilds(desiredInstances int32) *echo.Responses { + return &echo.Responses{ + Parse: echo.ParseComplete, + ProvisionPlan: []*proto.Response{ + { + Type: &proto.Response_Plan{ + Plan: &proto.PlanComplete{ + Presets: []*proto.Preset{ + { + Name: "preset-test", + Parameters: []*proto.PresetParameter{ + { + Name: "k1", + Value: "v1", + }, + }, + Prebuild: &proto.Prebuild{ + Instances: desiredInstances, + }, + }, + }, + }, + }, + }, + }, + ProvisionApply: echo.ApplyFailed, + } +} + // TestWorkspaceTemplateParamsChange tests a workspace with a parameter that // validation changes on apply. The params used in create workspace are invalid // according to the static params on import. diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 4ab5403081a60..1724542b1f620 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -275,11 +275,12 @@ export interface BuildInfoResponse { } // From codersdk/workspacebuilds.go -export type BuildReason = "autostart" | "autostop" | "initiator"; +export type BuildReason = "autostart" | "autostop" | "dormancy" | "initiator"; export const BuildReasons: BuildReason[] = [ "autostart", "autostop", + "dormancy", "initiator", ]; diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 135b965589054..89ec8622de0dc 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -75,7 +75,7 @@ export const getDisplayWorkspaceBuildStatus = ( export const getDisplayWorkspaceBuildInitiatedBy = ( build: TypesGen.WorkspaceBuild, -): string => { +): string | undefined => { switch (build.reason) { case "initiator": return build.initiator_name; @@ -83,6 +83,7 @@ export const getDisplayWorkspaceBuildInitiatedBy = ( case "autostop": return "Coder"; } + return undefined; }; const getWorkspaceBuildDurationInSeconds = ( From d8eb34e50c4cb4c0269bdf3825f73f4979434517 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 10:59:50 +0000 Subject: [PATCH 2/6] docs: remove warning about prebuilt and Workspace schedule/dormancy incompatabilities --- .../templates/extending-templates/prebuilt-workspaces.md | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 2c5e73ad289b4..7da3b360363ad 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -2,13 +2,10 @@ > [!WARNING] > Prebuilds Compatibility Limitations: -> Prebuilt workspaces are currently not compatible with configurations that have Workspace schedule (autostart/autostop), or Dormancy enabled. -> If these features are configured, prebuilt workspaces may fail to run correctly. +> Prebuilds currently do not work reliably with [DevContainers feature](../managing-templates/devcontainers/index.md). +> If your project relies on DevContainer configuration, we recommend disabling prebuilds or carefully testing behavior before enabling them. > -> In addition, prebuilds currently do not work reliably with [DevContainers feature](../managing-templates/devcontainers/index.md). -> If your project relies on DevContainer configuration, we recommend disabling prebuilds or carefully testing behavior before enabling them in production. -> -> We’re actively working to improve compatibility, but for now, please avoid using prebuilds with these features to ensure stability and expected behavior. +> We’re actively working to improve compatibility, but for now, please avoid using prebuilds with this feature to ensure stability and expected behavior. Prebuilt workspaces allow template administrators to improve the developer experience by reducing workspace creation time with an automatically maintained pool of ready-to-use workspaces for specific parameter presets. From 4835eeb2f49dda4a5a6836d370a5748987ecb7d4 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 11:01:18 +0000 Subject: [PATCH 3/6] docs: fix typo --- docs/admin/templates/extending-templates/prebuilt-workspaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index 7da3b360363ad..d0fcae972fe0b 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -2,7 +2,7 @@ > [!WARNING] > Prebuilds Compatibility Limitations: -> Prebuilds currently do not work reliably with [DevContainers feature](../managing-templates/devcontainers/index.md). +> Prebuilt workspaces currently do not work reliably with [DevContainers feature](../managing-templates/devcontainers/index.md). > If your project relies on DevContainer configuration, we recommend disabling prebuilds or carefully testing behavior before enabling them. > > We’re actively working to improve compatibility, but for now, please avoid using prebuilds with this feature to ensure stability and expected behavior. From fd12369932b12a55f47a7968ce23ba60b5777768 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 11:17:20 +0000 Subject: [PATCH 4/6] docs: fix unreached header reference --- docs/admin/templates/extending-templates/prebuilt-workspaces.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/admin/templates/extending-templates/prebuilt-workspaces.md b/docs/admin/templates/extending-templates/prebuilt-workspaces.md index d0fcae972fe0b..8e61687ce0f01 100644 --- a/docs/admin/templates/extending-templates/prebuilt-workspaces.md +++ b/docs/admin/templates/extending-templates/prebuilt-workspaces.md @@ -23,7 +23,7 @@ Prebuilt workspaces are: ## Relationship to workspace presets -Prebuilt workspaces are tightly integrated with [workspace presets](./parameters.md#workspace-presets-beta): +Prebuilt workspaces are tightly integrated with [workspace presets](./parameters.md#workspace-presets): 1. Each prebuilt workspace is associated with a specific template preset. 1. The preset must define all required parameters needed to build the workspace. From 2bb2102fcc3bfb4bc8bd2556eff7de740aec98b1 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 11:31:29 +0000 Subject: [PATCH 5/6] test: use testutil.Eventually --- enterprise/coderd/workspaces_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 29d1a7194bb1b..5d417f0dfb829 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1736,7 +1736,7 @@ func TestExecutorPrebuilds(t *testing.T) { t.Helper() var runningPrebuilds []database.GetRunningPrebuiltWorkspacesRow - require.Eventually(t, func() bool { + testutil.Eventually(ctx, t, func(context.Context) bool { rows, err := db.GetRunningPrebuiltWorkspaces(ctx) if err != nil { return false @@ -1765,7 +1765,7 @@ func TestExecutorPrebuilds(t *testing.T) { t.Logf("found %d running prebuilds so far, want %d", len(runningPrebuilds), prebuildInstances) return len(runningPrebuilds) == prebuildInstances - }, testutil.WaitSuperLong, testutil.IntervalSlow) + }, testutil.IntervalSlow, "prebuilds not running") return runningPrebuilds } From d1a9c6494515b67ab23ed5b82fc637fd0a686abd Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 7 Jul 2025 11:39:01 +0000 Subject: [PATCH 6/6] chore: add dormancy case to getDisplayWorkspaceBuildInitiatedBy --- site/src/utils/workspace.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/site/src/utils/workspace.tsx b/site/src/utils/workspace.tsx index 89ec8622de0dc..c88ffc9d8edaa 100644 --- a/site/src/utils/workspace.tsx +++ b/site/src/utils/workspace.tsx @@ -81,6 +81,7 @@ export const getDisplayWorkspaceBuildInitiatedBy = ( return build.initiator_name; case "autostart": case "autostop": + case "dormancy": return "Coder"; } return undefined;