fix: exclude prebuilt workspaces from lifecycle executor (#18762)

## Description

This PR updates the lifecycle executor to explicitly exclude prebuilt
workspaces from being considered for lifecycle operations such as
`autostart`, `autostop`, `dormancy`, `default TTL` and `failure TTL`.

Prebuilt workspaces (i.e., those owned by the prebuild system user) are
handled separately by the prebuild reconciliation loop. Including them
in the lifecycle executor could lead to unintended behavior such as
incorrect scheduling or state transitions.

## Changes

* Updated the lifecycle executor query
`GetWorkspacesEligibleForTransition` to exclude workspaces with
`owner_id = 'c42fdf75-3097-471c-8c33-fb52454d81c0'` (prebuilds).
* Added tests to verify prebuilt workspaces are not considered in:
  * Autostop
  * Autostart
  * Default TTL
  * Dormancy
  * Failure TTL

Fixes: https://github.com/coder/coder/issues/18740
Related to: https://github.com/coder/coder/issues/18658
This commit is contained in:
Susana Ferreira
2025-07-08 11:35:28 +01:00
committed by GitHub
parent 0118e75009
commit 211393a69c
14 changed files with 1204 additions and 18 deletions
+4 -2
View File
@@ -11366,12 +11366,14 @@ const docTemplate = `{
"enum": [
"initiator",
"autostart",
"autostop"
"autostop",
"dormancy"
],
"x-enum-varnames": [
"BuildReasonInitiator",
"BuildReasonAutostart",
"BuildReasonAutostop"
"BuildReasonAutostop",
"BuildReasonDormancy"
]
},
"codersdk.ChangePasswordWithOneTimePasscodeRequest": {
+3 -2
View File
@@ -10106,11 +10106,12 @@
},
"codersdk.BuildReason": {
"type": "string",
"enum": ["initiator", "autostart", "autostop"],
"enum": ["initiator", "autostart", "autostop", "dormancy"],
"x-enum-varnames": [
"BuildReasonInitiator",
"BuildReasonAutostart",
"BuildReasonAutostop"
"BuildReasonAutostop",
"BuildReasonDormancy"
]
},
"codersdk.ChangePasswordWithOneTimePasscodeRequest": {
+2
View File
@@ -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
+349
View File
@@ -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 prebuilds 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 prebuilds 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)
+22
View File
@@ -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()),
+6 -1
View File
@@ -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 {
+6 -1
View File
@@ -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
+2
View File
@@ -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
+6 -3
View File
@@ -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.
@@ -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.
> 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.
>
> 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.
>
> Were actively working to improve compatibility, but for now, please avoid using prebuilds with these features to ensure stability and expected behavior.
> Were 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.
@@ -26,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.
+1
View File
@@ -1049,6 +1049,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
| `initiator` |
| `autostart` |
| `autostop` |
| `dormancy` |
## codersdk.ChangePasswordWithOneTimePasscodeRequest
+794
View File
@@ -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
testutil.Eventually(ctx, t, func(context.Context) 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.IntervalSlow, "prebuilds not running")
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 prebuilds 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 prebuilds 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.
+2 -1
View File
@@ -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",
];
+3 -1
View File
@@ -75,14 +75,16 @@ export const getDisplayWorkspaceBuildStatus = (
export const getDisplayWorkspaceBuildInitiatedBy = (
build: TypesGen.WorkspaceBuild,
): string => {
): string | undefined => {
switch (build.reason) {
case "initiator":
return build.initiator_name;
case "autostart":
case "autostop":
case "dormancy":
return "Coder";
}
return undefined;
};
const getWorkspaceBuildDurationInSeconds = (