From 2537503fb48c21f547d0026da8164e3a89b71992 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Mon, 22 Jun 2026 16:47:07 +0000 Subject: [PATCH] feat(coderd/schedule): plumb time_til_autostop_notify template field Add the per-template time_til_autostop_notify duration (ns; 0=disabled) through the schedule store (AGPL + enterprise), codersdk Template/CreateTemplateRequest/UpdateTemplateMeta, the template create/patch handlers, and the CLI --autostop-reminder flag. Validation rejects negatives and sub-minute values. Not entitlement-gated, mirrors activity_bump. --- cli/templateedit.go | 23 ++- cli/templateedit_test.go | 3 + .../coder_templates_edit_--help.golden | 4 + coderd/apidoc/docs.go | 12 ++ coderd/apidoc/swagger.json | 12 ++ coderd/database/queries.sql.go | 5 +- coderd/database/queries/templates.sql | 3 +- coderd/schedule/template.go | 26 ++-- coderd/templates.go | 34 ++++- coderd/templates_meta_update.go | 2 + coderd/templates_meta_update_internal_test.go | 16 ++ coderd/templates_test.go | 144 ++++++++++++++++++ codersdk/organizations.go | 4 + codersdk/templates.go | 9 ++ docs/reference/api/schemas.md | 9 +- docs/reference/api/templatebuilder.md | 1 + docs/reference/api/templates.md | 12 +- docs/reference/cli/templates_edit.md | 8 + enterprise/coderd/schedule/template.go | 11 +- enterprise/coderd/schedule/template_test.go | 71 +++++++++ site/src/api/typesGenerated.ts | 19 +++ .../TemplateSettingsPage.test.tsx | 5 +- site/src/testHelpers/entities.ts | 1 + 23 files changed, 402 insertions(+), 32 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index 5871e82e9e25d..b71075a3f6f25 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -23,6 +23,7 @@ func (r *RootCmd) templateEdit() *serpent.Command { icon string defaultTTL time.Duration activityBump time.Duration + timeTilAutostopNotify time.Duration autostopRequirementDaysOfWeek []string autostopRequirementWeeks int64 autostartRequirementDaysOfWeek []string @@ -113,6 +114,10 @@ func (r *RootCmd) templateEdit() *serpent.Command { activityBump = time.Duration(template.ActivityBumpMillis) * time.Millisecond } + if !userSetOption(inv, "autostop-reminder") { + timeTilAutostopNotify = time.Duration(template.TimeTilAutostopNotifyMillis) * time.Millisecond + } + if !userSetOption(inv, "allow-user-autostop") { allowUserAutostop = template.AllowUserAutostop } @@ -174,12 +179,13 @@ func (r *RootCmd) templateEdit() *serpent.Command { } req := codersdk.UpdateTemplateMeta{ - Name: &name, - DisplayName: &displayName, - Description: &description, - Icon: &icon, - DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), - ActivityBumpMillis: ptr.Ref(activityBump.Milliseconds()), + Name: &name, + DisplayName: &displayName, + Description: &description, + Icon: &icon, + DefaultTTLMillis: ptr.Ref(defaultTTL.Milliseconds()), + ActivityBumpMillis: ptr.Ref(activityBump.Milliseconds()), + TimeTilAutostopNotifyMillis: ptr.Ref(timeTilAutostopNotify.Milliseconds()), AutostopRequirement: &codersdk.TemplateAutostopRequirement{ DaysOfWeek: autostopRequirementDaysOfWeek, Weeks: autostopRequirementWeeks, @@ -248,6 +254,11 @@ func (r *RootCmd) templateEdit() *serpent.Command { Description: "Edit the template activity bump - workspaces created from this template will have their shutdown time bumped by this value when activity is detected. Maps to \"Activity bump\" in the UI.", Value: serpent.DurationOf(&activityBump), }, + { + Flag: "autostop-reminder", + Description: "Edit how long before the autostop deadline a reminder notification is sent for workspaces created from this template. Set to 0 to disable.", + Value: serpent.DurationOf(&timeTilAutostopNotify), + }, { Flag: "autostart-requirement-weekdays", Description: "Edit the template autostart requirement weekdays - workspaces created from this template can only autostart on the given weekdays. To unset this value for the template (and allow autostart on all days), pass 'all'.", diff --git a/cli/templateedit_test.go b/cli/templateedit_test.go index 5d5cab0e12035..d6c8af82b0f33 100644 --- a/cli/templateedit_test.go +++ b/cli/templateedit_test.go @@ -44,6 +44,7 @@ func TestTemplateEdit(t *testing.T) { desc := "lorem ipsum dolor sit amet et cetera" icon := "/icon/new-icon.png" defaultTTL := 12 * time.Hour + timeTilAutostopNotify := 5 * time.Minute allowUserCancelWorkspaceJobs := false cmdArgs := []string{ @@ -55,6 +56,7 @@ func TestTemplateEdit(t *testing.T) { "--description", desc, "--icon", icon, "--default-ttl", defaultTTL.String(), + "--autostop-reminder", timeTilAutostopNotify.String(), "--allow-user-cancel-workspace-jobs=" + strconv.FormatBool(allowUserCancelWorkspaceJobs), } inv, root := clitest.New(t, cmdArgs...) @@ -73,6 +75,7 @@ func TestTemplateEdit(t *testing.T) { assert.Equal(t, desc, updated.Description) assert.Equal(t, icon, updated.Icon) assert.Equal(t, defaultTTL.Milliseconds(), updated.DefaultTTLMillis) + assert.Equal(t, timeTilAutostopNotify.Milliseconds(), updated.TimeTilAutostopNotifyMillis) assert.Equal(t, allowUserCancelWorkspaceJobs, updated.AllowUserCancelWorkspaceJobs) }) t.Run("FirstEmptyThenNotModified", func(t *testing.T) { diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index baa7999604f06..979f46b48a317 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -31,6 +31,10 @@ OPTIONS: this value for the template (and allow autostart on all days), pass 'all'. + --autostop-reminder duration + Edit how long before the autostop deadline a reminder notification is + sent for workspaces created from this template. Set to 0 to disable. + --autostop-requirement-weekdays [monday|tuesday|wednesday|thursday|friday|saturday|sunday|none] Edit the template autostop requirement weekdays - workspaces created from this template must be restarted on the given weekdays. To unset diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 3ddd4fdd33039..6aaf1ce5961d1 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -18440,6 +18440,10 @@ const docTemplate = `{ "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", "format": "uuid" + }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis allows optionally specifying the duration\nbefore the autostop deadline at which a reminder notification is sent for\nworkspaces created from this template. Defaults to 0 (disabled).", + "type": "integer" } } }, @@ -23735,6 +23739,10 @@ const docTemplate = `{ "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", "type": "boolean" }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis is the duration before the workspace's\nautostop deadline at which a reminder notification is sent. 0 disables\nthe notification.", + "type": "integer" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, @@ -25022,6 +25030,10 @@ const docTemplate = `{ "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", "type": "boolean" }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis allows optionally specifying the duration\nbefore the autostop deadline at which a reminder notification is sent for\nworkspaces created from this template. Defaults to 0 (disabled). Omitting\nthe field keeps the existing value.", + "type": "integer" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index e089738cd31c8..ab113d9894392 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -16674,6 +16674,10 @@ "description": "VersionID is an in-progress or completed job to use as an initial version\nof the template.\n\nThis is required on creation to enable a user-flow of validating a\ntemplate works. There is no reason the data-model cannot support empty\ntemplates, but it doesn't make sense for users.", "type": "string", "format": "uuid" + }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis allows optionally specifying the duration\nbefore the autostop deadline at which a reminder notification is sent for\nworkspaces created from this template. Defaults to 0 (disabled).", + "type": "integer" } } }, @@ -21772,6 +21776,10 @@ "description": "RequireActiveVersion mandates that workspaces are built with the active\ntemplate version.", "type": "boolean" }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis is the duration before the workspace's\nautostop deadline at which a reminder notification is sent. 0 disables\nthe notification.", + "type": "integer" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, @@ -22995,6 +23003,10 @@ "description": "RequireActiveVersion mandates workspaces built using this template\nuse the active version of the template. This option has no\neffect on template admins.", "type": "boolean" }, + "time_til_autostop_notify_ms": { + "description": "TimeTilAutostopNotifyMillis allows optionally specifying the duration\nbefore the autostop deadline at which a reminder notification is sent for\nworkspaces created from this template. Defaults to 0 (disabled). Omitting\nthe field keeps the existing value.", + "type": "integer" + }, "time_til_dormant_autodelete_ms": { "type": "integer" }, diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fdf442549348f..f2d26920e541e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -27013,7 +27013,8 @@ SET autostart_block_days_of_week = $9, failure_ttl = $10, time_til_dormant = $11, - time_til_dormant_autodelete = $12 + time_til_dormant_autodelete = $12, + time_til_autostop_notify = $13 WHERE id = $1 ` @@ -27031,6 +27032,7 @@ type UpdateTemplateScheduleByIDParams struct { FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` TimeTilDormant int64 `db:"time_til_dormant" json:"time_til_dormant"` TimeTilDormantAutoDelete int64 `db:"time_til_dormant_autodelete" json:"time_til_dormant_autodelete"` + TimeTilAutostopNotify int64 `db:"time_til_autostop_notify" json:"time_til_autostop_notify"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) error { @@ -27047,6 +27049,7 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.FailureTTL, arg.TimeTilDormant, arg.TimeTilDormantAutoDelete, + arg.TimeTilAutostopNotify, ) return err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index eb6ada1972da3..dc9b72223be35 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -193,7 +193,8 @@ SET autostart_block_days_of_week = $9, failure_ttl = $10, time_til_dormant = $11, - time_til_dormant_autodelete = $12 + time_til_dormant_autodelete = $12, + time_til_autostop_notify = $13 WHERE id = $1 ; diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index 0e3d3306ab892..cbbe4836d765c 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -123,6 +123,10 @@ type TemplateScheduleOptions struct { // ActivityBump dictates the duration to bump the workspace's deadline by if // Coder detects activity from the user. A value of 0 means no bumping. ActivityBump time.Duration + // TimeTilAutostopNotify dictates how long before the workspace's autostop + // deadline a reminder notification should be sent. A value of 0 means + // disabled. + TimeTilAutostopNotify time.Duration // AutostopRequirement dictates when the workspace must be restarted. This // used to be handled by MaxTTL. AutostopRequirement TemplateAutostopRequirement @@ -178,10 +182,11 @@ func (*agplTemplateScheduleStore) Get(ctx context.Context, db database.Store, te return TemplateScheduleOptions{ // Disregard the values in the database, since user scheduling is an // enterprise feature. - UserAutostartEnabled: true, - UserAutostopEnabled: true, - DefaultTTL: time.Duration(tpl.DefaultTTL), - ActivityBump: time.Duration(tpl.ActivityBump), + UserAutostartEnabled: true, + UserAutostopEnabled: true, + DefaultTTL: time.Duration(tpl.DefaultTTL), + ActivityBump: time.Duration(tpl.ActivityBump), + TimeTilAutostopNotify: time.Duration(tpl.TimeTilAutostopNotify), // Disregard the values in the database, since AutostopRequirement, // FailureTTL, TimeTilDormant, and TimeTilDormantAutoDelete are enterprise features. AutostartRequirement: TemplateAutostartRequirement{ @@ -204,7 +209,9 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp ctx, span := tracing.StartSpan(ctx) defer span.End() - if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.ActivityBump) == tpl.ActivityBump { + if int64(opts.DefaultTTL) == tpl.DefaultTTL && + int64(opts.ActivityBump) == tpl.ActivityBump && + int64(opts.TimeTilAutostopNotify) == tpl.TimeTilAutostopNotify { // Avoid updating the UpdatedAt timestamp if nothing will be changed. return tpl, nil } @@ -212,10 +219,11 @@ func (*agplTemplateScheduleStore) Set(ctx context.Context, db database.Store, tp var template database.Template err := db.InTx(func(db database.Store) error { err := db.UpdateTemplateScheduleByID(ctx, database.UpdateTemplateScheduleByIDParams{ - ID: tpl.ID, - UpdatedAt: dbtime.Now(), - DefaultTTL: int64(opts.DefaultTTL), - ActivityBump: int64(opts.ActivityBump), + ID: tpl.ID, + UpdatedAt: dbtime.Now(), + DefaultTTL: int64(opts.DefaultTTL), + ActivityBump: int64(opts.ActivityBump), + TimeTilAutostopNotify: int64(opts.TimeTilAutostopNotify), // Don't allow changing these settings, but keep the value in the DB (to // avoid clearing settings if the license has an issue). AutostopRequirementDaysOfWeek: tpl.AutostopRequirementDaysOfWeek, diff --git a/coderd/templates.go b/coderd/templates.go index 9817382da0b07..933f46ed2b21d 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -303,6 +303,7 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque failureTTL time.Duration dormantTTL time.Duration dormantAutoDeletionTTL time.Duration + timeTilAutostopNotify time.Duration ) if createTemplate.DefaultTTLMillis != nil { defaultTTL = time.Duration(*createTemplate.DefaultTTLMillis) * time.Millisecond @@ -310,6 +311,9 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if createTemplate.ActivityBumpMillis != nil { activityBump = time.Duration(*createTemplate.ActivityBumpMillis) * time.Millisecond } + if createTemplate.TimeTilAutostopNotifyMillis != nil { + timeTilAutostopNotify = time.Duration(*createTemplate.TimeTilAutostopNotifyMillis) * time.Millisecond + } if createTemplate.AutostopRequirement != nil { autostopRequirementDaysOfWeek = createTemplate.AutostopRequirement.DaysOfWeek autostopRequirementWeeks = createTemplate.AutostopRequirement.Weeks @@ -343,6 +347,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque if activityBump < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."}) } + if timeTilAutostopNotify < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be a positive integer."}) + } else if timeTilAutostopNotify != 0 && timeTilAutostopNotify < time.Minute { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be 0 (disabled) or at least one minute."}) + } if len(autostopRequirementDaysOfWeek) > 0 { autostopRequirementDaysOfWeekParsed, err = codersdk.WeekdaysToBitmap(autostopRequirementDaysOfWeek) @@ -458,10 +467,11 @@ func (api *API) postTemplateByOrganization(rw http.ResponseWriter, r *http.Reque } dbTemplate, err = (*api.TemplateScheduleStore.Load()).Set(ctx, tx, dbTemplate, schedule.TemplateScheduleOptions{ - UserAutostartEnabled: allowUserAutostart, - UserAutostopEnabled: allowUserAutostop, - DefaultTTL: defaultTTL, - ActivityBump: activityBump, + UserAutostartEnabled: allowUserAutostart, + UserAutostopEnabled: allowUserAutostop, + DefaultTTL: defaultTTL, + ActivityBump: activityBump, + TimeTilAutostopNotify: timeTilAutostopNotify, // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. @@ -693,6 +703,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if resolved.activityBumpMillis < 0 { validErrs = append(validErrs, codersdk.ValidationError{Field: "activity_bump_ms", Detail: "Must be a positive integer."}) } + if resolved.timeTilAutostopNotifyMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be a positive integer."}) + } else if resolved.timeTilAutostopNotifyMillis != 0 && time.Duration(resolved.timeTilAutostopNotifyMillis)*time.Millisecond < time.Minute { + validErrs = append(validErrs, codersdk.ValidationError{Field: "time_til_autostop_notify_ms", Detail: "Must be 0 (disabled) or at least one minute."}) + } if resolved.autostopRequirementWeeks > schedule.MaxTemplateAutostopRequirementWeeks { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)}) } @@ -793,6 +808,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(resolved.defaultTTLMillis) * time.Millisecond activityBump := time.Duration(resolved.activityBumpMillis) * time.Millisecond + timeTilAutostopNotify := time.Duration(resolved.timeTilAutostopNotifyMillis) * time.Millisecond failureTTL := time.Duration(resolved.failureTTLMillis) * time.Millisecond inactivityTTL := time.Duration(resolved.timeTilDormantMillis) * time.Millisecond timeTilDormantAutoDelete := time.Duration(resolved.timeTilDormantAutoDeleteMillis) * time.Millisecond @@ -808,10 +824,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { // Some of these values are enterprise-only, but the // TemplateScheduleStore will handle avoiding setting them if // unlicensed. - UserAutostartEnabled: resolved.allowUserAutostart, - UserAutostopEnabled: resolved.allowUserAutostop, - DefaultTTL: defaultTTL, - ActivityBump: activityBump, + UserAutostartEnabled: resolved.allowUserAutostart, + UserAutostopEnabled: resolved.allowUserAutostop, + DefaultTTL: defaultTTL, + ActivityBump: activityBump, + TimeTilAutostopNotify: timeTilAutostopNotify, AutostopRequirement: schedule.TemplateAutostopRequirement{ DaysOfWeek: resolved.autostopRequirementDaysOfWeekParsed, Weeks: resolved.autostopRequirementWeeks, @@ -1020,6 +1037,7 @@ func (api *API) convertTemplate( Icon: template.Icon, DefaultTTLMillis: time.Duration(template.DefaultTTL).Milliseconds(), ActivityBumpMillis: time.Duration(template.ActivityBump).Milliseconds(), + TimeTilAutostopNotifyMillis: time.Duration(template.TimeTilAutostopNotify).Milliseconds(), CreatedByID: template.CreatedBy, CreatedByName: template.CreatedByUsername, AllowUserAutostart: template.AllowUserAutostart, diff --git a/coderd/templates_meta_update.go b/coderd/templates_meta_update.go index 8dfc55eb61ef8..f239837837782 100644 --- a/coderd/templates_meta_update.go +++ b/coderd/templates_meta_update.go @@ -22,6 +22,7 @@ type templateMetaUpdate struct { icon string defaultTTLMillis int64 activityBumpMillis int64 + timeTilAutostopNotifyMillis int64 failureTTLMillis int64 timeTilDormantMillis int64 timeTilDormantAutoDeleteMillis int64 @@ -73,6 +74,7 @@ func resolveTemplateMetaUpdate( icon: ptr.NilToDefault(req.Icon, template.Icon), defaultTTLMillis: ptr.NilToDefault(req.DefaultTTLMillis, time.Duration(template.DefaultTTL).Milliseconds()), activityBumpMillis: ptr.NilToDefault(req.ActivityBumpMillis, time.Duration(template.ActivityBump).Milliseconds()), + timeTilAutostopNotifyMillis: ptr.NilToDefault(req.TimeTilAutostopNotifyMillis, time.Duration(template.TimeTilAutostopNotify).Milliseconds()), failureTTLMillis: ptr.NilToDefault(req.FailureTTLMillis, time.Duration(template.FailureTTL).Milliseconds()), timeTilDormantMillis: ptr.NilToDefault(req.TimeTilDormantMillis, time.Duration(template.TimeTilDormant).Milliseconds()), timeTilDormantAutoDeleteMillis: ptr.NilToDefault(req.TimeTilDormantAutoDeleteMillis, time.Duration(template.TimeTilDormantAutoDelete).Milliseconds()), diff --git a/coderd/templates_meta_update_internal_test.go b/coderd/templates_meta_update_internal_test.go index 3ef2a462d3c57..919663659105f 100644 --- a/coderd/templates_meta_update_internal_test.go +++ b/coderd/templates_meta_update_internal_test.go @@ -31,6 +31,7 @@ func baselineTemplate() database.Template { RequireActiveVersion: true, DefaultTTL: int64(60 * 60 * 1000 * 1000 * 1000), // 1 hour in ns ActivityBump: int64(30 * 60 * 1000 * 1000 * 1000), // 30 minutes in ns + TimeTilAutostopNotify: int64(10 * 60 * 1000 * 1000 * 1000), // 10 minutes in ns FailureTTL: int64(120 * 60 * 1000 * 1000 * 1000), // 2 hours in ns TimeTilDormant: int64(240 * 60 * 1000 * 1000 * 1000), // 4 hours in ns TimeTilDormantAutoDelete: int64(480 * 60 * 1000 * 1000 * 1000), // 8 hours in ns @@ -73,6 +74,7 @@ func baselineResolved() templateMetaUpdate { icon: tpl.Icon, defaultTTLMillis: tpl.DefaultTTL / 1e6, activityBumpMillis: tpl.ActivityBump / 1e6, + timeTilAutostopNotifyMillis: tpl.TimeTilAutostopNotify / 1e6, failureTTLMillis: tpl.FailureTTL / 1e6, timeTilDormantMillis: tpl.TimeTilDormant / 1e6, timeTilDormantAutoDeleteMillis: tpl.TimeTilDormantAutoDelete / 1e6, @@ -176,6 +178,20 @@ func TestResolveTemplateMetaUpdate(t *testing.T) { r.activityBumpMillis = 900_000 }}, }, + { + name: "TimeTilAutostopNotifyMillis", + req: codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: ptr.Ref(int64(300_000))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.timeTilAutostopNotifyMillis = 300_000 + }}, + }, + { + name: "TimeTilAutostopNotifyMillisZeroExplicit", + req: codersdk.UpdateTemplateMeta{TimeTilAutostopNotifyMillis: ptr.Ref(int64(0))}, + expected: expected{override: func(r *templateMetaUpdate) { + r.timeTilAutostopNotifyMillis = 0 + }}, + }, { name: "AllowUserAutostart", req: codersdk.UpdateTemplateMeta{AllowUserAutostart: ptr.Ref(true)}, diff --git a/coderd/templates_test.go b/coderd/templates_test.go index da7f660cf0a3d..8c23c0e596b94 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -67,8 +67,10 @@ func TestPostTemplateByOrganization(t *testing.T) { expected := coderdtest.CreateTemplate(t, client, owner.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { ctr.ActivityBumpMillis = ptr.Ref((3 * time.Hour).Milliseconds()) + ctr.TimeTilAutostopNotifyMillis = ptr.Ref((5 * time.Minute).Milliseconds()) }) assert.Equal(t, (3 * time.Hour).Milliseconds(), expected.ActivityBumpMillis) + assert.Equal(t, (5 * time.Minute).Milliseconds(), expected.TimeTilAutostopNotifyMillis) ctx := testutil.Context(t, testutil.WaitLong) @@ -78,6 +80,7 @@ func TestPostTemplateByOrganization(t *testing.T) { assert.Equal(t, expected.Name, got.Name) assert.Equal(t, expected.Description, got.Description) assert.Equal(t, expected.ActivityBumpMillis, got.ActivityBumpMillis) + assert.Equal(t, expected.TimeTilAutostopNotifyMillis, got.TimeTilAutostopNotifyMillis) assert.Equal(t, expected.UseClassicParameterFlow, false) // Current default is false require.Len(t, auditor.AuditLogs(), 3) @@ -141,6 +144,62 @@ func TestPostTemplateByOrganization(t *testing.T) { require.Contains(t, err.Error(), "default_ttl_ms: Must be a positive integer") }) + t.Run("TimeTilAutostopNotifyTooLow", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + TimeTilAutostopNotifyMillis: ptr.Ref(int64(30_000)), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + assert.Equal(t, "time_til_autostop_notify_ms", apiErr.Validations[0].Field) + assert.Equal(t, "Must be 0 (disabled) or at least one minute.", apiErr.Validations[0].Detail) + }) + + t.Run("TimeTilAutostopNotifyExactlyOneMinute", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + got, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + TimeTilAutostopNotifyMillis: ptr.Ref(time.Minute.Milliseconds()), + }) + require.NoError(t, err) + assert.Equal(t, time.Minute.Milliseconds(), got.TimeTilAutostopNotifyMillis) + }) + + t.Run("TimeTilAutostopNotifyNegative", func(t *testing.T) { + t.Parallel() + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + + ctx := testutil.Context(t, testutil.WaitLong) + _, err := client.CreateTemplate(ctx, user.OrganizationID, codersdk.CreateTemplateRequest{ + Name: "testing", + VersionID: version.ID, + TimeTilAutostopNotifyMillis: ptr.Ref(int64(-1)), + }) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Equal(t, http.StatusBadRequest, apiErr.StatusCode()) + require.Len(t, apiErr.Validations, 1) + assert.Equal(t, "time_til_autostop_notify_ms", apiErr.Validations[0].Field) + assert.Equal(t, "Must be a positive integer.", apiErr.Validations[0].Detail) + }) + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() client := coderdtest.New(t, nil) @@ -907,6 +966,7 @@ func TestPatchTemplateMeta(t *testing.T) { Icon: ptr.Ref("/icon/new-icon.png"), DefaultTTLMillis: ptr.Ref(12 * time.Hour.Milliseconds()), ActivityBumpMillis: ptr.Ref(3 * time.Hour.Milliseconds()), + TimeTilAutostopNotifyMillis: ptr.Ref(5 * time.Minute.Milliseconds()), AllowUserCancelWorkspaceJobs: ptr.Ref(false), } // It is unfortunate we need to sleep, but the test can fail if the @@ -924,6 +984,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, *req.Icon, updated.Icon) assert.Equal(t, *req.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) + assert.Equal(t, *req.TimeTilAutostopNotifyMillis, updated.TimeTilAutostopNotifyMillis) assert.False(t, *req.AllowUserCancelWorkspaceJobs) // Extra paranoid: did it _really_ happen? @@ -936,6 +997,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, *req.Icon, updated.Icon) assert.Equal(t, *req.DefaultTTLMillis, updated.DefaultTTLMillis) assert.Equal(t, *req.ActivityBumpMillis, updated.ActivityBumpMillis) + assert.Equal(t, *req.TimeTilAutostopNotifyMillis, updated.TimeTilAutostopNotifyMillis) assert.False(t, *req.AllowUserCancelWorkspaceJobs) require.Len(t, auditor.AuditLogs(), 5) @@ -1343,6 +1405,32 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, template.AllowUserAutostop, updated.AllowUserAutostop) }) + t.Run("TimeTilAutostopNotifyPreservedWhenOmitted", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilAutostopNotifyMillis = ptr.Ref((5 * time.Minute).Milliseconds()) + }) + require.Equal(t, (5 * time.Minute).Milliseconds(), template.TimeTilAutostopNotifyMillis) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Patch an unrelated field, omitting TimeTilAutostopNotifyMillis. + req := codersdk.UpdateTemplateMeta{ + Description: ptr.Ref("updated description"), + } + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + + updated, err := client.Template(ctx, template.ID) + require.NoError(t, err) + assert.Equal(t, "updated description", updated.Description) + assert.Equal(t, template.TimeTilAutostopNotifyMillis, updated.TimeTilAutostopNotifyMillis) + }) + t.Run("Invalid", func(t *testing.T) { t.Parallel() @@ -1375,6 +1463,62 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Equal(t, template.DefaultTTLMillis, updated.DefaultTTLMillis) }) + t.Run("TimeTilAutostopNotifyInvalid", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Sub-minute (non-zero) values are rejected. + req := codersdk.UpdateTemplateMeta{ + TimeTilAutostopNotifyMillis: ptr.Ref(int64(30_000)), + } + _, err := client.UpdateTemplateMeta(ctx, template.ID, req) + var apiErr *codersdk.Error + require.ErrorAs(t, err, &apiErr) + require.Contains(t, apiErr.Message, "Invalid request") + require.Len(t, apiErr.Validations, 1) + assert.Equal(t, "time_til_autostop_notify_ms", apiErr.Validations[0].Field) + assert.Equal(t, "Must be 0 (disabled) or at least one minute.", apiErr.Validations[0].Detail) + + // Negative values are rejected. + req = codersdk.UpdateTemplateMeta{ + TimeTilAutostopNotifyMillis: ptr.Ref(int64(-1)), + } + _, err = client.UpdateTemplateMeta(ctx, template.ID, req) + require.ErrorAs(t, err, &apiErr) + require.Contains(t, apiErr.Message, "Invalid request") + require.Len(t, apiErr.Validations, 1) + assert.Equal(t, "time_til_autostop_notify_ms", apiErr.Validations[0].Field) + assert.Equal(t, "Must be a positive integer.", apiErr.Validations[0].Detail) + }) + + t.Run("TimeTilAutostopNotifyDisableAfterEnable", func(t *testing.T) { + t.Parallel() + + client := coderdtest.New(t, nil) + user := coderdtest.CreateFirstUser(t, client) + version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil) + template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID, func(ctr *codersdk.CreateTemplateRequest) { + ctr.TimeTilAutostopNotifyMillis = ptr.Ref((5 * time.Minute).Milliseconds()) + }) + require.Equal(t, (5 * time.Minute).Milliseconds(), template.TimeTilAutostopNotifyMillis) + + ctx := testutil.Context(t, testutil.WaitLong) + + // Explicitly disable by sending 0, which must not be treated as omitted. + req := codersdk.UpdateTemplateMeta{ + TimeTilAutostopNotifyMillis: ptr.Ref(int64(0)), + } + updated, err := client.UpdateTemplateMeta(ctx, template.ID, req) + require.NoError(t, err) + assert.Equal(t, int64(0), updated.TimeTilAutostopNotifyMillis) + }) + t.Run("RemoveIcon", func(t *testing.T) { t.Parallel() diff --git a/codersdk/organizations.go b/codersdk/organizations.go index 63ea3cd0c3b83..5d949860db37c 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -170,6 +170,10 @@ type CreateTemplateRequest struct { // duration for all workspaces created from this template. Defaults to 1h // but can be set to 0 to disable activity bumping. ActivityBumpMillis *int64 `json:"activity_bump_ms,omitempty"` + // TimeTilAutostopNotifyMillis allows optionally specifying the duration + // before the autostop deadline at which a reminder notification is sent for + // workspaces created from this template. Defaults to 0 (disabled). + TimeTilAutostopNotifyMillis *int64 `json:"time_til_autostop_notify_ms,omitempty"` // AutostopRequirement allows optionally specifying the autostop requirement // for workspaces created from this template. This is an enterprise feature. AutostopRequirement *TemplateAutostopRequirement `json:"autostop_requirement,omitempty"` diff --git a/codersdk/templates.go b/codersdk/templates.go index 87fea25fb9cc2..ba1f287709c08 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -36,6 +36,10 @@ type Template struct { Icon string `json:"icon"` DefaultTTLMillis int64 `json:"default_ttl_ms"` ActivityBumpMillis int64 `json:"activity_bump_ms"` + // TimeTilAutostopNotifyMillis is the duration before the workspace's + // autostop deadline at which a reminder notification is sent. 0 disables + // the notification. + TimeTilAutostopNotifyMillis int64 `json:"time_til_autostop_notify_ms"` // AutostopRequirement and AutostartRequirement are enterprise features. Its // value is only used if your license is entitled to use the advanced template // scheduling feature. @@ -227,6 +231,11 @@ type UpdateTemplateMeta struct { // duration for all workspaces created from this template. Defaults to 1h // but can be set to 0 to disable activity bumping. ActivityBumpMillis *int64 `json:"activity_bump_ms,omitempty"` + // TimeTilAutostopNotifyMillis allows optionally specifying the duration + // before the autostop deadline at which a reminder notification is sent for + // workspaces created from this template. Defaults to 0 (disabled). Omitting + // the field keeps the existing value. + TimeTilAutostopNotifyMillis *int64 `json:"time_til_autostop_notify_ms,omitempty"` // AutostopRequirement and AutostartRequirement can only be set if your license // includes the advanced template scheduling feature. If you attempt to set this // value while unlicensed, it will be ignored. diff --git a/docs/reference/api/schemas.md b/docs/reference/api/schemas.md index 9b3e42de28259..0803c6ea4502e 100644 --- a/docs/reference/api/schemas.md +++ b/docs/reference/api/schemas.md @@ -4971,7 +4971,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "name": "string", "require_active_version": true, "template_use_classic_parameter_flow": true, - "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "time_til_autostop_notify_ms": 0 } ``` @@ -5000,6 +5001,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `template_use_classic_parameter_flow` | boolean | false | | Template use classic parameter flow allows optionally specifying whether the template should use the classic parameter flow. The default if unset is true, and is why `*bool` is used here. When dynamic parameters becomes the default, this will default to false. | |`template_version_id`|string|true||Template version ID is an in-progress or completed job to use as an initial version of the template. This is required on creation to enable a user-flow of validating a template works. There is no reason the data-model cannot support empty templates, but it doesn't make sense for users.| +|`time_til_autostop_notify_ms`|integer|false||Time til autostop notify ms allows optionally specifying the duration before the autostop deadline at which a reminder notification is sent for workspaces created from this template. Defaults to 0 (disabled).| ## codersdk.CreateTemplateVersionDryRunRequest @@ -11884,6 +11886,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -11926,6 +11929,7 @@ Only certain features set these fields: - FeatureManagedAgentLimit| | `organization_name` | string | false | | | | `provisioner` | string | false | | | | `require_active_version` | boolean | false | | Require active version mandates that workspaces are built with the active template version. | +| `time_til_autostop_notify_ms` | integer | false | | Time til autostop notify ms is the duration before the workspace's autostop deadline at which a reminder notification is sent. 0 disables the notification. | | `time_til_dormant_autodelete_ms` | integer | false | | | | `time_til_dormant_ms` | integer | false | | | | `updated_at` | string | false | | | @@ -12343,6 +12347,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -13468,6 +13473,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W "max_port_share_level": "owner", "name": "string", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "update_workspace_dormant_at": true, @@ -13498,6 +13504,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W | `max_port_share_level` | [codersdk.WorkspaceAgentPortShareLevel](#codersdkworkspaceagentportsharelevel) | false | | | | `name` | string | false | | | | `require_active_version` | boolean | false | | Require active version mandates workspaces built using this template use the active version of the template. This option has no effect on template admins. | +| `time_til_autostop_notify_ms` | integer | false | | Time til autostop notify ms allows optionally specifying the duration before the autostop deadline at which a reminder notification is sent for workspaces created from this template. Defaults to 0 (disabled). Omitting the field keeps the existing value. | | `time_til_dormant_autodelete_ms` | integer | false | | | | `time_til_dormant_ms` | integer | false | | | | `update_workspace_dormant_at` | boolean | false | | Update workspace dormant at updates the dormant_at field of workspaces spawned from the template. This is useful for preventing dormant workspaces being immediately deleted when updating the dormant_ttl field to a new, shorter value. | diff --git a/docs/reference/api/templatebuilder.md b/docs/reference/api/templatebuilder.md index 05ba6cc720aba..c908f62d20730 100644 --- a/docs/reference/api/templatebuilder.md +++ b/docs/reference/api/templatebuilder.md @@ -205,6 +205,7 @@ curl -X POST http://coder-server:8080/api/v2/templatebuilder/compose/template \ "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/api/templates.md b/docs/reference/api/templates.md index 6deddeb2a53dd..e5c8b6e665f45 100644 --- a/docs/reference/api/templates.md +++ b/docs/reference/api/templates.md @@ -79,6 +79,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -138,6 +139,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |`» organization_name`|string(url)|false||| |`» provisioner`|string|false||| |`» require_active_version`|boolean|false||Require active version mandates that workspaces are built with the active template version.| +|`» time_til_autostop_notify_ms`|integer|false||Time til autostop notify ms is the duration before the workspace's autostop deadline at which a reminder notification is sent. 0 disables the notification.| |`» time_til_dormant_autodelete_ms`|integer|false||| |`» time_til_dormant_ms`|integer|false||| |`» updated_at`|string(date-time)|false||| @@ -199,7 +201,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "name": "string", "require_active_version": true, "template_use_classic_parameter_flow": true, - "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1" + "template_version_id": "0ba39c92-1f1b-4c32-aa3e-9925d7713eb1", + "time_til_autostop_notify_ms": 0 } ``` @@ -265,6 +268,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -417,6 +421,7 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -839,6 +844,7 @@ To include deprecated templates, specify `deprecated:true` in the search query. "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -898,6 +904,7 @@ Restarts will only happen on weekdays in this list on weeks which line up with W |`» organization_name`|string(url)|false||| |`» provisioner`|string|false||| |`» require_active_version`|boolean|false||Require active version mandates that workspaces are built with the active template version.| +|`» time_til_autostop_notify_ms`|integer|false||Time til autostop notify ms is the duration before the workspace's autostop deadline at which a reminder notification is sent. 0 disables the notification.| |`» time_til_dormant_autodelete_ms`|integer|false||| |`» time_til_dormant_ms`|integer|false||| |`» updated_at`|string(date-time)|false||| @@ -1043,6 +1050,7 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", @@ -1147,6 +1155,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "max_port_share_level": "owner", "name": "string", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "update_workspace_dormant_at": true, @@ -1217,6 +1226,7 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "organization_name": "string", "provisioner": "terraform", "require_active_version": true, + "time_til_autostop_notify_ms": 0, "time_til_dormant_autodelete_ms": 0, "time_til_dormant_ms": 0, "updated_at": "2019-08-24T14:15:22Z", diff --git a/docs/reference/cli/templates_edit.md b/docs/reference/cli/templates_edit.md index 069e7d7a6b679..422f9c8638470 100644 --- a/docs/reference/cli/templates_edit.md +++ b/docs/reference/cli/templates_edit.md @@ -67,6 +67,14 @@ Edit the template default time before shutdown - workspaces created from this te Edit the template activity bump - workspaces created from this template will have their shutdown time bumped by this value when activity is detected. Maps to "Activity bump" in the UI. +### --autostop-reminder + +| | | +|------|-----------------------| +| Type | duration | + +Edit how long before the autostop deadline a reminder notification is sent for workspaces created from this template. Set to 0 to disable. + ### --autostart-requirement-weekdays | | | diff --git a/enterprise/coderd/schedule/template.go b/enterprise/coderd/schedule/template.go index 809a851798a3f..6660a664c0c10 100644 --- a/enterprise/coderd/schedule/template.go +++ b/enterprise/coderd/schedule/template.go @@ -84,10 +84,11 @@ func (*EnterpriseTemplateScheduleStore) Get(ctx context.Context, db database.Sto } return agpl.TemplateScheduleOptions{ - UserAutostartEnabled: tpl.AllowUserAutostart, - UserAutostopEnabled: tpl.AllowUserAutostop, - DefaultTTL: time.Duration(tpl.DefaultTTL), - ActivityBump: time.Duration(tpl.ActivityBump), + UserAutostartEnabled: tpl.AllowUserAutostart, + UserAutostopEnabled: tpl.AllowUserAutostop, + DefaultTTL: time.Duration(tpl.DefaultTTL), + ActivityBump: time.Duration(tpl.ActivityBump), + TimeTilAutostopNotify: time.Duration(tpl.TimeTilAutostopNotify), AutostopRequirement: agpl.TemplateAutostopRequirement{ // #nosec G115 - Safe conversion as we've verified tpl.AutostopRequirementDaysOfWeek is <= 255 DaysOfWeek: uint8(tpl.AutostopRequirementDaysOfWeek), @@ -116,6 +117,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.ActivityBump) == tpl.ActivityBump && + int64(opts.TimeTilAutostopNotify) == tpl.TimeTilAutostopNotify && int16(opts.AutostopRequirement.DaysOfWeek) == tpl.AutostopRequirementDaysOfWeek && opts.AutostartRequirement.DaysOfWeek == tpl.AutostartAllowedDays() && opts.AutostopRequirement.Weeks == tpl.AutostopRequirementWeeks && @@ -153,6 +155,7 @@ func (s *EnterpriseTemplateScheduleStore) Set(ctx context.Context, db database.S AllowUserAutostop: opts.UserAutostopEnabled, DefaultTTL: int64(opts.DefaultTTL), ActivityBump: int64(opts.ActivityBump), + TimeTilAutostopNotify: int64(opts.TimeTilAutostopNotify), AutostopRequirementDaysOfWeek: int16(opts.AutostopRequirement.DaysOfWeek), AutostopRequirementWeeks: opts.AutostopRequirement.Weeks, // Database stores the inverse of the allowed days of the week. diff --git a/enterprise/coderd/schedule/template_test.go b/enterprise/coderd/schedule/template_test.go index ada77b0dfcb3f..f97de3f7cc839 100644 --- a/enterprise/coderd/schedule/template_test.go +++ b/enterprise/coderd/schedule/template_test.go @@ -1385,6 +1385,77 @@ func TestTemplateUpdatePrebuilds(t *testing.T) { } } +// TestTemplateScheduleTimeTilAutostopNotify verifies that the enterprise +// template schedule store round-trips the TimeTilAutostopNotify field through +// Set/Get, and that the field participates in the no-op short-circuit +// comparison in Set. If the short-circuit compared the wrong field, an update +// that changes only TimeTilAutostopNotify would be silently swallowed. +func TestTemplateScheduleTimeTilAutostopNotify(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}) + + 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) + + // baseOpts is reused across Set calls so that only TimeTilAutostopNotify + // differs between the first and second update. This is what exercises the + // no-op short-circuit guard: every other compared field stays identical. + baseOpts := agplschedule.TemplateScheduleOptions{ + DefaultTTL: 24 * time.Hour, + } + + // Set then Get round-trip: TimeTilAutostopNotify should persist. + firstOpts := baseOpts + firstOpts.TimeTilAutostopNotify = time.Hour + template, err = templateScheduleStore.Set(ctx, db, template, firstOpts) + require.NoError(t, err) + + gotOpts, err := templateScheduleStore.Get(ctx, db, template.ID) + require.NoError(t, err) + require.Equal(t, time.Hour, gotOpts.TimeTilAutostopNotify) + + // No-op short-circuit guard: change ONLY TimeTilAutostopNotify and keep all + // other options identical. If the short-circuit compared the wrong field, + // this update would be treated as a no-op and the value would remain 1h. + secondOpts := baseOpts + secondOpts.TimeTilAutostopNotify = 2 * time.Hour + template, err = templateScheduleStore.Set(ctx, db, template, secondOpts) + require.NoError(t, err) + + gotOpts, err = templateScheduleStore.Get(ctx, db, template.ID) + require.NoError(t, err) + require.Equal(t, 2*time.Hour, gotOpts.TimeTilAutostopNotify) +} + func must[V any](v V, err error) V { if err != nil { panic(err) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9bbc885540862..4ceacb58107f4 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -3693,6 +3693,12 @@ export interface CreateTemplateRequest { * but can be set to 0 to disable activity bumping. */ readonly activity_bump_ms?: number; + /** + * TimeTilAutostopNotifyMillis allows optionally specifying the duration + * before the autostop deadline at which a reminder notification is sent for + * workspaces created from this template. Defaults to 0 (disabled). + */ + readonly time_til_autostop_notify_ms?: number; /** * AutostopRequirement allows optionally specifying the autostop requirement * for workspaces created from this template. This is an enterprise feature. @@ -8175,6 +8181,12 @@ export interface Template { readonly icon: string; readonly default_ttl_ms: number; readonly activity_bump_ms: number; + /** + * TimeTilAutostopNotifyMillis is the duration before the workspace's + * autostop deadline at which a reminder notification is sent. 0 disables + * the notification. + */ + readonly time_til_autostop_notify_ms: number; /** * AutostopRequirement and AutostartRequirement are enterprise features. Its * value is only used if your license is entitled to use the advanced template @@ -9112,6 +9124,13 @@ export interface UpdateTemplateMeta { * but can be set to 0 to disable activity bumping. */ readonly activity_bump_ms?: number; + /** + * TimeTilAutostopNotifyMillis allows optionally specifying the duration + * before the autostop deadline at which a reminder notification is sent for + * workspaces created from this template. Defaults to 0 (disabled). Omitting + * the field keeps the existing value. + */ + readonly time_til_autostop_notify_ms?: number; /** * AutostopRequirement and AutostartRequirement can only be set if your license * includes the advanced template scheduling feature. If you attempt to set this diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index d87d9509ff066..153205c7cfe48 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -19,7 +19,10 @@ import TemplateSettingsPage from "./TemplateSettingsPage"; type FormValues = Required< Omit< UpdateTemplateMeta, - "default_ttl_ms" | "activity_bump_ms" | "deprecation_message" + | "default_ttl_ms" + | "activity_bump_ms" + | "time_til_autostop_notify_ms" + | "deprecation_message" > >; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7f18fc9b067e0..3559e702eb8ab 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -899,6 +899,7 @@ export const MockTemplate: TypesGen.Template = { description: "This is a test description.", default_ttl_ms: 24 * 60 * 60 * 1000, activity_bump_ms: 1 * 60 * 60 * 1000, + time_til_autostop_notify_ms: 0, autostop_requirement: { days_of_week: ["sunday"], weeks: 1,