fix: set prebuilds lifecycle parameters on creation and claim (#19252)

## Description

This PR ensures that prebuilt workspaces are properly excluded from the
lifecycle executor and treated as a separate class of workspaces, fully
managed by the prebuild reconciliation loop.

It introduces two lifecycle guarantees:
* When a prebuilt workspace is created (i.e., when the workspace build
completes), all lifecycle-related fields are unset, ensuring the
workspace does not participate in TTL, autostop, autostart, dormancy, or
auto-deletion logic.
* When a prebuilt workspace is claimed, it transitions into a regular
user workspace. At this point, all lifecycle fields are correctly
populated according to template-level configurations, allowing the
workspace to be managed by the lifecycle executor as expected.

## Changes

* Prebuilt workspaces now have all lifecycle-relevant fields unset
during creation
* When a prebuild is claimed:
* Lifecycle fields are set based on template and workspace level
configurations. This ensures a clean transition into the standard
workspace lifecycle flow.
* Updated lifecycle-related SQL update queries to explicitly exclude
prebuilt workspaces.

## Relates 

Related issue: https://github.com/coder/coder/issues/18898

To reduce the scope of this PR and make the review process more
manageable, the original implementation has been split into the
following focused PRs:
* https://github.com/coder/coder/pull/19259
* https://github.com/coder/coder/pull/19263
* https://github.com/coder/coder/pull/19264
* https://github.com/coder/coder/pull/19265

These PRs should be considered in conjunction with this one to
understand the complete set of lifecycle separation changes for prebuilt
workspaces.
This commit is contained in:
Susana Ferreira
2025-08-13 12:45:46 +01:00
committed by GitHub
parent f17ab92798
commit 8567ecbe52
14 changed files with 479 additions and 297 deletions
+231 -226
View File
@@ -1722,7 +1722,7 @@ func TestTemplateDoesNotAllowUserAutostop(t *testing.T) {
})
}
func TestExecutorPrebuilds(t *testing.T) {
func TestPrebuildsAutobuild(t *testing.T) {
t.Parallel()
if !dbtestutil.WillUsePostgres() {
@@ -1800,14 +1800,21 @@ func TestExecutorPrebuilds(t *testing.T) {
username string,
version codersdk.TemplateVersion,
presetID uuid.UUID,
autostartSchedule ...string,
) codersdk.Workspace {
t.Helper()
var startSchedule string
if len(autostartSchedule) > 0 {
startSchedule = autostartSchedule[0]
}
workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-")
userWorkspace, err := userClient.CreateUserWorkspace(ctx, username, codersdk.CreateWorkspaceRequest{
TemplateVersionID: version.ID,
Name: workspaceName,
TemplateVersionPresetID: presetID,
AutostartSchedule: ptr.Ref(startSchedule),
})
require.NoError(t, err)
build := coderdtest.AwaitWorkspaceBuildJobCompleted(t, userClient, userWorkspace.LatestBuild.ID)
@@ -1820,7 +1827,7 @@ func TestExecutorPrebuilds(t *testing.T) {
// 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.
// Once the workspace is claimed, the default TTL should take effect.
t.Run("DefaultTTLOnlyTriggersAfterClaim", func(t *testing.T) {
t.Parallel()
@@ -1875,9 +1882,9 @@ func TestExecutorPrebuilds(t *testing.T) {
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)
// Set a template level TTL to trigger the autostop
// Template level TTL can only be set if autostop is disabled for users
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())
})
@@ -1890,43 +1897,48 @@ func TestExecutorPrebuilds(t *testing.T) {
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
// 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)
require.NotZero(t, prebuild.LatestBuild.Deadline)
// Prebuilt workspaces should have an empty Deadline and MaxDeadline
// which is equivalent to 0001-01-01 00:00:00 +0000
require.Zero(t, prebuild.LatestBuild.Deadline)
require.Zero(t, prebuild.LatestBuild.MaxDeadline)
// When: the autobuild executor ticks *after* the deadline
next := prebuild.LatestBuild.Deadline.Time.Add(time.Minute)
clock.Set(next)
// When: the autobuild executor ticks *after* the TTL time (10:00 AM UTC)
next := clock.Now().Add(ttlTime).Add(time.Minute)
clock.Set(next) // 10:01 AM UTC
go func() {
tickCh <- next
}()
// Then: the prebuilt workspace should remain in a start transition
prebuildStats := <-statsCh
prebuildStats := testutil.RequireReceive(ctx, t, 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)
require.Zero(t, prebuild.LatestBuild.Deadline)
require.Zero(t, prebuild.LatestBuild.MaxDeadline)
// Given: a user claims the prebuilt workspace sometime later
clock.Set(clock.Now().Add(ttlTime))
clock.Set(clock.Now().Add(1 * time.Hour)) // 11:01 AM UTC
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
// Workspace deadline must be ttlTime from the time it is claimed (1:01 PM UTC)
require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime)))
// When: the autobuild executor ticks *after* the deadline
// When: the autobuild executor ticks *after* the TTL time (1:01 PM UTC)
next = workspace.LatestBuild.Deadline.Time.Add(time.Minute)
clock.Set(next)
clock.Set(next) // 1:02 PM UTC
go func() {
tickCh <- next
close(tickCh)
}()
// Then: the workspace should be stopped
workspaceStats := <-statsCh
workspaceStats := testutil.RequireReceive(ctx, t, statsCh)
require.Len(t, workspaceStats.Errors, 0)
require.Len(t, workspaceStats.Transitions, 1)
require.Contains(t, workspaceStats.Transitions, workspace.ID)
@@ -1941,158 +1953,125 @@ func TestExecutorPrebuilds(t *testing.T) {
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,
},
}
// 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))
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{},
// 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,
prometheus.NewRegistry(),
notificationsNoop,
api.AGPL.BuildUsageChecker,
)
var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db)
api.AGPL.PrebuildsClaimer.Store(&claimer)
),
},
LicenseOptions: &coderdenttest.LicenseOptions{
Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1},
},
})
// 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,
})
// Setup Prebuild reconciler
cache := files.New(prometheus.NewRegistry(), &coderdtest.FakeAuthorizer{})
reconciler := prebuilds.NewStoreReconciler(
db, pb, cache,
codersdk.PrebuildsConfig{},
logger,
clock,
prometheus.NewRegistry(),
notificationsNoop,
api.AGPL.BuildUsageChecker,
)
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)
// Set a template level Autostop schedule to trigger the autostop daily
coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) {
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)
})
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: 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)
// 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)
// Prebuilt workspaces should have an empty Deadline and MaxDeadline
// which is equivalent to 0001-01-01 00:00:00 +0000
require.Zero(t, prebuild.LatestBuild.Deadline)
require.Zero(t, prebuild.LatestBuild.MaxDeadline)
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)
}
// When: the autobuild executor ticks *after* the deadline (2024-01-02 0:00 UTC)
next := clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour).Add(time.Minute)
clock.Set(next) // 2024-01-02 0:01 UTC
go func() {
tickCh <- next
}()
clock.Set(next)
go func() {
tickCh <- next
}()
// Then: the prebuilt workspace should remain in a start transition
prebuildStats := testutil.RequireReceive(ctx, t, 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)
require.Zero(t, prebuild.LatestBuild.Deadline)
require.Zero(t, prebuild.LatestBuild.MaxDeadline)
// 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)
// Then: the claimed workspace should respect the next valid scheduled deadline (2024-01-03 0:00 UTC)
require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Truncate(24*time.Hour).Add(24*time.Hour)))
// 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)
// When: the autobuild executor ticks *after* the deadline (2024-01-03 0:00 UTC)
next = workspace.LatestBuild.Deadline.Time.Add(time.Minute)
clock.Set(next) // 2024-01-03 0:01 UTC
go func() {
tickCh <- next
close(tickCh)
}()
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)
})
}
// Then: the workspace should be stopped
workspaceStats := testutil.RequireReceive(ctx, t, 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.
// After being claimed, the workspace should be started according to the autostart schedule.
t.Run("AutostartScheduleOnlyTriggersAfterClaim", func(t *testing.T) {
t.Parallel()
@@ -2146,8 +2125,11 @@ func TestExecutorPrebuilds(t *testing.T) {
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)
// Template-level autostart config only defines allowed days for workspaces to autostart
// The actual autostart schedule is set at the workspace level
sched, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *")
require.NoError(t, err)
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}
})
@@ -2160,14 +2142,11 @@ func TestExecutorPrebuilds(t *testing.T) {
runningPrebuilds := getRunningPrebuilds(t, ctx, db, int(prebuildInstances))
require.Len(t, runningPrebuilds, int(prebuildInstances))
// Given: prebuilt workspace has autostart schedule daily at midnight
// Given: a running prebuilt workspace
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)
// Prebuilt workspaces should have an empty Autostart Schedule
require.Nil(t, prebuild.AutostartSchedule)
require.Nil(t, prebuild.NextStartAt)
// Given: prebuilt workspace is stopped
prebuild = coderdtest.MustTransitionWorkspace(t, client, prebuild.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop)
@@ -2181,32 +2160,32 @@ func TestExecutorPrebuilds(t *testing.T) {
}()
// Then: the prebuilt workspace should remain in a stop transition
prebuildStats := <-statsCh
prebuildStats := testutil.RequireReceive(ctx, t, 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)
require.Nil(t, prebuild.AutostartSchedule)
require.Nil(t, prebuild.NextStartAt)
// 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)
// Given: a user claims the prebuilt workspace with an Autostart schedule request
workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID, sched.String())
require.Equal(t, prebuild.ID, workspace.ID)
// Then: newly claimed workspace's AutostartSchedule and NextStartAt should be set
require.NotNil(t, workspace.AutostartSchedule)
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
@@ -2215,17 +2194,19 @@ func TestExecutorPrebuilds(t *testing.T) {
}()
// Then: the workspace should have a NextStartAt equal to the next autostart schedule
workspaceStats := <-statsCh
workspaceStats := testutil.RequireReceive(ctx, t, statsCh)
require.Len(t, workspaceStats.Errors, 0)
require.Len(t, workspaceStats.Transitions, 1)
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
require.NotNil(t, workspace.AutostartSchedule)
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.
// Prebuild workspaces should not transition to dormant or be deleted due to inactivity.
// This test verifies that both TimeTilDormantMillis and TimeTilDormantAutoDeleteMillis
// are ignored while the workspace is a prebuild. After the workspace is claimed,
// it should respect these inactivity thresholds accordingly.
t.Run("DormantOnlyAfterClaimed", func(t *testing.T) {
t.Parallel()
@@ -2276,13 +2257,15 @@ func TestExecutorPrebuilds(t *testing.T) {
// Setup user, template and template version with a preset with 1 prebuild instance
prebuildInstances := int32(1)
inactiveTTL := 2 * time.Hour
dormantTTL := 2 * time.Hour
deletionTTL := 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)
// Set a template level dormant TTL to trigger dormancy
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())
ctr.TimeTilDormantMillis = ptr.Ref[int64](dormantTTL.Milliseconds())
ctr.TimeTilDormantAutoDeleteMillis = ptr.Ref[int64](deletionTTL.Milliseconds())
})
presets, err := client.TemplateVersionPresets(ctx, version.ID)
require.NoError(t, err)
@@ -2296,41 +2279,68 @@ func TestExecutorPrebuilds(t *testing.T) {
// 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)
require.Nil(t, prebuild.DormantAt)
require.Nil(t, prebuild.DeletingAt)
// When: the autobuild executor ticks *after* the inactive TTL
// When: the autobuild executor ticks *after* the dormant TTL (10:00 AM UTC)
next := clock.Now().Add(dormantTTL).Add(time.Minute)
clock.Set(next) // 10:01 AM UTC
go func() {
tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute)
tickCh <- next
}()
// Then: the prebuilt workspace should remain in a start transition
prebuildStats := <-statsCh
prebuildStats := testutil.RequireReceive(ctx, t, 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)
require.Nil(t, prebuild.DormantAt)
require.Nil(t, prebuild.DeletingAt)
// Given: a user claims the prebuilt workspace sometime later
clock.Set(clock.Now().Add(inactiveTTL))
clock.Set(clock.Now().Add(1 * time.Hour)) // 11:01 AM UTC
workspace := claimPrebuild(t, ctx, client, userClient, user.Username, version, presets[0].ID)
require.Equal(t, prebuild.ID, workspace.ID)
require.Nil(t, prebuild.DormantAt)
// Then: the claimed workspace should have DormantAt and DeletingAt unset (nil),
// and LastUsedAt updated
require.Nil(t, workspace.DormantAt)
require.Nil(t, workspace.DeletingAt)
require.True(t, workspace.LastUsedAt.After(prebuild.LastUsedAt))
// When: the autobuild executor ticks *after* the inactive TTL
// When: the autobuild executor ticks *after* the dormant TTL (1:01 PM UTC)
next = clock.Now().Add(dormantTTL).Add(time.Minute)
clock.Set(next) // 1:02 PM UTC
go func() {
tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute)
close(tickCh)
tickCh <- next
}()
// Then: the workspace should transition to stopped state for breaching failure TTL
workspaceStats := <-statsCh
// Then: the workspace should transition to stopped state for breaching dormant TTL
workspaceStats := testutil.RequireReceive(ctx, t, 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)
coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.LatestBuild.ID)
workspace = coderdtest.MustWorkspace(t, client, workspace.ID)
require.Equal(t, codersdk.BuildReasonDormancy, workspace.LatestBuild.Reason)
require.Equal(t, codersdk.WorkspaceStatusStopped, workspace.LatestBuild.Status)
require.NotNil(t, workspace.DormantAt)
require.NotNil(t, workspace.DeletingAt)
// When: the autobuild executor ticks *after* the deletion TTL
go func() {
tickCh <- workspace.DeletingAt.Add(time.Minute)
}()
// Then: the workspace should be deleted
dormantWorkspaceStats := testutil.RequireReceive(ctx, t, statsCh)
require.Len(t, dormantWorkspaceStats.Errors, 0)
require.Len(t, dormantWorkspaceStats.Transitions, 1)
require.Contains(t, dormantWorkspaceStats.Transitions, workspace.ID)
require.Equal(t, database.WorkspaceTransitionDelete, dormantWorkspaceStats.Transitions[workspace.ID])
})
// Prebuild workspaces should not be deleted when the failure TTL is reached.
@@ -2390,8 +2400,8 @@ func TestExecutorPrebuilds(t *testing.T) {
failureTTL := 2 * time.Hour
version := coderdtest.CreateTemplateVersion(t, client, owner.OrganizationID, templateWithFailedResponseAndPresetsWithPrebuilds(prebuildInstances))
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
// Set a template level Failure TTL to trigger workspace deletion
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)
@@ -2400,7 +2410,6 @@ func TestExecutorPrebuilds(t *testing.T) {
// 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{
@@ -2427,7 +2436,7 @@ func TestExecutorPrebuilds(t *testing.T) {
}()
// Then: the prebuilt workspace should remain in a start transition
prebuildStats := <-statsCh
prebuildStats := testutil.RequireReceive(ctx, t, statsCh)
require.Len(t, prebuildStats.Errors, 0)
require.Len(t, prebuildStats.Transitions, 0)
require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition)
@@ -2437,50 +2446,46 @@ func TestExecutorPrebuilds(t *testing.T) {
}
func templateWithAgentAndPresetsWithPrebuilds(desiredInstances int32) *echo.Responses {
agent := &proto.Agent{
Name: "smith",
OperatingSystem: "linux",
Architecture: "i386",
}
resource := func(withAgent bool) *proto.Resource {
r := &proto.Resource{Type: "compute", Name: "main"}
if withAgent {
r.Agents = []*proto.Agent{agent}
}
return r
}
applyResponse := func(withAgent bool) *proto.Response {
return &proto.Response{
Type: &proto.Response_Apply{
Apply: &proto.ApplyComplete{
Resources: []*proto.Resource{resource(withAgent)},
},
},
}
}
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",
},
},
},
},
},
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},
}},
},
},
}},
ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{
proto.WorkspaceTransition_START: {applyResponse(true)},
proto.WorkspaceTransition_STOP: {applyResponse(false)},
},
}
}