feat: plumb time_til_autostop_notify template field (#26439)

This commit is contained in:
Jon Ayers
2026-06-23 17:32:47 -05:00
committed by GitHub
parent a11f349c16
commit 4cfed1b3ed
23 changed files with 403 additions and 32 deletions
+12
View File
@@ -18437,6 +18437,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"
}
}
},
@@ -23732,6 +23736,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"
},
+12
View File
@@ -16671,6 +16671,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"
}
}
},
@@ -21769,6 +21773,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"
},
+4 -1
View File
@@ -26837,7 +26837,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
`
@@ -26855,6 +26856,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 {
@@ -26871,6 +26873,7 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT
arg.FailureTTL,
arg.TimeTilDormant,
arg.TimeTilDormantAutoDelete,
arg.TimeTilAutostopNotify,
)
return err
}
+2 -1
View File
@@ -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
;
+17 -9
View File
@@ -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,
+26 -8
View File
@@ -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,
+2
View File
@@ -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()),
@@ -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)},
+144
View File
@@ -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()