diff --git a/coderd/database/dbauthz/dbauthz.go b/coderd/database/dbauthz/dbauthz.go index 21514cdf4e590..2d5e440575cab 100644 --- a/coderd/database/dbauthz/dbauthz.go +++ b/coderd/database/dbauthz/dbauthz.go @@ -4138,6 +4138,17 @@ func (q *querier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Cont return q.db.UpdateWorkspacesDormantDeletingAtByTemplateID(ctx, arg) } +func (q *querier) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg database.UpdateWorkspacesTTLByTemplateIDParams) error { + template, err := q.db.GetTemplateByID(ctx, arg.TemplateID) + if err != nil { + return xerrors.Errorf("get template by id: %w", err) + } + if err := q.authorizeContext(ctx, policy.ActionUpdate, template); err != nil { + return err + } + return q.db.UpdateWorkspacesTTLByTemplateID(ctx, arg) +} + func (q *querier) UpsertAnnouncementBanners(ctx context.Context, value string) error { if err := q.authorizeContext(ctx, policy.ActionUpdate, rbac.ResourceDeploymentConfig); err != nil { return err diff --git a/coderd/database/dbauthz/dbauthz_test.go b/coderd/database/dbauthz/dbauthz_test.go index e97523c918fc3..5c8cb7766a960 100644 --- a/coderd/database/dbauthz/dbauthz_test.go +++ b/coderd/database/dbauthz/dbauthz_test.go @@ -1017,6 +1017,12 @@ func (s *MethodTestSuite) TestTemplate() { TemplateID: t1.ID, }).Asserts(t1, policy.ActionUpdate) })) + s.Run("UpdateWorkspacesTTLByTemplateID", s.Subtest(func(db database.Store, check *expects) { + t1 := dbgen.Template(s.T(), db, database.Template{}) + check.Args(database.UpdateWorkspacesTTLByTemplateIDParams{ + TemplateID: t1.ID, + }).Asserts(t1, policy.ActionUpdate) + })) s.Run("UpdateTemplateActiveVersionByID", s.Subtest(func(db database.Store, check *expects) { t1 := dbgen.Template(s.T(), db, database.Template{ ActiveVersionID: uuid.New(), diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 9196b6bcb94eb..c5b9f7f6cc06f 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -10192,6 +10192,26 @@ func (q *FakeQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(_ context.Co return affectedRows, nil } +func (q *FakeQuerier) UpdateWorkspacesTTLByTemplateID(_ context.Context, arg database.UpdateWorkspacesTTLByTemplateIDParams) error { + err := validateDatabaseType(arg) + if err != nil { + return err + } + + q.mutex.Lock() + defer q.mutex.Unlock() + + for i, ws := range q.workspaces { + if ws.TemplateID != arg.TemplateID { + continue + } + + q.workspaces[i].Ttl = arg.Ttl + } + + return nil +} + func (q *FakeQuerier) UpsertAnnouncementBanners(_ context.Context, data string) error { q.mutex.RLock() defer q.mutex.RUnlock() diff --git a/coderd/database/dbmetrics/querymetrics.go b/coderd/database/dbmetrics/querymetrics.go index de43d05724467..797e3648aaff6 100644 --- a/coderd/database/dbmetrics/querymetrics.go +++ b/coderd/database/dbmetrics/querymetrics.go @@ -2590,6 +2590,13 @@ func (m queryMetricsStore) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx con return r0, r1 } +func (m queryMetricsStore) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg database.UpdateWorkspacesTTLByTemplateIDParams) error { + start := time.Now() + r0 := m.s.UpdateWorkspacesTTLByTemplateID(ctx, arg) + m.queryLatencies.WithLabelValues("UpdateWorkspacesTTLByTemplateID").Observe(time.Since(start).Seconds()) + return r0 +} + func (m queryMetricsStore) UpsertAnnouncementBanners(ctx context.Context, value string) error { start := time.Now() r0 := m.s.UpsertAnnouncementBanners(ctx, value) diff --git a/coderd/database/dbmock/dbmock.go b/coderd/database/dbmock/dbmock.go index 5c1c401046169..9d05156496580 100644 --- a/coderd/database/dbmock/dbmock.go +++ b/coderd/database/dbmock/dbmock.go @@ -5486,6 +5486,20 @@ func (mr *MockStoreMockRecorder) UpdateWorkspacesDormantDeletingAtByTemplateID(a return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesDormantDeletingAtByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesDormantDeletingAtByTemplateID), arg0, arg1) } +// UpdateWorkspacesTTLByTemplateID mocks base method. +func (m *MockStore) UpdateWorkspacesTTLByTemplateID(arg0 context.Context, arg1 database.UpdateWorkspacesTTLByTemplateIDParams) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "UpdateWorkspacesTTLByTemplateID", arg0, arg1) + ret0, _ := ret[0].(error) + return ret0 +} + +// UpdateWorkspacesTTLByTemplateID indicates an expected call of UpdateWorkspacesTTLByTemplateID. +func (mr *MockStoreMockRecorder) UpdateWorkspacesTTLByTemplateID(arg0, arg1 any) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpdateWorkspacesTTLByTemplateID", reflect.TypeOf((*MockStore)(nil).UpdateWorkspacesTTLByTemplateID), arg0, arg1) +} + // UpsertAnnouncementBanners mocks base method. func (m *MockStore) UpsertAnnouncementBanners(arg0 context.Context, arg1 string) error { m.ctrl.T.Helper() diff --git a/coderd/database/querier.go b/coderd/database/querier.go index 5593b9a14beb6..371dde97034db 100644 --- a/coderd/database/querier.go +++ b/coderd/database/querier.go @@ -501,6 +501,7 @@ type sqlcQuerier interface { UpdateWorkspaceProxyDeleted(ctx context.Context, arg UpdateWorkspaceProxyDeletedParams) error UpdateWorkspaceTTL(ctx context.Context, arg UpdateWorkspaceTTLParams) error UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.Context, arg UpdateWorkspacesDormantDeletingAtByTemplateIDParams) ([]WorkspaceTable, error) + UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error UpsertAnnouncementBanners(ctx context.Context, value string) error UpsertAppSecurityKey(ctx context.Context, value string) error UpsertApplicationName(ctx context.Context, value string) error diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 96fe9e5f8bf9a..ee2b71a4a6c63 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -16238,6 +16238,25 @@ func (q *sqlQuerier) UpdateWorkspacesDormantDeletingAtByTemplateID(ctx context.C return items, nil } +const updateWorkspacesTTLByTemplateID = `-- name: UpdateWorkspacesTTLByTemplateID :exec +UPDATE + workspaces +SET + ttl = $2 +WHERE + template_id = $1 +` + +type UpdateWorkspacesTTLByTemplateIDParams struct { + TemplateID uuid.UUID `db:"template_id" json:"template_id"` + Ttl sql.NullInt64 `db:"ttl" json:"ttl"` +} + +func (q *sqlQuerier) UpdateWorkspacesTTLByTemplateID(ctx context.Context, arg UpdateWorkspacesTTLByTemplateIDParams) error { + _, err := q.db.ExecContext(ctx, updateWorkspacesTTLByTemplateID, arg.TemplateID, arg.Ttl) + return err +} + const getWorkspaceAgentScriptsByAgentIDs = `-- name: GetWorkspaceAgentScriptsByAgentIDs :many SELECT workspace_agent_id, log_source_id, log_path, created_at, script, cron, start_blocks_login, run_on_start, run_on_stop, timeout_seconds, display_name, id FROM workspace_agent_scripts WHERE workspace_agent_id = ANY($1 :: uuid [ ]) ` diff --git a/coderd/database/queries/workspaces.sql b/coderd/database/queries/workspaces.sql index cdf4dfa5f0e3e..cb0d11e8a8960 100644 --- a/coderd/database/queries/workspaces.sql +++ b/coderd/database/queries/workspaces.sql @@ -501,6 +501,14 @@ SET WHERE id = $1; +-- name: UpdateWorkspacesTTLByTemplateID :exec +UPDATE + workspaces +SET + ttl = $2 +WHERE + template_id = $1; + -- name: UpdateWorkspaceLastUsedAt :exec UPDATE workspaces diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index a68cebd1fac93..ac7106af88173 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -2,6 +2,7 @@ package schedule import ( "context" + "database/sql" "time" "github.com/google/uuid" @@ -228,6 +229,23 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp return xerrors.Errorf("update template schedule: %w", err) } + // Users running the AGPL version are unable to customize their workspaces + // autostop, so we want to keep their workspaces in track with any template + // TTL changes. + if tpl.DefaultTTL != int64(opts.DefaultTTL) { + var ttl sql.NullInt64 + if opts.DefaultTTL != 0 { + ttl = sql.NullInt64{Valid: true, Int64: int64(opts.DefaultTTL)} + } + + if err = db.UpdateWorkspacesTTLByTemplateID(ctx, database.UpdateWorkspacesTTLByTemplateIDParams{ + TemplateID: tpl.ID, + Ttl: ttl, + }); err != nil { + return xerrors.Errorf("update workspace ttl by template id %q: %w", tpl.ID, err) + } + } + template, err = db.GetTemplateByID(ctx, tpl.ID) if err != nil { return xerrors.Errorf("fetch updated template: %w", err) diff --git a/coderd/schedule/template_test.go b/coderd/schedule/template_test.go new file mode 100644 index 0000000000000..7de7caa05c10f --- /dev/null +++ b/coderd/schedule/template_test.go @@ -0,0 +1,150 @@ +package schedule_test + +import ( + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/require" + + "github.com/coder/coder/v2/coderd/database" + "github.com/coder/coder/v2/coderd/database/dbgen" + "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" + "github.com/coder/coder/v2/coderd/schedule" + "github.com/coder/coder/v2/testutil" +) + +func TestTemplateTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + fromTTL time.Duration + toTTL time.Duration + expected sql.NullInt64 + }{ + { + name: "ModifyTTLDurationDown", + fromTTL: 24 * time.Hour, + toTTL: 1 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(1 * time.Hour)}, + }, + { + name: "ModifyTTLDurationUp", + fromTTL: 24 * time.Hour, + toTTL: 36 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(36 * time.Hour)}, + }, + { + name: "ModifyTTLDurationSame", + fromTTL: 24 * time.Hour, + toTTL: 24 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + { + name: "DisableTTL", + fromTTL: 24 * time.Hour, + toTTL: 0, + expected: sql.NullInt64{}, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + db, _ = dbtestutil.NewDB(t) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{CreatedBy: user.ID}) + // Create first template + templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{"foo": "bar"}, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: templateJob.ID, + OrganizationID: templateJob.OrganizationID, + }) + template = dbgen.Template(t, db, database.Template{ + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + OrganizationID: templateJob.OrganizationID, + }) + // Create second template + otherTTL = tt.fromTTL + 6*time.Hour + otherTemplateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{"foo": "bar"}, + }) + otherTemplateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: otherTemplateJob.ID, + OrganizationID: otherTemplateJob.OrganizationID, + }) + otherTemplate = dbgen.Template(t, db, database.Template{ + ActiveVersionID: otherTemplateVersion.ID, + CreatedBy: user.ID, + OrganizationID: otherTemplateJob.OrganizationID, + }) + ) + + templateScheduleStore := schedule.NewAGPLTemplateScheduleStore() + + // Set both template's default TTL + template, err := templateScheduleStore.Set(ctx, db, template, schedule.TemplateScheduleOptions{ + DefaultTTL: tt.fromTTL, + }) + require.NoError(t, err) + otherTemplate, err = templateScheduleStore.Set(ctx, db, otherTemplate, schedule.TemplateScheduleOptions{ + DefaultTTL: otherTTL, + }) + require.NoError(t, err) + + // We create two workspaces here, one with the template we're modifying, the + // other with a different template. We want to ensure we only modify one + // of the workspaces. + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: dbtime.Now(), + Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)}, + }) + otherWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: otherTemplate.ID, + OrganizationID: otherTemplateJob.OrganizationID, + LastUsedAt: dbtime.Now(), + Ttl: sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, + }) + + // Ensure the workspace's start with the correct TTLs + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)}, workspace.Ttl) + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, otherWorkspace.Ttl) + + // Update _only_ the primary template's TTL + _, err = templateScheduleStore.Set(ctx, db, template, schedule.TemplateScheduleOptions{ + DefaultTTL: tt.toTTL, + }) + require.NoError(t, err) + + // Verify the primary workspace's TTL has been updated. + ws, err := db.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, tt.expected, ws.Ttl) + + // Verify that the other workspace's TTL has not been touched. + ws, err = db.GetWorkspaceByID(ctx, otherWorkspace.ID) + require.NoError(t, err) + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, ws.Ttl) + }) + } +} diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 82ec97b531a5a..b1065aee7d2b6 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -195,6 +195,23 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S return xerrors.Errorf("get updated template schedule: %w", err) } + // Update all workspace's TTL using this template if either of the following: + // - The template's AllowUserAutostop has just been disabled + // - The template's TTL has been modified and AllowUserAutostop is disabled + if !opts.UserAutostopEnabled && (tpl.AllowUserAutostop || int64(opts.DefaultTTL) != tpl.DefaultTTL) { + var ttl sql.NullInt64 + 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, + }); err != nil { + return xerrors.Errorf("update workspaces ttl by template id %q: %w", template.ID, err) + } + } + // Recalculate max_deadline and deadline for all running workspace // builds on this template. err = s.updateWorkspaceBuilds(ctx, tx, template) diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index 5e3c9fd658cf3..712fa032c8c1b 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -19,6 +19,7 @@ import ( "github.com/coder/coder/v2/coderd/database/dbauthz" "github.com/coder/coder/v2/coderd/database/dbgen" "github.com/coder/coder/v2/coderd/database/dbtestutil" + "github.com/coder/coder/v2/coderd/database/dbtime" "github.com/coder/coder/v2/coderd/notifications" "github.com/coder/coder/v2/coderd/notifications/notificationstest" agplschedule "github.com/coder/coder/v2/coderd/schedule" @@ -708,6 +709,252 @@ func TestNotifications(t *testing.T) { }) } +func TestTemplateTTL(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + allowUserAutostop bool + fromTTL time.Duration + toTTL time.Duration + expected sql.NullInt64 + }{ + { + name: "AllowUserAutostopFalse/ModifyTTLDurationDown", + allowUserAutostop: false, + fromTTL: 24 * time.Hour, + toTTL: 1 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(1 * time.Hour)}, + }, + { + name: "AllowUserAutostopFalse/ModifyTTLDurationUp", + allowUserAutostop: false, + fromTTL: 24 * time.Hour, + toTTL: 36 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(36 * time.Hour)}, + }, + { + name: "AllowUserAutostopFalse/ModifyTTLDurationSame", + allowUserAutostop: false, + fromTTL: 24 * time.Hour, + toTTL: 24 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + { + name: "AllowUserAutostopFalse/DisableTTL", + allowUserAutostop: false, + fromTTL: 24 * time.Hour, + toTTL: 0, + expected: sql.NullInt64{}, + }, + { + name: "AllowUserAutostopTrue/ModifyTTLDurationDown", + allowUserAutostop: true, + fromTTL: 24 * time.Hour, + toTTL: 1 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + { + name: "AllowUserAutostopTrue/ModifyTTLDurationUp", + allowUserAutostop: true, + fromTTL: 24 * time.Hour, + toTTL: 36 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + { + name: "AllowUserAutostopTrue/ModifyTTLDurationSame", + allowUserAutostop: true, + fromTTL: 24 * time.Hour, + toTTL: 24 * time.Hour, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + { + name: "AllowUserAutostopTrue/DisableTTL", + allowUserAutostop: true, + fromTTL: 24 * time.Hour, + toTTL: 0, + expected: sql.NullInt64{Valid: true, Int64: int64(24 * time.Hour)}, + }, + } + + for _, tt := range tests { + tt := tt + + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var ( + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + db, _ = dbtestutil.NewDB(t) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{CreatedBy: user.ID}) + // Create first template + templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{"foo": "bar"}, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: templateJob.ID, + OrganizationID: templateJob.OrganizationID, + }) + template = dbgen.Template(t, db, database.Template{ + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + OrganizationID: templateJob.OrganizationID, + AllowUserAutostop: false, + }) + // Create second template + otherTTL = tt.fromTTL + 6*time.Hour + otherTemplateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{"foo": "bar"}, + }) + otherTemplateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: otherTemplateJob.ID, + OrganizationID: otherTemplateJob.OrganizationID, + }) + otherTemplate = dbgen.Template(t, db, database.Template{ + ActiveVersionID: otherTemplateVersion.ID, + CreatedBy: user.ID, + OrganizationID: otherTemplateJob.OrganizationID, + AllowUserAutostop: false, + }) + ) + + // Setup the template schedule store + notifyEnq := notifications.NewNoopEnqueuer() + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil) + + // Set both template's default TTL + template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + DefaultTTL: tt.fromTTL, + }) + require.NoError(t, err) + otherTemplate, err = templateScheduleStore.Set(ctx, db, otherTemplate, agplschedule.TemplateScheduleOptions{ + DefaultTTL: otherTTL, + }) + require.NoError(t, err) + + // We create two workspaces here, one with the template we're modifying, the + // other with a different template. We want to ensure we only modify one + // of the workspaces. + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: dbtime.Now(), + Ttl: sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)}, + }) + otherWorkspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: otherTemplate.ID, + OrganizationID: otherTemplateJob.OrganizationID, + LastUsedAt: dbtime.Now(), + Ttl: sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, + }) + + // Ensure the workspace's start with the correct TTLs + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(tt.fromTTL)}, workspace.Ttl) + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, otherWorkspace.Ttl) + + // Update _only_ the primary template's TTL + _, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + UserAutostopEnabled: tt.allowUserAutostop, + DefaultTTL: tt.toTTL, + }) + require.NoError(t, err) + + // Verify the primary workspace's TTL is what we expect + ws, err := db.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, tt.expected, ws.Ttl) + + // Verify we haven't changed the other workspace's TTL + ws, err = db.GetWorkspaceByID(ctx, otherWorkspace.ID) + require.NoError(t, err) + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(otherTTL)}, ws.Ttl) + }) + } + + t.Run("WorkspaceTTLUpdatedWhenAllowUserAutostopGetsDisabled", func(t *testing.T) { + t.Parallel() + + var ( + logger = slogtest.Make(t, &slogtest.Options{IgnoreErrors: true}).Leveled(slog.LevelDebug) + db, _ = dbtestutil.NewDB(t) + ctx = testutil.Context(t, testutil.WaitLong) + user = dbgen.User(t, db, database.User{}) + file = dbgen.File(t, db, database.File{CreatedBy: user.ID}) + // Create first template + templateJob = dbgen.ProvisionerJob(t, db, nil, database.ProvisionerJob{ + FileID: file.ID, + InitiatorID: user.ID, + Tags: database.StringMap{"foo": "bar"}, + }) + templateVersion = dbgen.TemplateVersion(t, db, database.TemplateVersion{ + CreatedBy: user.ID, + JobID: templateJob.ID, + OrganizationID: templateJob.OrganizationID, + }) + template = dbgen.Template(t, db, database.Template{ + ActiveVersionID: templateVersion.ID, + CreatedBy: user.ID, + OrganizationID: templateJob.OrganizationID, + }) + ) + + // Setup the template schedule store + notifyEnq := notifications.NewNoopEnqueuer() + const userQuietHoursSchedule = "CRON_TZ=UTC 0 0 * * *" // midnight UTC + userQuietHoursStore, err := schedule.NewEnterpriseUserQuietHoursScheduleStore(userQuietHoursSchedule, true) + require.NoError(t, err) + userQuietHoursStorePtr := &atomic.Pointer[agplschedule.UserQuietHoursScheduleStore]{} + userQuietHoursStorePtr.Store(&userQuietHoursStore) + templateScheduleStore := schedule.NewEnterpriseTemplateScheduleStore(userQuietHoursStorePtr, notifyEnq, logger, nil) + + // Enable AllowUserAutostop + template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + DefaultTTL: 24 * time.Hour, + UserAutostopEnabled: true, + }) + require.NoError(t, err) + + // Create a workspace with a TTL different than the template's default TTL + workspace := dbgen.Workspace(t, db, database.WorkspaceTable{ + OwnerID: user.ID, + TemplateID: template.ID, + OrganizationID: templateJob.OrganizationID, + LastUsedAt: dbtime.Now(), + Ttl: sql.NullInt64{Valid: true, Int64: int64(48 * time.Hour)}, + }) + + // Ensure the workspace start with the correct TTLs + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(48 * time.Hour)}, workspace.Ttl) + + // Disable AllowUserAutostop + template, err = templateScheduleStore.Set(ctx, db, template, agplschedule.TemplateScheduleOptions{ + DefaultTTL: 23 * time.Hour, + UserAutostopEnabled: false, + }) + require.NoError(t, err) + + // Ensure the workspace ends with the correct TTLs + ws, err := db.GetWorkspaceByID(ctx, workspace.ID) + require.NoError(t, err) + require.Equal(t, sql.NullInt64{Valid: true, Int64: int64(23 * time.Hour)}, ws.Ttl) + }) +} + func must[V any](v V, err error) V { if err != nil { panic(err)
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: