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 pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy