From 641506981d323f034c12aa6aa98cd2bc51e0aaf7 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 09:30:43 +0000 Subject: [PATCH 01/10] fix: prebuilds lifecycle parameters on creation and claim --- cli/schedule.go | 14 + coderd/database/querier.go | 17 + coderd/database/queries.sql.go | 44 +- coderd/database/queries/activitybump.sql | 2 + coderd/database/queries/prebuilds.sql | 10 +- coderd/database/queries/workspacebuilds.sql | 3 + coderd/database/queries/workspaces.sql | 29 +- .../provisionerdserver/provisionerdserver.go | 72 +- coderd/workspaces.go | 67 +- coderd/workspacestats/reporter.go | 51 +- enterprise/coderd/schedule/template.go | 12 +- enterprise/coderd/workspaces_test.go | 1057 ++++++++++------- 12 files changed, 877 insertions(+), 501 deletions(-) diff --git a/cli/schedule.go b/cli/schedule.go index 9ade82b9c4a36..f74d603d97282 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -157,6 +157,13 @@ func (r *RootCmd) scheduleStart() *serpent.Command { return err } + // Autostart configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostart configuration is not supported for prebuilt workspaces") + } + var schedStr *string if inv.Args[1] != "manual" { sched, err := parseCLISchedule(inv.Args[1:]...) @@ -205,6 +212,13 @@ func (r *RootCmd) scheduleStop() *serpent.Command { return err } + // Autostop configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild { + return xerrors.Errorf("autostop configuration is not supported for prebuilt workspaces") + } + var durMillis *int64 if inv.Args[1] != "manual" { dur, err := parseDuration(inv.Args[1]) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index a0f265e9658ce..bf403b0f6f13f 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -44,6 +44,8 @@ type sqlcQuerier interface { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop and not subject to activity bumping. // We only bump if the template has an activity bump duration set. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. @@ -640,18 +642,33 @@ type sqlcQuerier interface { UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop, not the lifecycle + // executor which handles autostart_schedule and next_start_at. UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop, not the lifecycle + // executor which handles deadline and max_deadline. UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop, not the lifecycle + // executor which handles dormant_at and deleting_at. UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop, not the lifecycle + // executor which handles next_start_at. UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error + // NOTE: This query should only be called for regular user workspaces. + // Prebuilds are managed by the reconciliation loop, not the lifecycle + // executor which handles regular workspace's TTL. UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 74cefd09359b0..b9e14be09d1e1 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -98,6 +98,8 @@ type ActivityBumpWorkspaceParams struct { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop and not subject to activity bumping. // We only bump if the template has an activity bump duration set. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. @@ -7122,7 +7124,15 @@ const claimPrebuiltWorkspace = `-- name: ClaimPrebuiltWorkspace :one UPDATE workspaces w SET owner_id = $1::uuid, name = $2::text, - updated_at = NOW() + updated_at = NOW(), + -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. + -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. + last_used_at = NOW(), + -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. + -- These fields should not be set on prebuilds, but we defensively reset them here to prevent + -- accidental dormancy or deletion by the lifecycle executor. + dormant_at = NULL, + deleting_at = NULL WHERE w.id IN ( SELECT p.id FROM workspace_prebuilds p @@ -19234,6 +19244,9 @@ type UpdateWorkspaceBuildDeadlineByIDParams struct { ID uuid.UUID `db:"id" json:"id"` } +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop, not the lifecycle +// executor which handles deadline and max_deadline. func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceBuildDeadlineByID, arg.Deadline, @@ -21187,6 +21200,9 @@ type UpdateWorkspaceAutostartParams struct { NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` } +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop, not the lifecycle +// executor which handles autostart_schedule and next_start_at. func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceAutostart, arg.ID, arg.AutostartSchedule, arg.NextStartAt) return err @@ -21244,6 +21260,9 @@ type UpdateWorkspaceDormantDeletingAtParams struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` } +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop, not the lifecycle +// executor which handles dormant_at and deleting_at. func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) { row := q.db.QueryRowContext(ctx, updateWorkspaceDormantDeletingAt, arg.ID, arg.DormantAt) var i WorkspaceTable @@ -21303,6 +21322,9 @@ type UpdateWorkspaceNextStartAtParams struct { NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` } +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop, not the lifecycle +// executor which handles next_start_at. func (q *sqlQuerier) UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceNextStartAt, arg.ID, arg.NextStartAt) return err @@ -21322,6 +21344,9 @@ type UpdateWorkspaceTTLParams struct { Ttl sql.NullInt64 `db:"ttl" json:"ttl"` } +// NOTE: This query should only be called for regular user workspaces. +// Prebuilds are managed by the reconciliation loop, not the lifecycle +// executor which handles regular workspace's TTL. func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err @@ -21338,8 +21363,11 @@ SET dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE template_id = $3 -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl ` @@ -21393,11 +21421,15 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C const updateWorkspacesTTLByTemplateID = `-- name: UpdateWorkspacesTTLByTemplateID :exec UPDATE - workspaces + workspaces SET - ttl = $2 + ttl = $2 WHERE - template_id = $1 + template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspacesTTLByTemplateIDParams struct { diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index 09349d29e5d06..e1a2dbfec9c8d 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -5,6 +5,8 @@ -- -- Max deadline is respected, and the deadline will never be bumped past it. -- The deadline will never decrease. +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop and not subject to activity bumping. -- name: ActivityBumpWorkspace :exec WITH latest AS ( SELECT diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index 37bff9487928e..1144601228df4 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -2,7 +2,15 @@ UPDATE workspaces w SET owner_id = @new_user_id::uuid, name = @new_name::text, - updated_at = NOW() + updated_at = NOW(), + -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. + -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. + last_used_at = NOW(), + -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. + -- These fields should not be set on prebuilds, but we defensively reset them here to prevent + -- accidental dormancy or deletion by the lifecycle executor. + dormant_at = NULL, + deleting_at = NULL WHERE w.id IN ( SELECT p.id FROM workspace_prebuilds p diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index be76b6642df1f..55d2f322a519b 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -135,6 +135,9 @@ WHERE id = $1; -- name: UpdateWorkspaceBuildDeadlineByID :exec +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop, not the lifecycle +-- executor which handles deadline and max_deadline. UPDATE workspace_builds SET diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index b6b4f2de0888f..70c45ef5aa05e 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -512,6 +512,9 @@ WHERE RETURNING *; -- name: UpdateWorkspaceAutostart :exec +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop, not the lifecycle +-- executor which handles autostart_schedule and next_start_at. UPDATE workspaces SET @@ -521,6 +524,9 @@ WHERE id = $1; -- name: UpdateWorkspaceNextStartAt :exec +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop, not the lifecycle +-- executor which handles next_start_at. UPDATE workspaces SET @@ -545,6 +551,9 @@ WHERE workspaces.id = batch.id; -- name: UpdateWorkspaceTTL :exec +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop, not the lifecycle +-- executor which handles regular workspace's TTL. UPDATE workspaces SET @@ -554,11 +563,15 @@ WHERE -- name: UpdateWorkspacesTTLByTemplateID :exec UPDATE - workspaces + workspaces SET - ttl = $2 + ttl = $2 WHERE - template_id = $1; + template_id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their TTL updated, as they are handled by the prebuilds + -- reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceLastUsedAt :exec UPDATE @@ -768,6 +781,9 @@ WHERE AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceDormantDeletingAt :one +-- NOTE: This query should only be called for regular user workspaces. +-- Prebuilds are managed by the reconciliation loop, not the lifecycle +-- executor which handles dormant_at and deleting_at. UPDATE workspaces SET @@ -805,8 +821,11 @@ SET dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE template_id = @template_id -AND - dormant_at IS NOT NULL + AND dormant_at IS NOT NULL + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- should not have their dormant or deleting at set, as these are handled by the + -- prebuilds reconciliation loop. + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING *; -- name: UpdateTemplateWorkspacesLastUsedAt :exec diff --git a/coderd/provisionerdserver/provisionerdserver.go b/coderd/provisionerdserver/provisionerdserver.go index d1b03cbd68a27..bba0bbd44ecce 100644 --- a/coderd/provisionerdserver/provisionerdserver.go +++ b/coderd/provisionerdserver/provisionerdserver.go @@ -1183,11 +1183,18 @@ func (s *server) FailJob(ctx context.Context, failJob *proto.FailedJob) (*proto. if err != nil { return xerrors.Errorf("update workspace build state: %w", err) } + + deadline := build.Deadline + maxDeadline := build.MaxDeadline + if workspace.IsPrebuild() { + deadline = time.Time{} + maxDeadline = time.Time{} + } err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: input.WorkspaceBuildID, UpdatedAt: s.timeNow(), - Deadline: build.Deadline, - MaxDeadline: build.MaxDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, }) if err != nil { return xerrors.Errorf("update workspace build deadline: %w", err) @@ -1860,38 +1867,47 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro return getWorkspaceError } - templateScheduleStore := *s.TemplateScheduleStore.Load() + // Prebuilt workspaces must not have Deadline or MaxDeadline set, + // as they are managed by the prebuild reconciliation loop, not the lifecycle executor + deadline := time.Time{} + maxDeadline := time.Time{} - autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ - Database: db, - TemplateScheduleStore: templateScheduleStore, - UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), - // `now` is used below to set the build completion time. - WorkspaceBuildCompletedAt: now, - Workspace: workspace.WorkspaceTable(), - // Allowed to be the empty string. - WorkspaceAutostart: workspace.AutostartSchedule.String, - }) - if err != nil { - return xerrors.Errorf("calculate auto stop: %w", err) - } + if !workspace.IsPrebuild() { + templateScheduleStore := *s.TemplateScheduleStore.Load() - if workspace.AutostartSchedule.Valid { - templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) + autoStop, err := schedule.CalculateAutostop(ctx, schedule.CalculateAutostopParams{ + Database: db, + TemplateScheduleStore: templateScheduleStore, + UserQuietHoursScheduleStore: *s.UserQuietHoursScheduleStore.Load(), + // `now` is used below to set the build completion time. + WorkspaceBuildCompletedAt: now, + Workspace: workspace.WorkspaceTable(), + // Allowed to be the empty string. + WorkspaceAutostart: workspace.AutostartSchedule.String, + }) if err != nil { - return xerrors.Errorf("get template schedule options: %w", err) + return xerrors.Errorf("calculate auto stop: %w", err) } - nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) - if err == nil { - err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ - ID: workspace.ID, - NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, - }) + if workspace.AutostartSchedule.Valid { + templateScheduleOptions, err := templateScheduleStore.Get(ctx, db, workspace.TemplateID) if err != nil { - return xerrors.Errorf("update workspace next start at: %w", err) + return xerrors.Errorf("get template schedule options: %w", err) + } + + nextStartAt, err := schedule.NextAllowedAutostart(now, workspace.AutostartSchedule.String, templateScheduleOptions) + if err == nil { + err = db.UpdateWorkspaceNextStartAt(ctx, database.UpdateWorkspaceNextStartAtParams{ + ID: workspace.ID, + NextStartAt: sql.NullTime{Valid: true, Time: nextStartAt.UTC()}, + }) + if err != nil { + return xerrors.Errorf("update workspace next start at: %w", err) + } } } + deadline = autoStop.Deadline + maxDeadline = autoStop.MaxDeadline } err = db.UpdateProvisionerJobWithCompleteByID(ctx, database.UpdateProvisionerJobWithCompleteByIDParams{ @@ -1917,8 +1933,8 @@ func (s *server) completeWorkspaceBuildJob(ctx context.Context, job database.Pro } err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ ID: workspaceBuild.ID, - Deadline: autoStop.Deadline, - MaxDeadline: autoStop.MaxDeadline, + Deadline: deadline, + MaxDeadline: maxDeadline, UpdatedAt: now, }) if err != nil { diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6da85c7608ca4..baed4f76cc189 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -664,9 +664,10 @@ func createWorkspace( } } + now := dbtime.Now() + // No prebuild found; regular flow. if claimedWorkspace == nil { - now := dbtime.Now() // Workspaces are created without any versions. minimumWorkspace, err := db.InsertWorkspace(ctx, database.InsertWorkspaceParams{ ID: uuid.New(), @@ -681,7 +682,7 @@ func createWorkspace( Ttl: dbTTL, // The workspaces page will sort by last used at, and it's useful to // have the newly created workspace at the top of the list! - LastUsedAt: dbtime.Now(), + LastUsedAt: now, AutomaticUpdates: dbAU, }) if err != nil { @@ -689,7 +690,24 @@ func createWorkspace( } workspaceID = minimumWorkspace.ID } else { - // Prebuild found! + // Prebuild found! Update lifecycle related parameters + err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ + ID: claimedWorkspace.ID, + AutostartSchedule: dbAutostartSchedule, + NextStartAt: nextStartAt, + }) + if err != nil { + return xerrors.Errorf("update claimed workspace Autostart: %w", err) + } + + err = db.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{ + ID: claimedWorkspace.ID, + Ttl: dbTTL, + }) + if err != nil { + return xerrors.Errorf("update claimed workspace TTL: %w", err) + } + workspaceID = claimedWorkspace.ID initiatorID = prebuildsClaimer.Initiator() } @@ -1072,6 +1090,17 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } + // Autostart configuration is not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ + Message: "Autostart is not supported for prebuilt workspaces", + Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + dbSched, err := validWorkspaceSchedule(req.Schedule) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1156,6 +1185,17 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } + // TTL updates are not supported for prebuilt workspaces. + // Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior + // defined per preset at the template level, not per workspace. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ + Message: "TTL updates are not supported for prebuilt workspaces", + Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.", + }) + return + } + var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { @@ -1272,6 +1312,16 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { return } + // Dormancy configuration is not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and are not subject to dormancy. + if oldWorkspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ + Message: "Dormancy configuration is not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces", + }) + return + } + // If the workspace is already in the desired state do nothing! if oldWorkspace.DormantAt.Valid == req.Dormant { rw.WriteHeader(http.StatusNotModified) @@ -1416,6 +1466,17 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return } + // Deadline extensions are not supported for prebuilt workspaces. + // Prebuilds are managed by the reconciliation loop and must always have + // Deadline and MaxDeadline unset. + if workspace.IsPrebuild() { + httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ + Message: "Deadline extension is not supported for prebuilt workspaces", + Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces", + }) + return + } + code := http.StatusOK resp := codersdk.Response{} diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index 58d177f1c2071..f6b8a8dd0953b 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -149,33 +149,36 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac return nil } - // check next autostart - var nextAutostart time.Time - if workspace.AutostartSchedule.String != "" { - templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) - // If the template schedule fails to load, just default to bumping - // without the next transition and log it. - switch { - case err == nil: - next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) - if allowed { - nextAutostart = next + // Prebuilds are not subject to activity-based deadline bumps + if !workspace.IsPrebuild() { + // check next autostart + var nextAutostart time.Time + if workspace.AutostartSchedule.String != "" { + templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) + // If the template schedule fails to load, just default to bumping + // without the next transition and log it. + switch { + case err == nil: + next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) + if allowed { + nextAutostart = next + } + case database.IsQueryCanceledError(err): + r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID)) + default: + r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID), + slog.Error(err), + ) } - case database.IsQueryCanceledError(err): - r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID)) - default: - r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID), - slog.Error(err), - ) } - } - // bump workspace activity - ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) + // bump workspace activity + ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) + } // bump workspace last_used_at r.opts.UsageTracker.Add(workspace.ID) diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 313268f2e39ad..3fc8487883aa8 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -205,7 +205,6 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S if opts.DefaultTTL != 0 { ttl = sql.NullInt64{Valid: true, Int64: int64(opts.DefaultTTL)} } - if err = tx.UpdateWorkspacesTTLByTemplateID(ctx, database.UpdateWorkspacesTTLByTemplateIDParams{ TemplateID: template.ID, Ttl: ttl, @@ -243,6 +242,10 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts := []time.Time{} for _, workspace := range workspaces { + // Skip prebuilt workspaces + if workspace.IsPrebuild() { + continue + } nextStartAt := time.Time{} if workspace.AutostartSchedule.Valid { next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule) @@ -255,7 +258,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts = append(nextStartAts, nextStartAt) } - //nolint:gocritic // We need to be able to update information about all workspaces. + //nolint:gocritic // We need to be able to update information about regular workspaces. if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{ IDs: workspaceIDs, NextStartAts: nextStartAts, @@ -335,6 +338,11 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err) } + // Skip lifecycle updates for prebuilt workspaces + if workspace.IsPrebuild() { + return nil + } + job, err := db.GetProvisionerJobByID(ctx, build.JobID) if err != nil { return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index f8fcddb005e19..dfa68fe62ab42 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -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,143 +1827,552 @@ 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() - // 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)) + cases := []struct { + name string + setDeadline bool + }{ + // Under normal operation, prebuild workspaces should not have any deadlines set. + // This case ensures that prebuilds are not subject to TTL before claim. + { + name: "PrebuildNoDeadlineBeforeClaim", + setDeadline: false, + }, + // Even if a prebuild workspace is incorrectly assigned a Deadline and MaxDeadline, + // these values must be ignored while in the prebuild state. + // The default TTL should only take effect after the workspace is claimed. + { + name: "PrebuildIgnoresDeadlineBeforeClaim", + setDeadline: true, + }, + } - // 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(), + 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, + 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) + 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) + // 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) { + 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, 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) + + // Note: This simulates a scenario where a prebuilt workspace incorrectly has + // a Deadline and MaxDeadline set (which should not happen in normal operation). + // Even if set, the deadline should be ignored for prebuilt workspaces + // as they are managed by the prebuilds reconciliation loop. + if tc.setDeadline { + prebuildBuild, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{ + WorkspaceID: prebuild.ID, + }) + require.NoError(t, err) + require.Len(t, prebuildBuild, 1) + + err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: prebuildBuild[0].ID, + Deadline: clock.Now().Add(ttlTime), // 10:00 AM UTC + MaxDeadline: clock.Now().Add(ttlTime), // 10:00 AM UTC + UpdatedAt: clock.Now(), // 08:00 AM UTC + }) + require.NoError(t, err) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + } + + // 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 + 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) + if !tc.setDeadline { + 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(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 (1:01 PM UTC) + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime))) + + // When: the autobuild executor ticks *after* the TTL time (1:01 PM UTC) + next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) // 1:02 PM UTC + 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 + setAutostopSchedule bool + }{ + // Under normal operation, prebuild workspaces should not have any Autostop schedule applied. + // This case ensures that prebuilds are not subject to Autostart scheduling before claim. + { + name: "PrebuildNoAutostopScheduleBeforeClaim", + setAutostopSchedule: false, + }, + // Even if a prebuild workspace is incorrectly assigned an Autostop schedule, + // these values must be ignored while in the prebuild state. + // The Autostop schedule should only take effect after the workspace is claimed. + { + name: "PrebuildIgnoresAutostopScheduleBeforeClaim", + setAutostopSchedule: true, + }, + } + + 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, + 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) + + // 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) + // 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) + + // Note: This simulates a scenario where a prebuilt workspace incorrectly has + // a Deadline and MaxDeadline set (which should not happen in normal operation). + // Even if set, the deadline should be ignored for prebuilt workspaces + // as they are managed by the prebuilds reconciliation loop. + if tc.setAutostopSchedule { + prebuildBuild, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{ + WorkspaceID: prebuild.ID, + }) + require.NoError(t, err) + require.Len(t, prebuildBuild, 1) + err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ + ID: prebuildBuild[0].ID, + Deadline: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // 2024-01-02 0:00 UTC + MaxDeadline: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // 2024-01-02 0:00 UTC + UpdatedAt: clock.Now(), // 2024-01-01 8:00 UTC + }) + require.NoError(t, err) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + } + + // 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 + }() + + // 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) + if !tc.setAutostopSchedule { + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + } + + // 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))) + + // 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) + }() + + // 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. + // After being claimed, the workspace should be started according to the autostart schedule. + t.Run("AutostartScheduleOnlyTriggersAfterClaim", func(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + setAutostart bool + }{ + // Under normal operation, prebuild workspaces should not have any Autostart schedule applied. + // This case ensures that prebuilds are not subject to Autostart scheduling before claim. + { + name: "PrebuildNoAutostartScheduleBeforeClaim", + setAutostart: false, }, - LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + // Even if a prebuild workspace is incorrectly assigned an Autostart schedule, + // these values must be ignored while in the prebuild state. + // The Autostart schedule should only take effect after the workspace is claimed. + { + name: "PrebuildIgnoresAutostartScheduleBeforeClaim", + setAutostart: true, }, - }) + } - // 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) + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() - // 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) + // Set the clock to dbtime.Now() to match the workspace build's CreatedAt + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) - // 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)) + // 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}, + }, + }) - // 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) + // 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) - // When: the autobuild executor ticks *after* the deadline - next := prebuild.LatestBuild.Deadline.Time.Add(time.Minute) - clock.Set(next) - go func() { - tickCh <- next - }() + // 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) + // 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) { + 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) - // 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: 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 + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + // Prebuilt workspaces should have an empty Autostart Schedule + require.Nil(t, prebuild.AutostartSchedule) + require.Nil(t, prebuild.NextStartAt) + + // Note: This simulates a scenario where a prebuilt workspace incorrectly has + // an autostart schedule set (which should not happen in normal operation). + // Even if set, the autostart schedule should be ignored for prebuilt workspaces + // as they are managed by the prebuilds reconciliation loop. + if tc.setAutostart { + err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ + ID: prebuild.ID, + AutostartSchedule: sql.NullString{ + String: sched.String(), + Valid: true, + }, + NextStartAt: sql.NullTime{ + Time: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // Next midnight + Valid: true, + }, + }) + require.NoError(t, err) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) + } + + // 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) + if !tc.setAutostart { + require.Nil(t, prebuild.AutostartSchedule) + require.Nil(t, prebuild.NextStartAt) + } - // 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))) + // 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)) - // 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) - }() + // 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) - // 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) + // Given: workspace is stopped + workspace = coderdtest.MustTransitionWorkspace(t, client, workspace.ID, codersdk.WorkspaceTransitionStart, codersdk.WorkspaceTransitionStop) + coderdtest.AwaitWorkspaceBuildJobCompleted(t, client, workspace.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 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.AutostartSchedule) + require.NotNil(t, workspace.NextStartAt) + require.Equal(t, sched.Next(clock.Now()), workspace.NextStartAt.UTC()) + }) + } }) - // 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) { + // 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() cases := []struct { - name string - isClaimedBeforeDeadline bool + name string + setDormantFields bool }{ - // If the prebuild is claimed before the scheduled deadline, - // the claimed workspace should inherit and respect that same deadline. + // Under normal operation, prebuild workspaces should not be marked as dormant + // or scheduled for deletion. + // This case ensures these fields remain unset before claim. { - name: "ClaimedBeforeDeadline_UsesSameDeadline", - isClaimedBeforeDeadline: true, + name: "PrebuildNoDormantOrDeleteBeforeClaim", + setDormantFields: false, }, - // 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). + // Even if a prebuild workspace is incorrectly marked as dormant and assigned a deletion deadline, + // these fields must be ignored while the workspace remains unclaimed. + // After the workspace is claimed, dormancy and deletion should only be applied + // once the configured inactivity and deletion thresholds are actually reached. { - name: "ClaimedAfterDeadline_SchedulesForNextDay", - isClaimedBeforeDeadline: false, + name: "PrebuildIgnoresDormantAndDeleteBeforeClaim", + setDormantFields: true, }, } @@ -1965,9 +2381,9 @@ func TestExecutorPrebuilds(t *testing.T) { 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 + // Set the clock to dbtime.Now() to match the workspace's LastUsedAt clock := quartz.NewMock(t) - clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) + clock.Set(dbtime.Now()) // Setup ctx := testutil.Context(t, testutil.WaitSuperLong) @@ -1992,7 +2408,9 @@ func TestExecutorPrebuilds(t *testing.T) { ), }, LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, }, }) @@ -2012,43 +2430,50 @@ func TestExecutorPrebuilds(t *testing.T) { // Setup user, template and template version with a preset with 1 prebuild instance prebuildInstances := int32(1) + 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 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, - }) + 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) require.Len(t, presets, 1) - // Given: Reconciliation loop runs and starts prebuilt workspace + // 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 + // 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) - - 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) + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) + + // Note: This simulates a scenario where a prebuilt workspace incorrectly has + // the dormant and deleting fields set (which should not happen in normal operation). + // Even if set, the dormancy and deletion should be ignored for prebuilt workspaces + // as they are managed by the prebuilds reconciliation loop. + if tc.setDormantFields { + _, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ + ID: prebuild.ID, + DormantAt: sql.NullTime{ + Time: clock.Now(), + Valid: true, + }, + }) + require.NoError(t, err) + prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) } - clock.Set(next) + // When: the autobuild executor ticks *after* the dormant TTL go func() { - tickCh <- next + tickCh <- prebuild.LastUsedAt.Add(dormantTTL).Add(time.Minute) }() // Then: the prebuilt workspace should remain in a start transition @@ -2058,279 +2483,52 @@ func TestExecutorPrebuilds(t *testing.T) { require.Equal(t, codersdk.WorkspaceTransitionStart, prebuild.LatestBuild.Transition) prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) require.Equal(t, codersdk.BuildReasonInitiator, prebuild.LatestBuild.Reason) + if !tc.setDormantFields { + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) + } // 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 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)) - 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) + // When: the autobuild executor ticks *after* the dormant TTL go func() { - tickCh <- next - close(tickCh) + tickCh <- workspace.LastUsedAt.Add(dormantTTL).Add(time.Minute) }() - // Then: the workspace should be stopped + // Then: the workspace should transition to stopped state for breaching dormant 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.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, - 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) - 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, - 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) - 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) + 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 inactive TTL - go func() { - tickCh <- prebuild.LastUsedAt.Add(inactiveTTL).Add(time.Minute) - close(tickCh) - }() + // When: the autobuild executor ticks *after* the deletion TTL + go func() { + tickCh <- workspace.DeletingAt.Add(time.Minute) + }() - // 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) + // Then: the workspace should be deleted + dormantWorkspaceStats := <-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 +2588,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 +2598,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{ @@ -2437,50 +2634,46 @@ func TestExecutorPrebuilds(t *testing.T) { } 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, - }, - }, - }, - }, + 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)}, }, }, - }, - 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", - }, - }, - }, - }, - }, + } + } + + 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}, + }}, }, }, + }}, + ProvisionApplyMap: map[proto.WorkspaceTransition][]*proto.Response{ + proto.WorkspaceTransition_START: {applyResponse(true)}, + proto.WorkspaceTransition_STOP: {applyResponse(false)}, }, } } From e322671ae3c96f556bfb8a90cb97994bbf87b397 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 17:05:21 +0000 Subject: [PATCH 02/10] refactor: remove CLI schedule changes --- cli/schedule.go | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/cli/schedule.go b/cli/schedule.go index f74d603d97282..9ade82b9c4a36 100644 --- a/cli/schedule.go +++ b/cli/schedule.go @@ -157,13 +157,6 @@ func (r *RootCmd) scheduleStart() *serpent.Command { return err } - // Autostart configuration is not supported for prebuilt workspaces. - // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior - // defined per preset at the template level, not per workspace. - if workspace.IsPrebuild { - return xerrors.Errorf("autostart configuration is not supported for prebuilt workspaces") - } - var schedStr *string if inv.Args[1] != "manual" { sched, err := parseCLISchedule(inv.Args[1:]...) @@ -212,13 +205,6 @@ func (r *RootCmd) scheduleStop() *serpent.Command { return err } - // Autostop configuration is not supported for prebuilt workspaces. - // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior - // defined per preset at the template level, not per workspace. - if workspace.IsPrebuild { - return xerrors.Errorf("autostop configuration is not supported for prebuilt workspaces") - } - var durMillis *int64 if inv.Args[1] != "manual" { dur, err := parseDuration(inv.Args[1]) From 85d24ab5e02c00df3f2a6b745696aa96c645c2ad Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 17:29:50 +0000 Subject: [PATCH 03/10] refactor: remove activity bump changes --- coderd/database/querier.go | 2 - coderd/database/queries.sql.go | 2 - coderd/database/queries/activitybump.sql | 2 - coderd/workspacestats/reporter.go | 51 +++++++++++------------- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index bf403b0f6f13f..eb28442134c0e 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -44,8 +44,6 @@ type sqlcQuerier interface { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop and not subject to activity bumping. // We only bump if the template has an activity bump duration set. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index b9e14be09d1e1..23ba0e65d6fd8 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -98,8 +98,6 @@ type ActivityBumpWorkspaceParams struct { // // Max deadline is respected, and the deadline will never be bumped past it. // The deadline will never decrease. -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop and not subject to activity bumping. // We only bump if the template has an activity bump duration set. // We only bump if the raw interval is positive and non-zero. // We only bump if workspace shutdown is manual. diff --git a/coderd/database/queries/activitybump.sql b/coderd/database/queries/activitybump.sql index e1a2dbfec9c8d..09349d29e5d06 100644 --- a/coderd/database/queries/activitybump.sql +++ b/coderd/database/queries/activitybump.sql @@ -5,8 +5,6 @@ -- -- Max deadline is respected, and the deadline will never be bumped past it. -- The deadline will never decrease. --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop and not subject to activity bumping. -- name: ActivityBumpWorkspace :exec WITH latest AS ( SELECT diff --git a/coderd/workspacestats/reporter.go b/coderd/workspacestats/reporter.go index f6b8a8dd0953b..58d177f1c2071 100644 --- a/coderd/workspacestats/reporter.go +++ b/coderd/workspacestats/reporter.go @@ -149,37 +149,34 @@ func (r *Reporter) ReportAgentStats(ctx context.Context, now time.Time, workspac return nil } - // Prebuilds are not subject to activity-based deadline bumps - if !workspace.IsPrebuild() { - // check next autostart - var nextAutostart time.Time - if workspace.AutostartSchedule.String != "" { - templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) - // If the template schedule fails to load, just default to bumping - // without the next transition and log it. - switch { - case err == nil: - next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) - if allowed { - nextAutostart = next - } - case database.IsQueryCanceledError(err): - r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID)) - default: - r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", - slog.F("workspace_id", workspace.ID), - slog.F("template_id", workspace.TemplateID), - slog.Error(err), - ) + // check next autostart + var nextAutostart time.Time + if workspace.AutostartSchedule.String != "" { + templateSchedule, err := (*(r.opts.TemplateScheduleStore.Load())).Get(ctx, r.opts.Database, workspace.TemplateID) + // If the template schedule fails to load, just default to bumping + // without the next transition and log it. + switch { + case err == nil: + next, allowed := schedule.NextAutostart(now, workspace.AutostartSchedule.String, templateSchedule) + if allowed { + nextAutostart = next } + case database.IsQueryCanceledError(err): + r.opts.Logger.Debug(ctx, "query canceled while loading template schedule", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID)) + default: + r.opts.Logger.Error(ctx, "failed to load template schedule bumping activity, defaulting to bumping by 60min", + slog.F("workspace_id", workspace.ID), + slog.F("template_id", workspace.TemplateID), + slog.Error(err), + ) } - - // bump workspace activity - ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) } + // bump workspace activity + ActivityBumpWorkspace(ctx, r.opts.Logger.Named("activity_bump"), r.opts.Database, workspace.ID, nextAutostart) + // bump workspace last_used_at r.opts.UsageTracker.Add(workspace.ID) From 04a481dde2d24d9772b2d546e3865431d2ed514e Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 18:01:30 +0000 Subject: [PATCH 04/10] refactor: remove lifecycle API endpoint changes --- coderd/workspaces.go | 43 ------------------------------------------- 1 file changed, 43 deletions(-) diff --git a/coderd/workspaces.go b/coderd/workspaces.go index baed4f76cc189..5f44f27540521 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1090,17 +1090,6 @@ func (api *API) putWorkspaceAutostart(rw http.ResponseWriter, r *http.Request) { return } - // Autostart configuration is not supported for prebuilt workspaces. - // Prebuild lifecycle is managed by the reconciliation loop, with scheduling behavior - // defined per preset at the template level, not per workspace. - if workspace.IsPrebuild() { - httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ - Message: "Autostart is not supported for prebuilt workspaces", - Detail: "Prebuilt workspace scheduling is configured per preset at the template level. Workspace-level overrides are not supported.", - }) - return - } - dbSched, err := validWorkspaceSchedule(req.Schedule) if err != nil { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -1185,17 +1174,6 @@ func (api *API) putWorkspaceTTL(rw http.ResponseWriter, r *http.Request) { return } - // TTL updates are not supported for prebuilt workspaces. - // Prebuild lifecycle is managed by the reconciliation loop, with TTL behavior - // defined per preset at the template level, not per workspace. - if workspace.IsPrebuild() { - httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ - Message: "TTL updates are not supported for prebuilt workspaces", - Detail: "Prebuilt workspace TTL is configured per preset at the template level. Workspace-level overrides are not supported.", - }) - return - } - var dbTTL sql.NullInt64 err := api.Database.InTx(func(s database.Store) error { @@ -1312,16 +1290,6 @@ func (api *API) putWorkspaceDormant(rw http.ResponseWriter, r *http.Request) { return } - // Dormancy configuration is not supported for prebuilt workspaces. - // Prebuilds are managed by the reconciliation loop and are not subject to dormancy. - if oldWorkspace.IsPrebuild() { - httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ - Message: "Dormancy configuration is not supported for prebuilt workspaces", - Detail: "Prebuilt workspaces are not subject to dormancy. Dormancy behavior is only applicable to regular workspaces", - }) - return - } - // If the workspace is already in the desired state do nothing! if oldWorkspace.DormantAt.Valid == req.Dormant { rw.WriteHeader(http.StatusNotModified) @@ -1466,17 +1434,6 @@ func (api *API) putExtendWorkspace(rw http.ResponseWriter, r *http.Request) { return } - // Deadline extensions are not supported for prebuilt workspaces. - // Prebuilds are managed by the reconciliation loop and must always have - // Deadline and MaxDeadline unset. - if workspace.IsPrebuild() { - httpapi.Write(ctx, rw, http.StatusUnprocessableEntity, codersdk.Response{ - Message: "Deadline extension is not supported for prebuilt workspaces", - Detail: "Prebuilt workspaces do not support user deadline modifications. Deadline extension is only applicable to regular workspaces", - }) - return - } - code := http.StatusOK resp := codersdk.Response{} From 2761b7e5526d7c062648d99c3c3873f0631c3c5d Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 18:24:07 +0000 Subject: [PATCH 05/10] refactor: remove template lifecycle update changes --- coderd/database/queries.sql.go | 11 ++--------- coderd/database/queries/workspaces.sql | 13 +++---------- enterprise/coderd/schedule/template.go | 11 +---------- 3 files changed, 6 insertions(+), 29 deletions(-) diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 23ba0e65d6fd8..cba0a4a88d2f0 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -21361,11 +21361,8 @@ SET dormant_at = CASE WHEN $2::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN $2::timestamptz ELSE dormant_at END WHERE template_id = $3 - AND dormant_at IS NOT NULL - -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) - -- should not have their dormant or deleting at set, as these are handled by the - -- prebuilds reconciliation loop. - AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID +AND + dormant_at IS NOT NULL RETURNING id, created_at, updated_at, owner_id, organization_id, template_id, deleted, name, autostart_schedule, ttl, last_used_at, dormant_at, deleting_at, automatic_updates, favorite, next_start_at, group_acl, user_acl ` @@ -21424,10 +21421,6 @@ SET ttl = $2 WHERE template_id = $1 - -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) - -- should not have their TTL updated, as they are handled by the prebuilds - -- reconciliation loop. - AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspacesTTLByTemplateIDParams struct { diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index 70c45ef5aa05e..c1b0a1e9f9e50 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -567,11 +567,7 @@ UPDATE SET ttl = $2 WHERE - template_id = $1 - -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) - -- should not have their TTL updated, as they are handled by the prebuilds - -- reconciliation loop. - AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; + template_id = $1; -- name: UpdateWorkspaceLastUsedAt :exec UPDATE @@ -821,11 +817,8 @@ SET dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END WHERE template_id = @template_id - AND dormant_at IS NOT NULL - -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) - -- should not have their dormant or deleting at set, as these are handled by the - -- prebuilds reconciliation loop. - AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID +AND + dormant_at IS NOT NULL RETURNING *; -- name: UpdateTemplateWorkspacesLastUsedAt :exec diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 3fc8487883aa8..203de46db4168 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -242,10 +242,6 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts := []time.Time{} for _, workspace := range workspaces { - // Skip prebuilt workspaces - if workspace.IsPrebuild() { - continue - } nextStartAt := time.Time{} if workspace.AutostartSchedule.Valid { next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule) @@ -258,7 +254,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S nextStartAts = append(nextStartAts, nextStartAt) } - //nolint:gocritic // We need to be able to update information about regular workspaces. + //nolint:gocritic // We need to be able to update information about all workspaces. if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{ IDs: workspaceIDs, NextStartAts: nextStartAts, @@ -338,11 +334,6 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err) } - // Skip lifecycle updates for prebuilt workspaces - if workspace.IsPrebuild() { - return nil - } - job, err := db.GetProvisionerJobByID(ctx, build.JobID) if err != nil { return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err) From 4c847c9af294d5c726cdc8b28101719bc239b4b6 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Fri, 8 Aug 2025 19:02:03 +0000 Subject: [PATCH 06/10] fix: add database constraints to prevent invalid lifecycle fields on prebuilt workspaces --- coderd/database/querier.go | 15 - coderd/database/queries.sql.go | 41 +- coderd/database/queries/workspacebuilds.sql | 13 +- coderd/database/queries/workspaces.sql | 34 +- enterprise/coderd/workspaces_test.go | 1105 ++++++++----------- 5 files changed, 510 insertions(+), 698 deletions(-) diff --git a/coderd/database/querier.go b/coderd/database/querier.go index eb28442134c0e..a0f265e9658ce 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -640,33 +640,18 @@ type sqlcQuerier interface { UpdateWorkspaceAgentStartupByID(ctx context.Context, arg UpdateWorkspaceAgentStartupByIDParams) error UpdateWorkspaceAppHealthByID(ctx context.Context, arg UpdateWorkspaceAppHealthByIDParams) error UpdateWorkspaceAutomaticUpdates(ctx context.Context, arg UpdateWorkspaceAutomaticUpdatesParams) error - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop, not the lifecycle - // executor which handles autostart_schedule and next_start_at. UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error UpdateWorkspaceBuildAITaskByID(ctx context.Context, arg UpdateWorkspaceBuildAITaskByIDParams) error UpdateWorkspaceBuildCostByID(ctx context.Context, arg UpdateWorkspaceBuildCostByIDParams) error - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop, not the lifecycle - // executor which handles deadline and max_deadline. UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error UpdateWorkspaceBuildProvisionerStateByID(ctx context.Context, arg UpdateWorkspaceBuildProvisionerStateByIDParams) error UpdateWorkspaceDeletedByID(ctx context.Context, arg UpdateWorkspaceDeletedByIDParams) error - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop, not the lifecycle - // executor which handles dormant_at and deleting_at. UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) UpdateWorkspaceLastUsedAt(ctx context.Context, arg UpdateWorkspaceLastUsedAtParams) error - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop, not the lifecycle - // executor which handles next_start_at. UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error // This allows editing the properties of a workspace proxy. UpdateWorkspaceProxy(ctx context.Context, arg UpdateWorkspaceProxyParams) (WorkspaceProxy, error) UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error - // NOTE: This query should only be called for regular user workspaces. - // Prebuilds are managed by the reconciliation loop, not the lifecycle - // executor which handles regular workspace's TTL. UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index cba0a4a88d2f0..845bc4d8c1b6b 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -19232,7 +19232,15 @@ SET deadline = $1::timestamptz, max_deadline = $2::timestamptz, updated_at = $3::timestamptz -WHERE id = $4::uuid +FROM + workspaces +WHERE + workspace_builds.id = $4::uuid + AND workspace_builds.workspace_id = workspaces.id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- deadline and max_deadline + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceBuildDeadlineByIDParams struct { @@ -19242,9 +19250,6 @@ type UpdateWorkspaceBuildDeadlineByIDParams struct { ID uuid.UUID `db:"id" json:"id"` } -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop, not the lifecycle -// executor which handles deadline and max_deadline. func (q *sqlQuerier) UpdateWorkspaceBuildDeadlineByID(ctx context.Context, arg UpdateWorkspaceBuildDeadlineByIDParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceBuildDeadlineByID, arg.Deadline, @@ -21190,6 +21195,10 @@ SET next_start_at = $3 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- autostart_schedule and next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceAutostartParams struct { @@ -21198,9 +21207,6 @@ type UpdateWorkspaceAutostartParams struct { NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` } -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop, not the lifecycle -// executor which handles autostart_schedule and next_start_at. func (q *sqlQuerier) UpdateWorkspaceAutostart(ctx context.Context, arg UpdateWorkspaceAutostartParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceAutostart, arg.ID, arg.AutostartSchedule, arg.NextStartAt) return err @@ -21249,6 +21255,10 @@ FROM WHERE workspaces.id = $1 AND templates.id = workspaces.template_id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- dormant_at and deleting_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING workspaces.id, workspaces.created_at, workspaces.updated_at, workspaces.owner_id, workspaces.organization_id, workspaces.template_id, workspaces.deleted, workspaces.name, workspaces.autostart_schedule, workspaces.ttl, workspaces.last_used_at, workspaces.dormant_at, workspaces.deleting_at, workspaces.automatic_updates, workspaces.favorite, workspaces.next_start_at, workspaces.group_acl, workspaces.user_acl ` @@ -21258,9 +21268,6 @@ type UpdateWorkspaceDormantDeletingAtParams struct { DormantAt sql.NullTime `db:"dormant_at" json:"dormant_at"` } -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop, not the lifecycle -// executor which handles dormant_at and deleting_at. func (q *sqlQuerier) UpdateWorkspaceDormantDeletingAt(ctx context.Context, arg UpdateWorkspaceDormantDeletingAtParams) (WorkspaceTable, error) { row := q.db.QueryRowContext(ctx, updateWorkspaceDormantDeletingAt, arg.ID, arg.DormantAt) var i WorkspaceTable @@ -21313,6 +21320,10 @@ SET next_start_at = $2 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceNextStartAtParams struct { @@ -21320,9 +21331,6 @@ type UpdateWorkspaceNextStartAtParams struct { NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` } -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop, not the lifecycle -// executor which handles next_start_at. func (q *sqlQuerier) UpdateWorkspaceNextStartAt(ctx context.Context, arg UpdateWorkspaceNextStartAtParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceNextStartAt, arg.ID, arg.NextStartAt) return err @@ -21335,6 +21343,10 @@ SET ttl = $2 WHERE id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- ttl + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID ` type UpdateWorkspaceTTLParams struct { @@ -21342,9 +21354,6 @@ type UpdateWorkspaceTTLParams struct { Ttl sql.NullInt64 `db:"ttl" json:"ttl"` } -// NOTE: This query should only be called for regular user workspaces. -// Prebuilds are managed by the reconciliation loop, not the lifecycle -// executor which handles regular workspace's TTL. func (q *sqlQuerier) UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error { _, err := q.db.ExecContext(ctx, updateWorkspaceTTL, arg.ID, arg.Ttl) return err diff --git a/coderd/database/queries/workspacebuilds.sql b/coderd/database/queries/workspacebuilds.sql index 55d2f322a519b..0b44c459e32b9 100644 --- a/coderd/database/queries/workspacebuilds.sql +++ b/coderd/database/queries/workspacebuilds.sql @@ -135,16 +135,21 @@ WHERE id = $1; -- name: UpdateWorkspaceBuildDeadlineByID :exec --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop, not the lifecycle --- executor which handles deadline and max_deadline. UPDATE workspace_builds SET deadline = @deadline::timestamptz, max_deadline = @max_deadline::timestamptz, updated_at = @updated_at::timestamptz -WHERE id = @id::uuid; +FROM + workspaces +WHERE + workspace_builds.id = @id::uuid + AND workspace_builds.workspace_id = workspaces.id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- deadline and max_deadline + AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceBuildProvisionerStateByID :exec UPDATE diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index c1b0a1e9f9e50..8b9a9e3076555 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -512,27 +512,29 @@ WHERE RETURNING *; -- name: UpdateWorkspaceAutostart :exec --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop, not the lifecycle --- executor which handles autostart_schedule and next_start_at. UPDATE workspaces SET autostart_schedule = $2, next_start_at = $3 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- autostart_schedule and next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceNextStartAt :exec --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop, not the lifecycle --- executor which handles next_start_at. UPDATE workspaces SET next_start_at = $2 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- next_start_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: BatchUpdateWorkspaceNextStartAt :exec UPDATE @@ -551,15 +553,16 @@ WHERE workspaces.id = batch.id; -- name: UpdateWorkspaceTTL :exec --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop, not the lifecycle --- executor which handles regular workspace's TTL. UPDATE workspaces SET ttl = $2 WHERE - id = $1; + id = $1 + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- ttl + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspacesTTLByTemplateID :exec UPDATE @@ -777,9 +780,6 @@ WHERE AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID; -- name: UpdateWorkspaceDormantDeletingAt :one --- NOTE: This query should only be called for regular user workspaces. --- Prebuilds are managed by the reconciliation loop, not the lifecycle --- executor which handles dormant_at and deleting_at. UPDATE workspaces SET @@ -803,6 +803,10 @@ FROM WHERE workspaces.id = $1 AND templates.id = workspaces.template_id + -- Prebuilt workspaces (identified by having the prebuilds system user as owner_id) + -- are managed by the reconciliation loop, not the lifecycle executor which handles + -- dormant_at and deleting_at + AND owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID RETURNING workspaces.*; diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index dfa68fe62ab42..feabc685cdafe 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1831,169 +1831,120 @@ func TestPrebuildsAutobuild(t *testing.T) { t.Run("DefaultTTLOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() - cases := []struct { - name string - setDeadline bool - }{ - // Under normal operation, prebuild workspaces should not have any deadlines set. - // This case ensures that prebuilds are not subject to TTL before claim. - { - name: "PrebuildNoDeadlineBeforeClaim", - setDeadline: 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)) + + // 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, + ), }, - // Even if a prebuild workspace is incorrectly assigned a Deadline and MaxDeadline, - // these values must be ignored while in the prebuild state. - // The default TTL should only take effect after the workspace is claimed. - { - name: "PrebuildIgnoresDeadlineBeforeClaim", - setDeadline: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, - } + }) - 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, + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) - // 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) - 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) - // 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) { - 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, 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) - - // Note: This simulates a scenario where a prebuilt workspace incorrectly has - // a Deadline and MaxDeadline set (which should not happen in normal operation). - // Even if set, the deadline should be ignored for prebuilt workspaces - // as they are managed by the prebuilds reconciliation loop. - if tc.setDeadline { - prebuildBuild, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{ - WorkspaceID: prebuild.ID, - }) - require.NoError(t, err) - require.Len(t, prebuildBuild, 1) - - err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: prebuildBuild[0].ID, - Deadline: clock.Now().Add(ttlTime), // 10:00 AM UTC - MaxDeadline: clock.Now().Add(ttlTime), // 10:00 AM UTC - UpdatedAt: clock.Now(), // 08:00 AM UTC - }) - require.NoError(t, err) - prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) - } + // 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) + // 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) { + 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) - // 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 - 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) - if !tc.setDeadline { - require.Zero(t, prebuild.LatestBuild.Deadline) - require.Zero(t, prebuild.LatestBuild.MaxDeadline) - } + // 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 user claims the prebuilt workspace sometime later - 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 (1:01 PM UTC) - require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime))) - - // When: the autobuild executor ticks *after* the TTL time (1:01 PM UTC) - next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) - clock.Set(next) // 1:02 PM UTC - 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) - }) - } + // 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) + + // 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 + 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(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 (1:01 PM UTC) + require.True(t, workspace.LatestBuild.Deadline.Time.Equal(clock.Now().Add(ttlTime))) + + // When: the autobuild executor ticks *after* the TTL time (1:01 PM UTC) + next = workspace.LatestBuild.Deadline.Time.Add(time.Minute) + clock.Set(next) // 1:02 PM UTC + 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. @@ -2002,168 +1953,120 @@ func TestPrebuildsAutobuild(t *testing.T) { t.Run("AutostopScheduleOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() - cases := []struct { - name string - setAutostopSchedule bool - }{ - // Under normal operation, prebuild workspaces should not have any Autostop schedule applied. - // This case ensures that prebuilds are not subject to Autostart scheduling before claim. - { - name: "PrebuildNoAutostopScheduleBeforeClaim", - setAutostopSchedule: 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)) + + // 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, + ), }, - // Even if a prebuild workspace is incorrectly assigned an Autostop schedule, - // these values must be ignored while in the prebuild state. - // The Autostop schedule should only take effect after the workspace is claimed. - { - name: "PrebuildIgnoresAutostopScheduleBeforeClaim", - setAutostopSchedule: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, - } + }) - 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, + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) - // 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, - }) + // 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) - - // 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) - // 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) - - // Note: This simulates a scenario where a prebuilt workspace incorrectly has - // a Deadline and MaxDeadline set (which should not happen in normal operation). - // Even if set, the deadline should be ignored for prebuilt workspaces - // as they are managed by the prebuilds reconciliation loop. - if tc.setAutostopSchedule { - prebuildBuild, err := client.WorkspaceBuilds(ctx, codersdk.WorkspaceBuildsRequest{ - WorkspaceID: prebuild.ID, - }) - require.NoError(t, err) - require.Len(t, prebuildBuild, 1) - err = db.UpdateWorkspaceBuildDeadlineByID(ctx, database.UpdateWorkspaceBuildDeadlineByIDParams{ - ID: prebuildBuild[0].ID, - Deadline: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // 2024-01-02 0:00 UTC - MaxDeadline: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // 2024-01-02 0:00 UTC - UpdatedAt: clock.Now(), // 2024-01-01 8:00 UTC - }) - require.NoError(t, err) - prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) - } + }) + presets, err := client.TemplateVersionPresets(ctx, version.ID) + require.NoError(t, err) + require.Len(t, presets, 1) - // 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 - }() - - // 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) - if !tc.setAutostopSchedule { - require.Zero(t, prebuild.LatestBuild.Deadline) - require.Zero(t, prebuild.LatestBuild.MaxDeadline) - } + // 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 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))) - - // 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) - }() - - // 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) - }) - } + // 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) + + // 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 + }() + + // 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) + require.Zero(t, prebuild.LatestBuild.Deadline) + require.Zero(t, prebuild.LatestBuild.MaxDeadline) + + // 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))) + + // 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) + }() + + // 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. @@ -2172,180 +2075,132 @@ func TestPrebuildsAutobuild(t *testing.T) { t.Run("AutostartScheduleOnlyTriggersAfterClaim", func(t *testing.T) { t.Parallel() - cases := []struct { - name string - setAutostart bool - }{ - // Under normal operation, prebuild workspaces should not have any Autostart schedule applied. - // This case ensures that prebuilds are not subject to Autostart scheduling before claim. - { - name: "PrebuildNoAutostartScheduleBeforeClaim", - setAutostart: false, + // 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, + ), }, - // Even if a prebuild workspace is incorrectly assigned an Autostart schedule, - // these values must be ignored while in the prebuild state. - // The Autostart schedule should only take effect after the workspace is claimed. - { - name: "PrebuildIgnoresAutostartScheduleBeforeClaim", - setAutostart: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, - } + }) - for _, tc := range cases { - tc := tc - t.Run(tc.name, 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, + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) - // 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) - // 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) { - 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: a running prebuilt workspace - prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) - // Prebuilt workspaces should have an empty Autostart Schedule - require.Nil(t, prebuild.AutostartSchedule) - require.Nil(t, prebuild.NextStartAt) - - // Note: This simulates a scenario where a prebuilt workspace incorrectly has - // an autostart schedule set (which should not happen in normal operation). - // Even if set, the autostart schedule should be ignored for prebuilt workspaces - // as they are managed by the prebuilds reconciliation loop. - if tc.setAutostart { - err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ - ID: prebuild.ID, - AutostartSchedule: sql.NullString{ - String: sched.String(), - Valid: true, - }, - NextStartAt: sql.NullTime{ - Time: clock.Now().Truncate(24 * time.Hour).Add(24 * time.Hour), // Next midnight - Valid: true, - }, - }) - require.NoError(t, err) - prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) - } + // 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) + // 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) { + 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: 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) - if !tc.setAutostart { - require.Nil(t, prebuild.AutostartSchedule) - require.Nil(t, prebuild.NextStartAt) - } + // 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 + prebuild := coderdtest.MustWorkspace(t, client, runningPrebuilds[0].ID) + // 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) + 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) + }() - // 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 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) - - // 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.AutostartSchedule) - require.NotNil(t, workspace.NextStartAt) - require.Equal(t, sched.Next(clock.Now()), workspace.NextStartAt.UTC()) - }) - } + // 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) + 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 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) + + // 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.AutostartSchedule) + require.NotNil(t, workspace.NextStartAt) + require.Equal(t, sched.Next(clock.Now()), workspace.NextStartAt.UTC()) }) // Prebuild workspaces should not transition to dormant or be deleted due to inactivity. @@ -2355,180 +2210,134 @@ func TestPrebuildsAutobuild(t *testing.T) { t.Run("DormantOnlyAfterClaimed", func(t *testing.T) { t.Parallel() - cases := []struct { - name string - setDormantFields bool - }{ - // Under normal operation, prebuild workspaces should not be marked as dormant - // or scheduled for deletion. - // This case ensures these fields remain unset before claim. - { - name: "PrebuildNoDormantOrDeleteBeforeClaim", - setDormantFields: false, + // Set the clock to dbtime.Now() to match the workspace's LastUsedAt + 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, + ), }, - // Even if a prebuild workspace is incorrectly marked as dormant and assigned a deletion deadline, - // these fields must be ignored while the workspace remains unclaimed. - // After the workspace is claimed, dormancy and deletion should only be applied - // once the configured inactivity and deletion thresholds are actually reached. - { - name: "PrebuildIgnoresDormantAndDeleteBeforeClaim", - setDormantFields: true, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAdvancedTemplateScheduling: 1, + }, }, - } + }) - for _, tc := range cases { - tc := tc - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - - // Set the clock to dbtime.Now() to match the workspace's LastUsedAt - 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, + api.AGPL.BuildUsageChecker, + ) + var claimer agplprebuilds.Claimer = prebuilds.NewEnterpriseClaimer(db) + api.AGPL.PrebuildsClaimer.Store(&claimer) - // 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) - 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) { - 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) - 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) - require.Nil(t, prebuild.DormantAt) - require.Nil(t, prebuild.DeletingAt) - - // Note: This simulates a scenario where a prebuilt workspace incorrectly has - // the dormant and deleting fields set (which should not happen in normal operation). - // Even if set, the dormancy and deletion should be ignored for prebuilt workspaces - // as they are managed by the prebuilds reconciliation loop. - if tc.setDormantFields { - _, err = db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{ - ID: prebuild.ID, - DormantAt: sql.NullTime{ - Time: clock.Now(), - Valid: true, - }, - }) - require.NoError(t, err) - prebuild = coderdtest.MustWorkspace(t, client, prebuild.ID) - } + // Setup user, template and template version with a preset with 1 prebuild instance + prebuildInstances := int32(1) + 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) { + 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) + require.Len(t, presets, 1) - // When: the autobuild executor ticks *after* the dormant TTL - go func() { - tickCh <- prebuild.LastUsedAt.Add(dormantTTL).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) - if !tc.setDormantFields { - require.Nil(t, prebuild.DormantAt) - require.Nil(t, prebuild.DeletingAt) - } + // 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 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 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 dormant TTL - go func() { - tickCh <- workspace.LastUsedAt.Add(dormantTTL).Add(time.Minute) - }() - - // Then: the workspace should transition to stopped state for breaching dormant 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) - 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 := <-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]) - }) - } + // 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 dormant TTL + go func() { + tickCh <- prebuild.LastUsedAt.Add(dormantTTL).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) + require.Nil(t, prebuild.DormantAt) + require.Nil(t, prebuild.DeletingAt) + + // 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 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 dormant TTL + go func() { + tickCh <- workspace.LastUsedAt.Add(dormantTTL).Add(time.Minute) + }() + + // Then: the workspace should transition to stopped state for breaching dormant 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) + 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 := <-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. From 8653a33ed22270d3e4020532f665ce03f73ce97f Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 11 Aug 2025 12:00:04 +0000 Subject: [PATCH 07/10] refactor: add lifecycle fields to prebuild claim database query --- coderd/database/dbgen/dbgen.go | 9 ++++--- coderd/database/queries.sql.go | 25 ++++++++++++++---- coderd/database/queries/prebuilds.sql | 5 ++++ coderd/prebuilds/api.go | 11 +++++++- coderd/prebuilds/noop.go | 3 ++- coderd/workspaces.go | 38 ++++++++++++--------------- enterprise/coderd/prebuilds/claim.go | 12 ++++++--- 7 files changed, 69 insertions(+), 34 deletions(-) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 11e02d0f651e9..f6eff865452bc 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1438,9 +1438,12 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) datab 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, + NewUserID: newUserID, + NewName: newName, + PresetID: presetID, + AutostartSchedule: sql.NullString{}, + NextStartAt: sql.NullTime{}, + WorkspaceTtl: sql.NullInt64{}, }) require.NoError(t, err, "claim prebuilt workspace") diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 845bc4d8c1b6b..bcdb02a01f0ac 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7123,6 +7123,11 @@ UPDATE workspaces w SET owner_id = $1::uuid, name = $2::text, updated_at = NOW(), + -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level + -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. + autostart_schedule = $3, + next_start_at = $4, + ttl = $5, -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. last_used_at = NOW(), @@ -7141,7 +7146,7 @@ WHERE w.id IN ( -- The prebuilds system should never try to claim a prebuild for an inactive template version. -- Nevertheless, this filter is here as a defensive measure: AND b.template_version_id = t.active_version_id - AND p.current_preset_id = $3::uuid + AND p.current_preset_id = $6::uuid AND p.ready AND NOT t.deleted LIMIT 1 FOR UPDATE OF p SKIP LOCKED -- Ensure that a concurrent request will not select the same prebuild. @@ -7150,9 +7155,12 @@ RETURNING w.id, w.name ` type ClaimPrebuiltWorkspaceParams struct { - NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` - NewName string `db:"new_name" json:"new_name"` - PresetID uuid.UUID `db:"preset_id" json:"preset_id"` + NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` + NewName string `db:"new_name" json:"new_name"` + AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` + NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` + WorkspaceTtl sql.NullInt64 `db:"workspace_ttl" json:"workspace_ttl"` + PresetID uuid.UUID `db:"preset_id" json:"preset_id"` } type ClaimPrebuiltWorkspaceRow struct { @@ -7161,7 +7169,14 @@ type ClaimPrebuiltWorkspaceRow struct { } func (q *sqlQuerier) ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebuiltWorkspaceParams) (ClaimPrebuiltWorkspaceRow, error) { - row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, arg.NewUserID, arg.NewName, arg.PresetID) + row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, + arg.NewUserID, + arg.NewName, + arg.AutostartSchedule, + arg.NextStartAt, + arg.WorkspaceTtl, + arg.PresetID, + ) var i ClaimPrebuiltWorkspaceRow err := row.Scan(&i.ID, &i.Name) return i, err diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index 1144601228df4..860d79718f58f 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -3,6 +3,11 @@ UPDATE workspaces w SET owner_id = @new_user_id::uuid, name = @new_name::text, updated_at = NOW(), + -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level + -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. + autostart_schedule = @autostart_schedule, + next_start_at = @next_start_at, + ttl = @workspace_ttl, -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. last_used_at = NOW(), diff --git a/coderd/prebuilds/api.go b/coderd/prebuilds/api.go index 3092d27421d26..3273afed2b1f7 100644 --- a/coderd/prebuilds/api.go +++ b/coderd/prebuilds/api.go @@ -2,6 +2,7 @@ package prebuilds import ( "context" + "database/sql" "github.com/google/uuid" "golang.org/x/xerrors" @@ -54,6 +55,14 @@ type StateSnapshotter interface { } type Claimer interface { - Claim(ctx context.Context, userID uuid.UUID, name string, presetID uuid.UUID) (*uuid.UUID, error) + Claim( + ctx context.Context, + userID uuid.UUID, + name string, + presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, + ) (*uuid.UUID, error) Initiator() uuid.UUID } diff --git a/coderd/prebuilds/noop.go b/coderd/prebuilds/noop.go index 3c2dd78a804db..d17f9b6c1f41e 100644 --- a/coderd/prebuilds/noop.go +++ b/coderd/prebuilds/noop.go @@ -2,6 +2,7 @@ package prebuilds import ( "context" + "database/sql" "github.com/google/uuid" @@ -28,7 +29,7 @@ var DefaultReconciler ReconciliationOrchestrator = NoopReconciler{} type NoopClaimer struct{} -func (NoopClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID) (*uuid.UUID, error) { +func (NoopClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID, sql.NullString, sql.NullTime, sql.NullInt64) (*uuid.UUID, error) { // Not entitled to claim prebuilds in AGPL version. return nil, ErrAGPLDoesNotSupportPrebuiltWorkspaces } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 5f44f27540521..48b2c9737b222 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -638,7 +638,11 @@ func createWorkspace( // If a template preset was chosen, try claim a prebuilt workspace. if req.TemplateVersionPresetID != uuid.Nil { // Try and claim an eligible prebuild, if available. - claimedWorkspace, err = claimPrebuild(ctx, prebuildsClaimer, db, api.Logger, req, owner) + // On successful claim, initialize all lifecycle fields from template and workspace-level config + // so the newly claimed workspace is properly managed by the lifecycle executor. + claimedWorkspace, err = claimPrebuild( + ctx, prebuildsClaimer, db, api.Logger, req, owner, + dbAutostartSchedule, nextStartAt, dbTTL) // If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds), // we fall back to creating a new workspace. Otherwise, propagate the unexpected error. if err != nil { @@ -690,24 +694,7 @@ func createWorkspace( } workspaceID = minimumWorkspace.ID } else { - // Prebuild found! Update lifecycle related parameters - err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{ - ID: claimedWorkspace.ID, - AutostartSchedule: dbAutostartSchedule, - NextStartAt: nextStartAt, - }) - if err != nil { - return xerrors.Errorf("update claimed workspace Autostart: %w", err) - } - - err = db.UpdateWorkspaceTTL(ctx, database.UpdateWorkspaceTTLParams{ - ID: claimedWorkspace.ID, - Ttl: dbTTL, - }) - if err != nil { - return xerrors.Errorf("update claimed workspace TTL: %w", err) - } - + // Prebuild found! workspaceID = claimedWorkspace.ID initiatorID = prebuildsClaimer.Initiator() } @@ -890,8 +877,17 @@ func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.C return template, true } -func claimPrebuild(ctx context.Context, claimer prebuilds.Claimer, db database.Store, logger slog.Logger, req codersdk.CreateWorkspaceRequest, owner workspaceOwner) (*database.Workspace, error) { - claimedID, err := claimer.Claim(ctx, owner.ID, req.Name, req.TemplateVersionPresetID) +func claimPrebuild( + ctx context.Context, + claimer prebuilds.Claimer, + db database.Store, logger slog.Logger, + req codersdk.CreateWorkspaceRequest, + owner workspaceOwner, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, +) (*database.Workspace, error) { + claimedID, err := claimer.Claim(ctx, owner.ID, req.Name, req.TemplateVersionPresetID, autostartSchedule, nextStartAt, ttl) if err != nil { // TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim. return nil, xerrors.Errorf("claim prebuild: %w", err) diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go index b6a85ae1fc094..0bacec9f6b6fb 100644 --- a/enterprise/coderd/prebuilds/claim.go +++ b/enterprise/coderd/prebuilds/claim.go @@ -27,11 +27,17 @@ func (c EnterpriseClaimer) Claim( userID uuid.UUID, name string, presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, ) (*uuid.UUID, error) { result, err := c.store.ClaimPrebuiltWorkspace(ctx, database.ClaimPrebuiltWorkspaceParams{ - NewUserID: userID, - NewName: name, - PresetID: presetID, + NewUserID: userID, + NewName: name, + PresetID: presetID, + AutostartSchedule: autostartSchedule, + NextStartAt: nextStartAt, + WorkspaceTtl: ttl, }) if err != nil { switch { From b2c130ffcaae3c5e9897a2f4c8d9b45df0905c8f Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 11 Aug 2025 15:09:39 +0000 Subject: [PATCH 08/10] fix: lifecycle executor prebuilds test --- coderd/autobuild/lifecycle_executor_test.go | 18 ++++++++++++++++-- coderd/database/dbgen/dbgen.go | 17 +++++++++++++---- 2 files changed, 29 insertions(+), 6 deletions(-) diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 0229a907cbb2e..0f5585b50592c 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1259,7 +1259,14 @@ func TestExecutorPrebuilds(t *testing.T) { 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) + dbWorkspace := dbgen.ClaimPrebuild( + t, db, + user.ID, + "claimedWorkspace-autostop", + preset.ID, + sql.NullString{}, + sql.NullTime{}, + sql.NullInt64{}) workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) // When: the autobuild executor ticks *after* the deadline: @@ -1353,7 +1360,14 @@ func TestExecutorPrebuilds(t *testing.T) { database.WorkspaceTransitionStart) // Given: a user claims the prebuilt workspace - dbWorkspace := dbgen.ClaimPrebuild(t, db, user.ID, "claimedWorkspace-autostart", preset.ID) + dbWorkspace := dbgen.ClaimPrebuild( + t, db, + user.ID, + "claimedWorkspace-autostart", + preset.ID, + autostartSched, + sql.NullTime{}, + sql.NullInt64{}) workspace := coderdtest.MustWorkspace(t, client, dbWorkspace.ID) // Given: the prebuilt workspace goes to a stop status diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index f6eff865452bc..1657af6fbd214 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1436,14 +1436,23 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) datab return userSecret } -func ClaimPrebuild(t testing.TB, db database.Store, newUserID uuid.UUID, newName string, presetID uuid.UUID) database.ClaimPrebuiltWorkspaceRow { +func ClaimPrebuild( + t testing.TB, + db database.Store, + newUserID uuid.UUID, + newName string, + presetID uuid.UUID, + autostartSchedule sql.NullString, + nextStartAt sql.NullTime, + ttl sql.NullInt64, +) database.ClaimPrebuiltWorkspaceRow { claimedWorkspace, err := db.ClaimPrebuiltWorkspace(genCtx, database.ClaimPrebuiltWorkspaceParams{ NewUserID: newUserID, NewName: newName, PresetID: presetID, - AutostartSchedule: sql.NullString{}, - NextStartAt: sql.NullTime{}, - WorkspaceTtl: sql.NullInt64{}, + AutostartSchedule: autostartSchedule, + NextStartAt: nextStartAt, + WorkspaceTtl: ttl, }) require.NoError(t, err, "claim prebuilt workspace") From e54ba2aaf24b3ae0eea9bdb12d51eda8b0f16e7b Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Mon, 11 Aug 2025 15:24:40 +0000 Subject: [PATCH 09/10] refactor: use testutil.RequireReceive for receiving workspace lifecycle stats --- coderd/autobuild/lifecycle_executor_test.go | 8 ++++---- enterprise/coderd/workspaces_test.go | 20 ++++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 0f5585b50592c..3c675c548e6c4 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1251,7 +1251,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) @@ -1276,7 +1276,7 @@ func TestExecutorPrebuilds(t *testing.T) { }() // 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) @@ -1343,7 +1343,7 @@ 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) @@ -1388,7 +1388,7 @@ func TestExecutorPrebuilds(t *testing.T) { }() // Then: the workspace should eventually be started - 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) diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index feabc685cdafe..8f08379a26f30 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -1913,7 +1913,7 @@ func TestPrebuildsAutobuild(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) @@ -1938,7 +1938,7 @@ func TestPrebuildsAutobuild(t *testing.T) { }() // 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) @@ -2036,7 +2036,7 @@ func TestPrebuildsAutobuild(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) @@ -2060,7 +2060,7 @@ func TestPrebuildsAutobuild(t *testing.T) { }() // 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) @@ -2160,7 +2160,7 @@ func TestPrebuildsAutobuild(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) @@ -2194,7 +2194,7 @@ func TestPrebuildsAutobuild(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) @@ -2290,7 +2290,7 @@ func TestPrebuildsAutobuild(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) @@ -2314,7 +2314,7 @@ func TestPrebuildsAutobuild(t *testing.T) { }() // Then: the workspace should transition to stopped state for breaching dormant TTL - 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) @@ -2333,7 +2333,7 @@ func TestPrebuildsAutobuild(t *testing.T) { }() // Then: the workspace should be deleted - dormantWorkspaceStats := <-statsCh + 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) @@ -2433,7 +2433,7 @@ func TestPrebuildsAutobuild(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) From 837b02a40501f8dd31de93bcd4b41f7905c22631 Mon Sep 17 00:00:00 2001 From: Susana Ferreira Date: Tue, 12 Aug 2025 12:05:46 +0000 Subject: [PATCH 10/10] refactor: use api clock mock when creating workspace --- coderd/autobuild/lifecycle_executor_test.go | 9 +++++--- coderd/database/dbgen/dbgen.go | 2 ++ coderd/database/queries.sql.go | 14 +++++++------ coderd/database/queries/prebuilds.sql | 4 ++-- coderd/prebuilds/api.go | 2 ++ coderd/prebuilds/noop.go | 3 ++- coderd/workspaces.go | 13 +++++++----- enterprise/coderd/prebuilds/claim.go | 3 +++ enterprise/coderd/prebuilds/claim_test.go | 8 ++++++- enterprise/coderd/workspaces_test.go | 23 ++++++++++++--------- 10 files changed, 53 insertions(+), 28 deletions(-) diff --git a/coderd/autobuild/lifecycle_executor_test.go b/coderd/autobuild/lifecycle_executor_test.go index 3c675c548e6c4..babca5431d6b7 100644 --- a/coderd/autobuild/lifecycle_executor_test.go +++ b/coderd/autobuild/lifecycle_executor_test.go @@ -1261,6 +1261,7 @@ func TestExecutorPrebuilds(t *testing.T) { // Given: a user claims the prebuilt workspace dbWorkspace := dbgen.ClaimPrebuild( t, db, + clock.Now(), user.ID, "claimedWorkspace-autostop", preset.ID, @@ -1362,6 +1363,7 @@ func TestExecutorPrebuilds(t *testing.T) { // Given: a user claims the prebuilt workspace dbWorkspace := dbgen.ClaimPrebuild( t, db, + clock.Now(), user.ID, "claimedWorkspace-autostart", preset.ID, @@ -1500,8 +1502,8 @@ func setupTestDBWorkspaceBuild( 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}, + StartedAt: sql.NullTime{Time: clock.Now().Add(time.Hour), Valid: true}, + ReadyAt: sql.NullTime{Time: clock.Now().Add(-1 * time.Hour), Valid: true}, APIKeyScope: database.AgentKeyScopeEnumAll, }) @@ -1538,8 +1540,9 @@ func setupTestDBPrebuiltWorkspace( OrganizationID: orgID, OwnerID: database.PrebuildsSystemUserID, Deleted: false, - CreatedAt: time.Now().Add(-time.Hour * 2), + CreatedAt: clock.Now().Add(-time.Hour * 2), AutostartSchedule: options.AutostartSchedule, + LastUsedAt: clock.Now(), }) setupTestDBWorkspaceBuild(ctx, t, clock, db, ps, orgID, workspace.ID, templateVersionID, presetID, buildTransition) diff --git a/coderd/database/dbgen/dbgen.go b/coderd/database/dbgen/dbgen.go index 1657af6fbd214..56927507b6109 100644 --- a/coderd/database/dbgen/dbgen.go +++ b/coderd/database/dbgen/dbgen.go @@ -1439,6 +1439,7 @@ func UserSecret(t testing.TB, db database.Store, seed database.UserSecret) datab func ClaimPrebuild( t testing.TB, db database.Store, + now time.Time, newUserID uuid.UUID, newName string, presetID uuid.UUID, @@ -1449,6 +1450,7 @@ func ClaimPrebuild( claimedWorkspace, err := db.ClaimPrebuiltWorkspace(genCtx, database.ClaimPrebuiltWorkspaceParams{ NewUserID: newUserID, NewName: newName, + Now: now, PresetID: presetID, AutostartSchedule: autostartSchedule, NextStartAt: nextStartAt, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 9f67025dbaada..98b17e8d8df28 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -7122,15 +7122,15 @@ const claimPrebuiltWorkspace = `-- name: ClaimPrebuiltWorkspace :one UPDATE workspaces w SET owner_id = $1::uuid, name = $2::text, - updated_at = NOW(), + updated_at = $3::timestamptz, -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. - autostart_schedule = $3, - next_start_at = $4, - ttl = $5, + autostart_schedule = $4, + next_start_at = $5, + ttl = $6, -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. - last_used_at = NOW(), + last_used_at = $3::timestamptz, -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. -- These fields should not be set on prebuilds, but we defensively reset them here to prevent -- accidental dormancy or deletion by the lifecycle executor. @@ -7146,7 +7146,7 @@ WHERE w.id IN ( -- The prebuilds system should never try to claim a prebuild for an inactive template version. -- Nevertheless, this filter is here as a defensive measure: AND b.template_version_id = t.active_version_id - AND p.current_preset_id = $6::uuid + AND p.current_preset_id = $7::uuid AND p.ready AND NOT t.deleted LIMIT 1 FOR UPDATE OF p SKIP LOCKED -- Ensure that a concurrent request will not select the same prebuild. @@ -7157,6 +7157,7 @@ RETURNING w.id, w.name type ClaimPrebuiltWorkspaceParams struct { NewUserID uuid.UUID `db:"new_user_id" json:"new_user_id"` NewName string `db:"new_name" json:"new_name"` + Now time.Time `db:"now" json:"now"` AutostartSchedule sql.NullString `db:"autostart_schedule" json:"autostart_schedule"` NextStartAt sql.NullTime `db:"next_start_at" json:"next_start_at"` WorkspaceTtl sql.NullInt64 `db:"workspace_ttl" json:"workspace_ttl"` @@ -7172,6 +7173,7 @@ func (q *sqlQuerier) ClaimPrebuiltWorkspace(ctx context.Context, arg ClaimPrebui row := q.db.QueryRowContext(ctx, claimPrebuiltWorkspace, arg.NewUserID, arg.NewName, + arg.Now, arg.AutostartSchedule, arg.NextStartAt, arg.WorkspaceTtl, diff --git a/coderd/database/queries/prebuilds.sql b/coderd/database/queries/prebuilds.sql index 860d79718f58f..87a713974c563 100644 --- a/coderd/database/queries/prebuilds.sql +++ b/coderd/database/queries/prebuilds.sql @@ -2,7 +2,7 @@ UPDATE workspaces w SET owner_id = @new_user_id::uuid, name = @new_name::text, - updated_at = NOW(), + updated_at = @now::timestamptz, -- Update autostart_schedule, next_start_at and ttl according to template and workspace-level -- configurations, allowing the workspace to be managed by the lifecycle executor as expected. autostart_schedule = @autostart_schedule, @@ -10,7 +10,7 @@ SET owner_id = @new_user_id::uuid, ttl = @workspace_ttl, -- Update last_used_at during claim to ensure the claimed workspace is treated as recently used. -- This avoids unintended dormancy caused by prebuilds having stale usage timestamps. - last_used_at = NOW(), + last_used_at = @now::timestamptz, -- Clear dormant and deletion timestamps as a safeguard to ensure a clean lifecycle state after claim. -- These fields should not be set on prebuilds, but we defensively reset them here to prevent -- accidental dormancy or deletion by the lifecycle executor. diff --git a/coderd/prebuilds/api.go b/coderd/prebuilds/api.go index 3273afed2b1f7..1bedeb10130c8 100644 --- a/coderd/prebuilds/api.go +++ b/coderd/prebuilds/api.go @@ -3,6 +3,7 @@ package prebuilds import ( "context" "database/sql" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -57,6 +58,7 @@ type StateSnapshotter interface { type Claimer interface { Claim( ctx context.Context, + now time.Time, userID uuid.UUID, name string, presetID uuid.UUID, diff --git a/coderd/prebuilds/noop.go b/coderd/prebuilds/noop.go index d17f9b6c1f41e..ebb6d6964214e 100644 --- a/coderd/prebuilds/noop.go +++ b/coderd/prebuilds/noop.go @@ -3,6 +3,7 @@ package prebuilds import ( "context" "database/sql" + "time" "github.com/google/uuid" @@ -29,7 +30,7 @@ var DefaultReconciler ReconciliationOrchestrator = NoopReconciler{} type NoopClaimer struct{} -func (NoopClaimer) Claim(context.Context, uuid.UUID, string, uuid.UUID, sql.NullString, sql.NullTime, sql.NullInt64) (*uuid.UUID, error) { +func (NoopClaimer) Claim(context.Context, time.Time, uuid.UUID, string, uuid.UUID, sql.NullString, sql.NullTime, sql.NullInt64) (*uuid.UUID, error) { // Not entitled to claim prebuilds in AGPL version. return nil, ErrAGPLDoesNotSupportPrebuiltWorkspaces } diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 48b2c9737b222..ac5c2d92d628e 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -635,13 +635,16 @@ func createWorkspace( claimedWorkspace *database.Workspace ) + // Use injected Clock to allow time mocking in tests + now := api.Clock.Now() + // If a template preset was chosen, try claim a prebuilt workspace. if req.TemplateVersionPresetID != uuid.Nil { // Try and claim an eligible prebuild, if available. // On successful claim, initialize all lifecycle fields from template and workspace-level config // so the newly claimed workspace is properly managed by the lifecycle executor. claimedWorkspace, err = claimPrebuild( - ctx, prebuildsClaimer, db, api.Logger, req, owner, + ctx, prebuildsClaimer, db, api.Logger, now, req, owner, dbAutostartSchedule, nextStartAt, dbTTL) // If claiming fails with an expected error (no claimable prebuilds or AGPL does not support prebuilds), // we fall back to creating a new workspace. Otherwise, propagate the unexpected error. @@ -668,8 +671,6 @@ func createWorkspace( } } - now := dbtime.Now() - // No prebuild found; regular flow. if claimedWorkspace == nil { // Workspaces are created without any versions. @@ -880,14 +881,16 @@ func requestTemplate(ctx context.Context, rw http.ResponseWriter, req codersdk.C func claimPrebuild( ctx context.Context, claimer prebuilds.Claimer, - db database.Store, logger slog.Logger, + db database.Store, + logger slog.Logger, + now time.Time, req codersdk.CreateWorkspaceRequest, owner workspaceOwner, autostartSchedule sql.NullString, nextStartAt sql.NullTime, ttl sql.NullInt64, ) (*database.Workspace, error) { - claimedID, err := claimer.Claim(ctx, owner.ID, req.Name, req.TemplateVersionPresetID, autostartSchedule, nextStartAt, ttl) + claimedID, err := claimer.Claim(ctx, now, owner.ID, req.Name, req.TemplateVersionPresetID, autostartSchedule, nextStartAt, ttl) if err != nil { // TODO: enhance this by clarifying whether this *specific* prebuild failed or whether there are none to claim. return nil, xerrors.Errorf("claim prebuild: %w", err) diff --git a/enterprise/coderd/prebuilds/claim.go b/enterprise/coderd/prebuilds/claim.go index 0bacec9f6b6fb..daea281d38d60 100644 --- a/enterprise/coderd/prebuilds/claim.go +++ b/enterprise/coderd/prebuilds/claim.go @@ -4,6 +4,7 @@ import ( "context" "database/sql" "errors" + "time" "github.com/google/uuid" "golang.org/x/xerrors" @@ -24,6 +25,7 @@ func NewEnterpriseClaimer(store database.Store) *EnterpriseClaimer { func (c EnterpriseClaimer) Claim( ctx context.Context, + now time.Time, userID uuid.UUID, name string, presetID uuid.UUID, @@ -34,6 +36,7 @@ func (c EnterpriseClaimer) Claim( result, err := c.store.ClaimPrebuiltWorkspace(ctx, database.ClaimPrebuiltWorkspaceParams{ NewUserID: userID, NewName: name, + Now: now, PresetID: presetID, AutostartSchedule: autostartSchedule, NextStartAt: nextStartAt, diff --git a/enterprise/coderd/prebuilds/claim_test.go b/enterprise/coderd/prebuilds/claim_test.go index 01195e3485016..9ed7e9ffd19e0 100644 --- a/enterprise/coderd/prebuilds/claim_test.go +++ b/enterprise/coderd/prebuilds/claim_test.go @@ -15,6 +15,8 @@ import ( "github.com/stretchr/testify/require" "golang.org/x/xerrors" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/files" "github.com/coder/quartz" @@ -132,7 +134,9 @@ func TestClaimPrebuild(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - // Setup. + // Setup + clock := quartz.NewMock(t) + clock.Set(dbtime.Now()) ctx := testutil.Context(t, testutil.WaitSuperLong) db, pubsub := dbtestutil.NewDB(t) @@ -144,6 +148,7 @@ func TestClaimPrebuild(t *testing.T) { Options: &coderdtest.Options{ Database: spy, Pubsub: pubsub, + Clock: clock, }, LicenseOptions: &coderdenttest.LicenseOptions{ Features: license.Features{ @@ -238,6 +243,7 @@ func TestClaimPrebuild(t *testing.T) { // When: a user creates a new workspace with a preset for which prebuilds are configured. workspaceName := strings.ReplaceAll(testutil.GetRandomName(t), "_", "-") params := database.ClaimPrebuiltWorkspaceParams{ + Now: clock.Now(), NewUserID: user.ID, NewName: workspaceName, PresetID: presets[0].ID, diff --git a/enterprise/coderd/workspaces_test.go b/enterprise/coderd/workspaces_test.go index 8f08379a26f30..a260de9506e82 100644 --- a/enterprise/coderd/workspaces_test.go +++ b/enterprise/coderd/workspaces_test.go @@ -2210,9 +2210,9 @@ func TestPrebuildsAutobuild(t *testing.T) { t.Run("DormantOnlyAfterClaimed", func(t *testing.T) { t.Parallel() - // Set the clock to dbtime.Now() to match the workspace's LastUsedAt + // Set the clock to Monday, January 1st, 2024 at 8:00 AM UTC to keep the test deterministic clock := quartz.NewMock(t) - clock.Set(dbtime.Now()) + clock.Set(time.Date(2024, 1, 1, 8, 0, 0, 0, time.UTC)) // Setup ctx := testutil.Context(t, testutil.WaitSuperLong) @@ -2237,9 +2237,7 @@ func TestPrebuildsAutobuild(t *testing.T) { ), }, LicenseOptions: &coderdenttest.LicenseOptions{ - Features: license.Features{ - codersdk.FeatureAdvancedTemplateScheduling: 1, - }, + Features: license.Features{codersdk.FeatureAdvancedTemplateScheduling: 1}, }, }) @@ -2284,9 +2282,11 @@ func TestPrebuildsAutobuild(t *testing.T) { require.Nil(t, prebuild.DormantAt) require.Nil(t, prebuild.DeletingAt) - // When: the autobuild executor ticks *after* the dormant 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(dormantTTL).Add(time.Minute) + tickCh <- next }() // Then: the prebuilt workspace should remain in a start transition @@ -2299,7 +2299,8 @@ func TestPrebuildsAutobuild(t *testing.T) { require.Nil(t, prebuild.DormantAt) require.Nil(t, prebuild.DeletingAt) - // Given: a user claims the prebuilt workspace + // Given: a user claims the prebuilt workspace sometime later + 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) // Then: the claimed workspace should have DormantAt and DeletingAt unset (nil), @@ -2308,9 +2309,11 @@ func TestPrebuildsAutobuild(t *testing.T) { require.Nil(t, workspace.DeletingAt) require.True(t, workspace.LastUsedAt.After(prebuild.LastUsedAt)) - // When: the autobuild executor ticks *after* the dormant 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 <- workspace.LastUsedAt.Add(dormantTTL).Add(time.Minute) + tickCh <- next }() // Then: the workspace should transition to stopped state for breaching dormant TTL