Skip to content

Commit d79a779

Browse files
authored
fix: exclude prebuilt workspaces from template-level lifecycle updates (#19265)
## Description This PR ensures that lifecycle-related changes made via template schedule updates do **not affect prebuilt workspaces**. Since prebuilds are managed by the reconciliation loop and do not participate in the regular lifecycle executor flow, they must be excluded from any updates triggered by template configuration changes. This includes changes to TTL, dormant-deletion scheduling, deadline and autostart scheduling. ## Changes - Updated SQL query `UpdateWorkspacesTTLByTemplateID` to exclude prebuilt workspaces - Updated SQL query `UpdateWorkspacesDormantDeletingAtByTemplateID` to exclude prebuilt workspaces - Updated application-layer logic to skip any updates to lifecycle parameters if a workspace is a prebuild - Preserved all existing update behavior for regular user workspaces This change guarantees that only lifecycle-managed workspaces are affected when template-level configurations are modified, preserving strict boundaries between prebuild and user workspace lifecycles. Related with: * Issue: #18898 * PR: #19252
1 parent e879526 commit d79a779

File tree

4 files changed

+282
-8
lines changed

4 files changed

+282
-8
lines changed

coderd/database/queries.sql.go

Lines changed: 10 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

coderd/database/queries/workspaces.sql

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -579,7 +579,11 @@ UPDATE
579579
SET
580580
ttl = $2
581581
WHERE
582-
template_id = $1;
582+
template_id = $1
583+
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
584+
-- should not have their TTL updated, as they are handled by the prebuilds
585+
-- reconciliation loop.
586+
AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID;
583587

584588
-- name: UpdateWorkspaceLastUsedAt :exec
585589
UPDATE
@@ -824,14 +828,17 @@ UPDATE workspaces
824828
SET
825829
deleting_at = CASE
826830
WHEN @time_til_dormant_autodelete_ms::bigint = 0 THEN NULL
827-
WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint
831+
WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN (@dormant_at::timestamptz) + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint
828832
ELSE dormant_at + interval '1 milliseconds' * @time_til_dormant_autodelete_ms::bigint
829833
END,
830834
dormant_at = CASE WHEN @dormant_at::timestamptz > '0001-01-01 00:00:00+00'::timestamptz THEN @dormant_at::timestamptz ELSE dormant_at END
831835
WHERE
832836
template_id = @template_id
833-
AND
834-
dormant_at IS NOT NULL
837+
AND dormant_at IS NOT NULL
838+
-- Prebuilt workspaces (identified by having the prebuilds system user as owner_id)
839+
-- should not have their dormant or deleting at set, as these are handled by the
840+
-- prebuilds reconciliation loop.
841+
AND workspaces.owner_id != 'c42fdf75-3097-471c-8c33-fb52454d81c0'::UUID
835842
RETURNING *;
836843

837844
-- name: UpdateTemplateWorkspacesLastUsedAt :exec

enterprise/coderd/schedule/template.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,10 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
242242
nextStartAts := []time.Time{}
243243

244244
for _, workspace := range workspaces {
245+
// Skip prebuilt workspaces
246+
if workspace.IsPrebuild() {
247+
continue
248+
}
245249
nextStartAt := time.Time{}
246250
if workspace.AutostartSchedule.Valid {
247251
next, err := agpl.NextAllowedAutostart(s.now(), workspace.AutostartSchedule.String, templateSchedule)
@@ -254,7 +258,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S
254258
nextStartAts = append(nextStartAts, nextStartAt)
255259
}
256260

257-
//nolint:gocritic // We need to be able to update information about all workspaces.
261+
//nolint:gocritic // We need to be able to update information about regular user workspaces.
258262
if err := db.BatchUpdateWorkspaceNextStartAt(dbauthz.AsSystemRestricted(ctx), database.BatchUpdateWorkspaceNextStartAtParams{
259263
IDs: workspaceIDs,
260264
NextStartAts: nextStartAts,
@@ -334,6 +338,11 @@ func (s *EnterpriseTemplateScheduleStore) updateWorkspaceBuild(ctx context.Conte
334338
return xerrors.Errorf("get workspace %q: %w", build.WorkspaceID, err)
335339
}
336340

341+
// Skip lifecycle updates for prebuilt workspaces
342+
if workspace.IsPrebuild() {
343+
return nil
344+
}
345+
337346
job, err := db.GetProvisionerJobByID(ctx, build.JobID)
338347
if err != nil {
339348
return xerrors.Errorf("get provisioner job %q: %w", build.JobID, err)

enterprise/coderd/schedule/template_test.go

Lines changed: 251 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package schedule_test
22

33
import (
4+
"context"
45
"database/sql"
56
"encoding/json"
67
"fmt"
@@ -17,14 +18,18 @@ import (
1718

1819
"github.com/coder/coder/v2/coderd/database"
1920
"github.com/coder/coder/v2/coderd/database/dbauthz"
21+
"github.com/coder/coder/v2/coderd/database/dbfake"
2022
"github.com/coder/coder/v2/coderd/database/dbgen"
2123
"github.com/coder/coder/v2/coderd/database/dbtestutil"
2224
"github.com/coder/coder/v2/coderd/database/dbtime"
2325
"github.com/coder/coder/v2/coderd/notifications"
2426
"github.com/coder/coder/v2/coderd/notifications/notificationstest"
2527
agplschedule "github.com/coder/coder/v2/coderd/schedule"
28+
"github.com/coder/coder/v2/coderd/schedule/cron"
29+
"github.com/coder/coder/v2/codersdk"
2630
"github.com/coder/coder/v2/cryptorand"
2731
"github.com/coder/coder/v2/enterprise/coderd/schedule"
32+
"github.com/coder/coder/v2/provisionersdk/proto"
2833
"github.com/coder/coder/v2/testutil"
2934
"github.com/coder/quartz"
3035
)
@@ -979,6 +984,252 @@ func TestTemplateTTL(t *testing.T) {
979984
})
980985
}
981986

987+
func TestTemplateUpdatePrebuilds(t *testing.T) {
988+
t.Parallel()
989+
990+
// Dormant auto-delete configured to 10 hours
991+
dormantAutoDelete := 10 * time.Hour
992+
993+
// TTL configured to 8 hours
994+
ttl := 8 * time.Hour
995+
996+
// Autostop configuration set to everyday at midnight
997+
autostopWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek)
998+
require.NoError(t, err)
999+
1000+
// Autostart configuration set to everyday at midnight
1001+
autostartSchedule, err := cron.Weekly("CRON_TZ=UTC 0 0 * * *")
1002+
require.NoError(t, err)
1003+
autostartWeekdays, err := codersdk.WeekdaysToBitmap(codersdk.AllDaysOfWeek)
1004+
require.NoError(t, err)
1005+
1006+
cases := []struct {
1007+
name string
1008+
templateSchedule agplschedule.TemplateScheduleOptions
1009+
workspaceUpdate func(*testing.T, context.Context, database.Store, time.Time, database.ClaimPrebuiltWorkspaceRow)
1010+
assertWorkspace func(*testing.T, context.Context, database.Store, time.Time, bool, database.Workspace)
1011+
}{
1012+
{
1013+
name: "TemplateDormantAutoDeleteUpdatePrebuildAfterClaim",
1014+
templateSchedule: agplschedule.TemplateScheduleOptions{
1015+
// Template level TimeTilDormantAutodelete set to 10 hours
1016+
TimeTilDormantAutoDelete: dormantAutoDelete,
1017+
},
1018+
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
1019+
workspace database.ClaimPrebuiltWorkspaceRow,
1020+
) {
1021+
// When: the workspace is marked dormant
1022+
dormantWorkspace, err := db.UpdateWorkspaceDormantDeletingAt(ctx, database.UpdateWorkspaceDormantDeletingAtParams{
1023+
ID: workspace.ID,
1024+
DormantAt: sql.NullTime{
1025+
Time: now,
1026+
Valid: true,
1027+
},
1028+
})
1029+
require.NoError(t, err)
1030+
require.NotNil(t, dormantWorkspace.DormantAt)
1031+
},
1032+
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
1033+
isPrebuild bool, workspace database.Workspace,
1034+
) {
1035+
if isPrebuild {
1036+
// The unclaimed prebuild should have an empty DormantAt and DeletingAt
1037+
require.True(t, workspace.DormantAt.Time.IsZero())
1038+
require.True(t, workspace.DeletingAt.Time.IsZero())
1039+
} else {
1040+
// The claimed workspace should have its DormantAt and DeletingAt updated
1041+
require.False(t, workspace.DormantAt.Time.IsZero())
1042+
require.False(t, workspace.DeletingAt.Time.IsZero())
1043+
require.WithinDuration(t, now.UTC(), workspace.DormantAt.Time.UTC(), time.Second)
1044+
require.WithinDuration(t, now.Add(dormantAutoDelete).UTC(), workspace.DeletingAt.Time.UTC(), time.Second)
1045+
}
1046+
},
1047+
},
1048+
{
1049+
name: "TemplateTTLUpdatePrebuildAfterClaim",
1050+
templateSchedule: agplschedule.TemplateScheduleOptions{
1051+
// Template level TTL can only be set if autostop is disabled for users
1052+
DefaultTTL: ttl,
1053+
UserAutostopEnabled: false,
1054+
},
1055+
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
1056+
workspace database.ClaimPrebuiltWorkspaceRow) {
1057+
},
1058+
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
1059+
isPrebuild bool, workspace database.Workspace,
1060+
) {
1061+
if isPrebuild {
1062+
// The unclaimed prebuild should have an empty TTL
1063+
require.Equal(t, sql.NullInt64{}, workspace.Ttl)
1064+
} else {
1065+
// The claimed workspace should have its TTL updated
1066+
require.Equal(t, sql.NullInt64{Int64: int64(ttl), Valid: true}, workspace.Ttl)
1067+
}
1068+
},
1069+
},
1070+
{
1071+
name: "TemplateAutostopUpdatePrebuildAfterClaim",
1072+
templateSchedule: agplschedule.TemplateScheduleOptions{
1073+
// Template level Autostop set for everyday
1074+
AutostopRequirement: agplschedule.TemplateAutostopRequirement{
1075+
DaysOfWeek: autostopWeekdays,
1076+
Weeks: 0,
1077+
},
1078+
},
1079+
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time,
1080+
workspace database.ClaimPrebuiltWorkspaceRow) {
1081+
},
1082+
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) {
1083+
if isPrebuild {
1084+
// The unclaimed prebuild should have an empty MaxDeadline
1085+
prebuildBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
1086+
require.NoError(t, err)
1087+
require.True(t, prebuildBuild.MaxDeadline.IsZero())
1088+
} else {
1089+
// The claimed workspace should have its MaxDeadline updated
1090+
workspaceBuild, err := db.GetLatestWorkspaceBuildByWorkspaceID(ctx, workspace.ID)
1091+
require.NoError(t, err)
1092+
require.False(t, workspaceBuild.MaxDeadline.IsZero())
1093+
}
1094+
},
1095+
},
1096+
{
1097+
name: "TemplateAutostartUpdatePrebuildAfterClaim",
1098+
templateSchedule: agplschedule.TemplateScheduleOptions{
1099+
// Template level Autostart set for everyday
1100+
UserAutostartEnabled: true,
1101+
AutostartRequirement: agplschedule.TemplateAutostartRequirement{
1102+
DaysOfWeek: autostartWeekdays,
1103+
},
1104+
},
1105+
workspaceUpdate: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, workspace database.ClaimPrebuiltWorkspaceRow) {
1106+
// To compute NextStartAt, the workspace must have a valid autostart schedule
1107+
err = db.UpdateWorkspaceAutostart(ctx, database.UpdateWorkspaceAutostartParams{
1108+
ID: workspace.ID,
1109+
AutostartSchedule: sql.NullString{
1110+
String: autostartSchedule.String(),
1111+
Valid: true,
1112+
},
1113+
})
1114+
require.NoError(t, err)
1115+
},
1116+
assertWorkspace: func(t *testing.T, ctx context.Context, db database.Store, now time.Time, isPrebuild bool, workspace database.Workspace) {
1117+
if isPrebuild {
1118+
// The unclaimed prebuild should have an empty NextStartAt
1119+
require.True(t, workspace.NextStartAt.Time.IsZero())
1120+
} else {
1121+
// The claimed workspace should have its NextStartAt updated
1122+
require.False(t, workspace.NextStartAt.Time.IsZero())
1123+
}
1124+
},
1125+
},
1126+
}
1127+
1128+
for _, tc := range cases {
1129+
tc := tc
1130+
t.Run(tc.name, func(t *testing.T) {
1131+
t.Parallel()
1132+
1133+
clock := quartz.NewMock(t)
1134+
clock.Set(dbtime.Now())
1135+
1136+
// Setup
1137+
var (
1138+
logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug)
1139+
db, _ = dbtestutil.NewDB(t, dbtestutil.WithDumpOnFailure())
1140+
ctx = testutil.Context(t, testutil.WaitLong)
1141+
user = dbgen.User(t, db, database.User{})
1142+
)
1143+
1144+
// Setup the template schedule store
1145+
notifyEnq := notifications.NewNoopEnqueuer()
1146+
const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC
1147+
userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true)
1148+
require.NoError(t, err)
1149+
userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{}
1150+
userQuietHoursStorePtr.Store(&userQuietHoursStore)
1151+
templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, clock)
1152+
1153+
// Given: a template and a template version with preset and a prebuilt workspace
1154+
presetID := uuid.New()
1155+
org := dbfake.Organization(t, db).Do()
1156+
tv := dbfake.TemplateVersion(t, db).Seed(database.TemplateVersion{
1157+
OrganizationID: org.Org.ID,
1158+
CreatedBy: user.ID,
1159+
}).Preset(database.TemplateVersionPreset{
1160+
ID: presetID,
1161+
DesiredInstances: sql.NullInt32{
1162+
Int32: 1,
1163+
Valid: true,
1164+
},
1165+
}).Do()
1166+
workspaceBuild := dbfake.WorkspaceBuild(t, db, database.WorkspaceTable{
1167+
OwnerID: database.PrebuildsSystemUserID,
1168+
TemplateID: tv.Template.ID,
1169+
OrganizationID: tv.Template.OrganizationID,
1170+
}).Seed(database.WorkspaceBuild{
1171+
TemplateVersionID: tv.TemplateVersion.ID,
1172+
TemplateVersionPresetID: uuid.NullUUID{
1173+
UUID: presetID,
1174+
Valid: true,
1175+
},
1176+
}).WithAgent(func(agent []*proto.Agent) []*proto.Agent {
1177+
return agent
1178+
}).Do()
1179+
1180+
// Mark the prebuilt workspace's agent as ready so the prebuild can be claimed
1181+
// nolint:gocritic
1182+
agentCtx := dbauthz.AsSystemRestricted(testutil.Context(t, testutil.WaitLong))
1183+
agent, err := db.GetWorkspaceAgentAndLatestBuildByAuthToken(agentCtx, uuid.MustParse(workspaceBuild.AgentToken))
1184+
require.NoError(t, err)
1185+
err = db.UpdateWorkspaceAgentLifecycleStateByID(agentCtx, database.UpdateWorkspaceAgentLifecycleStateByIDParams{
1186+
ID: agent.WorkspaceAgent.ID,
1187+
LifecycleState: database.WorkspaceAgentLifecycleStateReady,
1188+
})
1189+
require.NoError(t, err)
1190+
1191+
// Given: a prebuilt workspace
1192+
prebuild, err := db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID)
1193+
require.NoError(t, err)
1194+
tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild)
1195+
1196+
// When: the template schedule is updated
1197+
_, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule)
1198+
require.NoError(t, err)
1199+
1200+
// Then: lifecycle parameters must remain unset while the prebuild is unclaimed
1201+
prebuild, err = db.GetWorkspaceByID(ctx, workspaceBuild.Workspace.ID)
1202+
require.NoError(t, err)
1203+
tc.assertWorkspace(t, ctx, db, clock.Now(), true, prebuild)
1204+
1205+
// Given: the prebuilt workspace is claimed by a user
1206+
claimedWorkspace := dbgen.ClaimPrebuild(
1207+
t, db,
1208+
clock.Now(),
1209+
user.ID,
1210+
"claimedWorkspace-autostop",
1211+
presetID,
1212+
sql.NullString{},
1213+
sql.NullTime{},
1214+
sql.NullInt64{})
1215+
require.Equal(t, prebuild.ID, claimedWorkspace.ID)
1216+
1217+
// Given: the workspace level configurations are properly set in order to ensure the
1218+
// lifecycle parameters are updated
1219+
tc.workspaceUpdate(t, ctx, db, clock.Now(), claimedWorkspace)
1220+
1221+
// When: the template schedule is updated
1222+
_, err = templateScheduleStore.Set(ctx, db, tv.Template, tc.templateSchedule)
1223+
require.NoError(t, err)
1224+
1225+
// Then: the workspace should have its lifecycle parameters updated
1226+
workspace, err := db.GetWorkspaceByID(ctx, claimedWorkspace.ID)
1227+
require.NoError(t, err)
1228+
tc.assertWorkspace(t, ctx, db, clock.Now(), false, workspace)
1229+
})
1230+
}
1231+
}
1232+
9821233
func must[V any](v V, err error) V {
9831234
if err != nil {
9841235
panic(err)

0 commit comments

Comments
 (0)
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