diff --git a/.vscode/settings.json b/.vscode/settings.json index a14ed55e9d..fd36a7aac5 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -34,6 +34,7 @@ "Dsts", "embeddedpostgres", "enablements", + "enterprisemeta", "errgroup", "eventsourcemock", "Failf", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 65642323a2..2ad130318d 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -6719,10 +6719,18 @@ const docTemplate = `{ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "failure_ttl_ms": { + "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", + "type": "integer" + }, "icon": { "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "inactivity_ttl_ms": { + "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\ndeletes inactive workspaces created from this template.", + "type": "integer" + }, "max_ttl_ms": { "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", "type": "integer" @@ -8698,6 +8706,10 @@ const docTemplate = `{ "display_name": { "type": "string" }, + "failure_ttl_ms": { + "description": "FailureTTLMillis and InactivityTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "type": "integer" + }, "icon": { "type": "string" }, @@ -8705,6 +8717,9 @@ const docTemplate = `{ "type": "string", "format": "uuid" }, + "inactivity_ttl_ms": { + "type": "integer" + }, "max_ttl_ms": { "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", "type": "integer" diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index f1d59dfa2c..e2a593c1a4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -5978,10 +5978,18 @@ "description": "DisplayName is the displayed name of the template.", "type": "string" }, + "failure_ttl_ms": { + "description": "FailureTTLMillis allows optionally specifying the max lifetime before Coder\nstops all resources for failed workspaces created from this template.", + "type": "integer" + }, "icon": { "description": "Icon is a relative path or external URL that specifies\nan icon to be displayed in the dashboard.", "type": "string" }, + "inactivity_ttl_ms": { + "description": "InactivityTTLMillis allows optionally specifying the max lifetime before Coder\ndeletes inactive workspaces created from this template.", + "type": "integer" + }, "max_ttl_ms": { "description": "MaxTTLMillis allows optionally specifying the max lifetime for\nworkspaces created from this template.", "type": "integer" @@ -7816,6 +7824,10 @@ "display_name": { "type": "string" }, + "failure_ttl_ms": { + "description": "FailureTTLMillis and InactivityTTLMillis are enterprise-only. Their\nvalues are used if your license is entitled to use the advanced\ntemplate scheduling feature.", + "type": "integer" + }, "icon": { "type": "string" }, @@ -7823,6 +7835,9 @@ "type": "string", "format": "uuid" }, + "inactivity_ttl_ms": { + "type": "integer" + }, "max_ttl_ms": { "description": "MaxTTLMillis is an enterprise feature. It's value is only used if your\nlicense is entitled to use the advanced template scheduling feature.", "type": "integer" diff --git a/coderd/database/dbfake/databasefake.go b/coderd/database/dbfake/databasefake.go index 7f80385af2..f254ee2668 100644 --- a/coderd/database/dbfake/databasefake.go +++ b/coderd/database/dbfake/databasefake.go @@ -1945,6 +1945,8 @@ func (q *fakeQuerier) UpdateTemplateScheduleByID(_ context.Context, arg database tpl.UpdatedAt = database.Now() tpl.DefaultTTL = arg.DefaultTTL tpl.MaxTTL = arg.MaxTTL + tpl.FailureTTL = arg.FailureTTL + tpl.InactivityTTL = arg.InactivityTTL q.templates[idx] = tpl return tpl.DeepCopy(), nil } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index f4beff8e0d..6fd8b76ae1 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -478,7 +478,9 @@ CREATE TABLE templates ( allow_user_cancel_workspace_jobs boolean DEFAULT true NOT NULL, max_ttl bigint DEFAULT '0'::bigint NOT NULL, allow_user_autostart boolean DEFAULT true NOT NULL, - allow_user_autostop boolean DEFAULT true NOT NULL + allow_user_autostop boolean DEFAULT true NOT NULL, + failure_ttl bigint DEFAULT 0 NOT NULL, + inactivity_ttl bigint DEFAULT 0 NOT NULL ); COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.'; diff --git a/coderd/database/migrations/000122_add_template_cleanup_ttls.down.sql b/coderd/database/migrations/000122_add_template_cleanup_ttls.down.sql new file mode 100644 index 0000000000..78a04e961e --- /dev/null +++ b/coderd/database/migrations/000122_add_template_cleanup_ttls.down.sql @@ -0,0 +1,4 @@ +BEGIN; +ALTER TABLE ONLY templates DROP COLUMN IF EXISTS failure_ttl; +ALTER TABLE ONLY templates DROP COLUMN IF EXISTS inactivity_ttl; +COMMIT; diff --git a/coderd/database/migrations/000122_add_template_cleanup_ttls.up.sql b/coderd/database/migrations/000122_add_template_cleanup_ttls.up.sql new file mode 100644 index 0000000000..f043356375 --- /dev/null +++ b/coderd/database/migrations/000122_add_template_cleanup_ttls.up.sql @@ -0,0 +1,4 @@ +BEGIN; +ALTER TABLE ONLY templates ADD COLUMN IF NOT EXISTS failure_ttl BIGINT NOT NULL DEFAULT 0; +ALTER TABLE ONLY templates ADD COLUMN IF NOT EXISTS inactivity_ttl BIGINT NOT NULL DEFAULT 0; +COMMIT; diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 162b91cf5d..8a105ec52c 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -80,6 +80,8 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ); err != nil { return nil, err } diff --git a/coderd/database/models.go b/coderd/database/models.go index 61d4600ad8..82a1a0ca80 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -1436,7 +1436,9 @@ type Template struct { // Allow users to specify an autostart schedule for workspaces (enterprise). AllowUserAutostart bool `db:"allow_user_autostart" json:"allow_user_autostart"` // Allow users to specify custom autostop values for workspaces (enterprise). - AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` } type TemplateVersion struct { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index 0ee1f3bc9a..5b21b8cc73 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -3490,7 +3490,7 @@ func (q *sqlQuerier) GetTemplateAverageBuildTime(ctx context.Context, arg GetTem const getTemplateByID = `-- name: GetTemplateByID :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl FROM templates WHERE @@ -3522,13 +3522,15 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } const getTemplateByOrganizationAndName = `-- name: GetTemplateByOrganizationAndName :one SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl FROM templates WHERE @@ -3568,12 +3570,14 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } const getTemplates = `-- name: GetTemplates :many -SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop FROM templates +SELECT id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl FROM templates ORDER BY (name, id) ASC ` @@ -3606,6 +3610,8 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ); err != nil { return nil, err } @@ -3622,7 +3628,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) { const getTemplatesWithFilter = `-- name: GetTemplatesWithFilter :many SELECT - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl FROM templates WHERE @@ -3692,6 +3698,8 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ); err != nil { return nil, err } @@ -3725,7 +3733,7 @@ INSERT INTO allow_user_cancel_workspace_jobs ) VALUES - ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) RETURNING id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl ` type InsertTemplateParams struct { @@ -3783,6 +3791,8 @@ func (q *sqlQuerier) InsertTemplate(ctx context.Context, arg InsertTemplateParam &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } @@ -3796,7 +3806,7 @@ SET WHERE id = $3 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl ` type UpdateTemplateACLByIDParams struct { @@ -3828,6 +3838,8 @@ func (q *sqlQuerier) UpdateTemplateACLByID(ctx context.Context, arg UpdateTempla &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } @@ -3887,7 +3899,7 @@ SET WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl ` type UpdateTemplateMetaByIDParams struct { @@ -3931,6 +3943,8 @@ func (q *sqlQuerier) UpdateTemplateMetaByID(ctx context.Context, arg UpdateTempl &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } @@ -3943,11 +3957,13 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - max_ttl = $6 + max_ttl = $6, + failure_ttl = $7, + inactivity_ttl = $8 WHERE id = $1 RETURNING - id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop + id, created_at, updated_at, organization_id, deleted, name, provisioner, active_version_id, description, default_ttl, created_by, icon, user_acl, group_acl, display_name, allow_user_cancel_workspace_jobs, max_ttl, allow_user_autostart, allow_user_autostop, failure_ttl, inactivity_ttl ` type UpdateTemplateScheduleByIDParams struct { @@ -3957,6 +3973,8 @@ type UpdateTemplateScheduleByIDParams struct { AllowUserAutostop bool `db:"allow_user_autostop" json:"allow_user_autostop"` DefaultTTL int64 `db:"default_ttl" json:"default_ttl"` MaxTTL int64 `db:"max_ttl" json:"max_ttl"` + FailureTTL int64 `db:"failure_ttl" json:"failure_ttl"` + InactivityTTL int64 `db:"inactivity_ttl" json:"inactivity_ttl"` } func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateTemplateScheduleByIDParams) (Template, error) { @@ -3967,6 +3985,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT arg.AllowUserAutostop, arg.DefaultTTL, arg.MaxTTL, + arg.FailureTTL, + arg.InactivityTTL, ) var i Template err := row.Scan( @@ -3989,6 +4009,8 @@ func (q *sqlQuerier) UpdateTemplateScheduleByID(ctx context.Context, arg UpdateT &i.MaxTTL, &i.AllowUserAutostart, &i.AllowUserAutostop, + &i.FailureTTL, + &i.InactivityTTL, ) return i, err } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 309cb1ce6b..d9eb13b0f6 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -118,7 +118,9 @@ SET allow_user_autostart = $3, allow_user_autostop = $4, default_ttl = $5, - max_ttl = $6 + max_ttl = $6, + failure_ttl = $7, + inactivity_ttl = $8 WHERE id = $1 RETURNING diff --git a/coderd/database/sqlc.yaml b/coderd/database/sqlc.yaml index fcc5d77700..cd3e846afb 100644 --- a/coderd/database/sqlc.yaml +++ b/coderd/database/sqlc.yaml @@ -51,6 +51,8 @@ overrides: template_max_ttl: TemplateMaxTTL motd_file: MOTDFile uuid: UUID + failure_ttl: FailureTTL + inactivity_ttl: InactivityTTL sql: - schema: "./dump.sql" diff --git a/coderd/schedule/template.go b/coderd/schedule/template.go index c445edee7d..084218cb56 100644 --- a/coderd/schedule/template.go +++ b/coderd/schedule/template.go @@ -18,6 +18,10 @@ type TemplateScheduleOptions struct { // // If set, users cannot disable automatic workspace shutdown. MaxTTL time.Duration `json:"max_ttl"` + // If FailureTTL is set, all failed workspaces will be stopped automatically after this time has elapsed. + FailureTTL time.Duration `json:"failure_ttl"` + // If InactivityTTL is set, all inactive workspaces will be deleted automatically after this time has elapsed. + InactivityTTL time.Duration `json:"inactivity_ttl"` } // TemplateScheduleStore provides an interface for retrieving template @@ -47,9 +51,11 @@ func (*agplTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.Context UserAutostartEnabled: true, UserAutostopEnabled: true, DefaultTTL: time.Duration(tpl.DefaultTTL), - // Disregard the value in the database, since MaxTTL is an enterprise - // feature. - MaxTTL: 0, + // Disregard the values in the database, since MaxTTL, FailureTTL, and InactivityTTL are enterprise + // features. + MaxTTL: 0, + FailureTTL: 0, + InactivityTTL: 0, }, nil } @@ -68,5 +74,7 @@ func (*agplTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context AllowUserAutostart: tpl.AllowUserAutostart, AllowUserAutostop: tpl.AllowUserAutostop, MaxTTL: tpl.MaxTTL, + FailureTTL: tpl.FailureTTL, + InactivityTTL: tpl.InactivityTTL, }) } diff --git a/coderd/templates.go b/coderd/templates.go index 2250d52698..c66c587521 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -476,6 +476,12 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { if req.MaxTTLMillis != 0 && req.DefaultTTLMillis > req.MaxTTLMillis { validErrs = append(validErrs, codersdk.ValidationError{Field: "default_ttl_ms", Detail: "Must be less than or equal to max_ttl_ms if max_ttl_ms is set."}) } + if req.FailureTTLMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "failure_ttl_ms", Detail: "Must be a positive integer."}) + } + if req.InactivityTTLMillis < 0 { + validErrs = append(validErrs, codersdk.ValidationError{Field: "inactivity_ttl_ms", Detail: "Must be a positive integer."}) + } if len(validErrs) > 0 { httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{ @@ -495,18 +501,14 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.AllowUserAutostop == template.AllowUserAutostop && req.AllowUserCancelWorkspaceJobs == template.AllowUserCancelWorkspaceJobs && req.DefaultTTLMillis == time.Duration(template.DefaultTTL).Milliseconds() && - req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() { + req.MaxTTLMillis == time.Duration(template.MaxTTL).Milliseconds() && + req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() && + req.InactivityTTLMillis == time.Duration(template.InactivityTTL).Milliseconds() { return nil } - // Update template metadata -- empty fields are not overwritten, - // except for display_name, description, icon, and default_ttl. - // These exceptions are required to clear content of these fields with UI. + // Users should not be able to clear the template name in the UI name := req.Name - displayName := req.DisplayName - desc := req.Description - icon := req.Icon - if name == "" { name = template.Name } @@ -516,9 +518,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { ID: template.ID, UpdatedAt: database.Now(), Name: name, - DisplayName: displayName, - Description: desc, - Icon: icon, + DisplayName: req.DisplayName, + Description: req.Description, + Icon: req.Icon, AllowUserCancelWorkspaceJobs: req.AllowUserCancelWorkspaceJobs, }) if err != nil { @@ -527,8 +529,13 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { defaultTTL := time.Duration(req.DefaultTTLMillis) * time.Millisecond maxTTL := time.Duration(req.MaxTTLMillis) * time.Millisecond + failureTTL := time.Duration(req.FailureTTLMillis) * time.Millisecond + inactivityTTL := time.Duration(req.InactivityTTLMillis) * time.Millisecond + if defaultTTL != time.Duration(template.DefaultTTL) || maxTTL != time.Duration(template.MaxTTL) || + failureTTL != time.Duration(template.FailureTTL) || + inactivityTTL != time.Duration(template.InactivityTTL) || req.AllowUserAutostart != template.AllowUserAutostart || req.AllowUserAutostop != template.AllowUserAutostop { updated, err = (*api.TemplateScheduleStore.Load()).SetTemplateScheduleOptions(ctx, tx, updated, schedule.TemplateScheduleOptions{ @@ -539,6 +546,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { UserAutostopEnabled: req.AllowUserAutostop, DefaultTTL: defaultTTL, MaxTTL: maxTTL, + FailureTTL: failureTTL, + InactivityTTL: inactivityTTL, }) if err != nil { return xerrors.Errorf("set template schedule options: %w", err) @@ -678,5 +687,7 @@ func (api *API) convertTemplate( AllowUserAutostart: template.AllowUserAutostart, AllowUserAutostop: template.AllowUserAutostop, AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: time.Duration(template.FailureTTL).Milliseconds(), + InactivityTTLMillis: time.Duration(template.InactivityTTL).Milliseconds(), } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 75ef12ede2..f0db7a12ca 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -604,6 +604,90 @@ func TestPatchTemplateMeta(t *testing.T) { }) }) + t.Run("CleanupTTLs", func(t *testing.T) { + t.Parallel() + + const ( + failureTTL = 7 * 24 * time.Hour + inactivityTTL = 180 * 24 * time.Hour + ) + + t.Run("OK", func(t *testing.T) { + t.Parallel() + + var setCalled int64 + client := coderdtest.New(t, &coderdtest.Options{ + TemplateScheduleStore: schedule.MockTemplateScheduleStore{ + SetFn: func(ctx context.Context, db database.Store, template database.Template, options schedule.TemplateScheduleOptions) (database.Template, error) { + if atomic.AddInt64(&setCalled, 1) == 2 { + require.Equal(t, failureTTL, options.FailureTTL) + require.Equal(t, inactivityTTL, options.InactivityTTL) + } + template.FailureTTL = int64(options.FailureTTL) + template.InactivityTTL = int64(options.InactivityTTL) + return template, 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.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: 0, + MaxTTLMillis: 0, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + InactivityTTLMillis: inactivityTTL.Milliseconds(), + }) + require.NoError(t, err) + + require.EqualValues(t, 2, atomic.LoadInt64(&setCalled)) + require.Equal(t, failureTTL.Milliseconds(), got.FailureTTLMillis) + require.Equal(t, inactivityTTL.Milliseconds(), got.InactivityTTLMillis) + }) + + t.Run("IgnoredUnlicensed", 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.FailureTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + ctr.InactivityTTLMillis = ptr.Ref(0 * time.Hour.Milliseconds()) + }) + + ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) + defer cancel() + + got, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ + Name: template.Name, + DisplayName: template.DisplayName, + Description: template.Description, + Icon: template.Icon, + DefaultTTLMillis: template.DefaultTTLMillis, + MaxTTLMillis: template.MaxTTLMillis, + AllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, + FailureTTLMillis: failureTTL.Milliseconds(), + InactivityTTLMillis: inactivityTTL.Milliseconds(), + }) + require.NoError(t, err) + require.Zero(t, got.FailureTTLMillis) + require.Zero(t, got.InactivityTTLMillis) + }) + }) + t.Run("AllowUserScheduling", func(t *testing.T) { t.Parallel() diff --git a/codersdk/deployment.go b/codersdk/deployment.go index aaee164d5a..979f9ee36e 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -46,7 +46,6 @@ const ( FeatureAppearance FeatureName = "appearance" FeatureAdvancedTemplateScheduling FeatureName = "advanced_template_scheduling" FeatureWorkspaceProxy FeatureName = "workspace_proxy" - FeatureWorkspaceActions FeatureName = "workspace_actions" ) // FeatureNames must be kept in-sync with the Feature enum above. @@ -62,7 +61,6 @@ var FeatureNames = []FeatureName{ FeatureAppearance, FeatureAdvancedTemplateScheduling, FeatureWorkspaceProxy, - FeatureWorkspaceActions, } // Humanize returns the feature name in a human-readable format. diff --git a/codersdk/organizations.go b/codersdk/organizations.go index f82315e6f0..2b23d2e464 100644 --- a/codersdk/organizations.go +++ b/codersdk/organizations.go @@ -106,6 +106,13 @@ type CreateTemplateRequest struct { // false, the DefaultTTL will always be used. This can only be disabled when // using an enterprise license. AllowUserAutostop *bool `json:"allow_user_autostop"` + + // FailureTTLMillis allows optionally specifying the max lifetime before Coder + // stops all resources for failed workspaces created from this template. + FailureTTLMillis *int64 `json:"failure_ttl_ms,omitempty"` + // InactivityTTLMillis allows optionally specifying the max lifetime before Coder + // deletes inactive workspaces created from this template. + InactivityTTLMillis *int64 `json:"inactivity_ttl_ms,omitempty"` } // CreateWorkspaceRequest provides options for creating a new workspace. diff --git a/codersdk/templates.go b/codersdk/templates.go index 5a7bcec683..b1f258d9da 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -40,6 +40,12 @@ type Template struct { AllowUserAutostart bool `json:"allow_user_autostart"` AllowUserAutostop bool `json:"allow_user_autostop"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs"` + + // FailureTTLMillis and InactivityTTLMillis are enterprise-only. Their + // values are used if your license is entitled to use the advanced + // template scheduling feature. + FailureTTLMillis int64 `json:"failure_ttl_ms"` + InactivityTTLMillis int64 `json:"inactivity_ttl_ms"` } type TransitionStats struct { @@ -95,6 +101,8 @@ type UpdateTemplateMeta struct { AllowUserAutostart bool `json:"allow_user_autostart,omitempty"` AllowUserAutostop bool `json:"allow_user_autostop,omitempty"` AllowUserCancelWorkspaceJobs bool `json:"allow_user_cancel_workspace_jobs,omitempty"` + FailureTTLMillis int64 `json:"failure_ttl_ms,omitempty"` + InactivityTTLMillis int64 `json:"inactivity_ttl_ms,omitempty"` } type TemplateExample struct { diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index 643ae0d76e..7bad515cdc 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -9,18 +9,18 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
created_atfalse
created_bytrue
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
inactivity_ttltrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
created_atfalse
created_bytrue
git_auth_providersfalse
idtrue
job_idfalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typefalse
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
autostart_scheduletrue
created_atfalse
deletedfalse
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
display_nametrue
icontrue
idtrue
nametrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index 0224980889..6a82b9a1c9 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -1333,7 +1333,9 @@ CreateParameterRequest is a structure used to create a new parameter value for a "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "parameter_values": [ @@ -1359,7 +1361,9 @@ CreateParameterRequest is a structure used to create a new parameter value for a | `default_ttl_ms` | integer | false | | Default ttl ms allows optionally specifying the default TTL for all workspaces created from this template. | | `description` | string | false | | Description is a description of what the template contains. It must be less than 128 bytes. | | `display_name` | string | false | | Display name is the displayed name of the template. | +| `failure_ttl_ms` | integer | false | | Failure ttl ms allows optionally specifying the max lifetime before Coder stops all resources for failed workspaces created from this template. | | `icon` | string | false | | Icon is a relative path or external URL that specifies an icon to be displayed in the dashboard. | +| `inactivity_ttl_ms` | integer | false | | Inactivity ttl ms allows optionally specifying the max lifetime before Coder deletes inactive workspaces created from this template. | | `max_ttl_ms` | integer | false | | Max ttl ms allows optionally specifying the max lifetime for workspaces created from this template. | | `name` | string | true | | Name is the name of the template. | | `parameter_values` | array of [codersdk.CreateParameterRequest](#codersdkcreateparameterrequest) | false | | Parameter values is a structure used to create a new parameter value for a scope.] | @@ -3866,8 +3870,10 @@ Parameter represents a set value for the scope. "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -3892,8 +3898,10 @@ Parameter represents a set value for the scope. | `default_ttl_ms` | integer | false | | | | `description` | string | false | | | | `display_name` | string | false | | | +| `failure_ttl_ms` | integer | false | | Failure ttl ms and InactivityTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | | `icon` | string | false | | | | `id` | string | false | | | +| `inactivity_ttl_ms` | integer | false | | | | `max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | | `name` | string | false | | | | `organization_id` | string | false | | | diff --git a/docs/api/templates.md b/docs/api/templates.md index 5b97c47b7b..7df57183b5 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -47,8 +47,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -86,8 +88,10 @@ Status Code **200** | `» default_ttl_ms` | integer | false | | | | `» description` | string | false | | | | `» display_name` | string | false | | | +| `» failure_ttl_ms` | integer | false | | Failure ttl ms and InactivityTTLMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | | `» icon` | string | false | | | | `» id` | string(uuid) | false | | | +| `» inactivity_ttl_ms` | integer | false | | | | `» max_ttl_ms` | integer | false | | Max ttl ms is an enterprise feature. It's value is only used if your license is entitled to use the advanced template scheduling feature. | | `» name` | string | false | | | | `» organization_id` | string(uuid) | false | | | @@ -126,7 +130,9 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "parameter_values": [ @@ -176,8 +182,10 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -301,8 +309,10 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -628,8 +638,10 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \ "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", @@ -736,8 +748,10 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \ "default_ttl_ms": 0, "description": "string", "display_name": "string", + "failure_ttl_ms": 0, "icon": "string", "id": "497f6eca-6276-4993-bfeb-53cbbbba6f08", + "inactivity_ttl_ms": 0, "max_ttl_ms": 0, "name": "string", "organization_id": "7c60d51f-b44e-4682-87d6-449835ea4de6", diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index 38378cf678..c8b90b8b23 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -75,6 +75,8 @@ var auditableResourcesTypes = map[any]map[string]Action{ "allow_user_autostop": ActionTrack, "allow_user_cancel_workspace_jobs": ActionTrack, "max_ttl": ActionTrack, + "failure_ttl": ActionTrack, + "inactivity_ttl": ActionTrack, }, &database.TemplateVersion{}: { "id": ActionTrack, diff --git a/enterprise/coderd/coderd.go b/enterprise/coderd/coderd.go index 3d8ad12ede..190a552a80 100644 --- a/enterprise/coderd/coderd.go +++ b/enterprise/coderd/coderd.go @@ -326,7 +326,6 @@ func (api *API) updateEntitlements(ctx context.Context) error { codersdk.FeatureExternalProvisionerDaemons: true, codersdk.FeatureAdvancedTemplateScheduling: true, codersdk.FeatureWorkspaceProxy: true, - codersdk.FeatureWorkspaceActions: true, }) if err != nil { return err diff --git a/enterprise/coderd/coderd_test.go b/enterprise/coderd/coderd_test.go index 26526721f1..27aa2cb4c3 100644 --- a/enterprise/coderd/coderd_test.go +++ b/enterprise/coderd/coderd_test.go @@ -54,7 +54,6 @@ func TestEntitlements(t *testing.T) { codersdk.FeatureExternalProvisionerDaemons: 1, codersdk.FeatureAdvancedTemplateScheduling: 1, codersdk.FeatureWorkspaceProxy: 1, - codersdk.FeatureWorkspaceActions: 1, }, GraceAt: time.Now().Add(59 * 24 * time.Hour), }) diff --git a/enterprise/coderd/provisionerdaemons.go b/enterprise/coderd/provisionerdaemons.go index 444a418d0f..cd8d54e433 100644 --- a/enterprise/coderd/provisionerdaemons.go +++ b/enterprise/coderd/provisionerdaemons.go @@ -324,12 +324,16 @@ func (*enterpriseTemplateScheduleStore) GetTemplateScheduleOptions(ctx context.C UserAutostopEnabled: tpl.AllowUserAutostop, DefaultTTL: time.Duration(tpl.DefaultTTL), MaxTTL: time.Duration(tpl.MaxTTL), + FailureTTL: time.Duration(tpl.FailureTTL), + InactivityTTL: time.Duration(tpl.InactivityTTL), }, nil } func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.Context, db database.Store, tpl database.Template, opts schedule.TemplateScheduleOptions) (database.Template, error) { if int64(opts.DefaultTTL) == tpl.DefaultTTL && int64(opts.MaxTTL) == tpl.MaxTTL && + int64(opts.FailureTTL) == tpl.FailureTTL && + int64(opts.InactivityTTL) == tpl.InactivityTTL && opts.UserAutostartEnabled == tpl.AllowUserAutostart && opts.UserAutostopEnabled == tpl.AllowUserAutostop { // Avoid updating the UpdatedAt timestamp if nothing will be changed. @@ -343,6 +347,8 @@ func (*enterpriseTemplateScheduleStore) SetTemplateScheduleOptions(ctx context.C AllowUserAutostop: opts.UserAutostopEnabled, DefaultTTL: int64(opts.DefaultTTL), MaxTTL: int64(opts.MaxTTL), + FailureTTL: int64(opts.FailureTTL), + InactivityTTL: int64(opts.InactivityTTL), }) if err != nil { return database.Template{}, xerrors.Errorf("update template schedule: %w", err) diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 0577976470..8bc4b1e6ae 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -194,6 +194,8 @@ export interface CreateTemplateRequest { readonly allow_user_cancel_workspace_jobs?: boolean readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean + readonly failure_ttl_ms?: number + readonly inactivity_ttl_ms?: number } // From codersdk/templateversions.go @@ -841,6 +843,8 @@ export interface Template { readonly allow_user_autostart: boolean readonly allow_user_autostop: boolean readonly allow_user_cancel_workspace_jobs: boolean + readonly failure_ttl_ms: number + readonly inactivity_ttl_ms: number } // From codersdk/templates.go @@ -1010,6 +1014,8 @@ export interface UpdateTemplateMeta { readonly allow_user_autostart?: boolean readonly allow_user_autostop?: boolean readonly allow_user_cancel_workspace_jobs?: boolean + readonly failure_ttl_ms?: number + readonly inactivity_ttl_ms?: number } // From codersdk/users.go @@ -1376,7 +1382,6 @@ export type FeatureName = | "scim" | "template_rbac" | "user_limit" - | "workspace_actions" | "workspace_proxy" export const FeatureNames: FeatureName[] = [ "advanced_template_scheduling", @@ -1389,7 +1394,6 @@ export const FeatureNames: FeatureName[] = [ "scim", "template_rbac", "user_limit", - "workspace_actions", "workspace_proxy", ] diff --git a/site/src/i18n/en/templateSettingsPage.json b/site/src/i18n/en/templateSettingsPage.json index 0b918be773..e60ccb9fac 100644 --- a/site/src/i18n/en/templateSettingsPage.json +++ b/site/src/i18n/en/templateSettingsPage.json @@ -19,6 +19,12 @@ "maxTTLHelperText_zero": "Workspaces may run indefinitely.", "maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.", "maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.", + "failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces", + "failureTTLHelperText_one": "Coder will automatically stop failed workspaces after {{count}} day.", + "failureTTLHelperText_other": "Coder will automatically stop failed workspaces after {{count}} days.", + "inactivityTTLHelperText_zero": "Coder will not automatically delete inactive workspaces", + "inactivityTTLHelperText_one": "Coder will automatically delete inactive workspaces after {{count}} day.", + "inactivityTTLHelperText_other": "Coder will automatically delete inactive workspaces after {{count}} days.", "allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.", "allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.", "allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.", diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 21fbca3ed6..5e15afad4a 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -1,17 +1,24 @@ import TextField from "@material-ui/core/TextField" import { Template, UpdateTemplateMeta } from "api/typesGenerated" import { FormikTouched, useFormik } from "formik" -import { FC } from "react" +import { FC, ChangeEvent } from "react" import { getFormHelpers } from "utils/formUtils" import * as Yup from "yup" import i18next from "i18next" import { useTranslation } from "react-i18next" import { Maybe } from "components/Conditionals/Maybe" -import { FormSection, HorizontalForm, FormFooter } from "components/Form/Form" +import { + FormSection, + HorizontalForm, + FormFooter, + FormFields, +} from "components/Form/Form" import { Stack } from "components/Stack/Stack" import { makeStyles } from "@material-ui/core/styles" import Link from "@material-ui/core/Link" import Checkbox from "@material-ui/core/Checkbox" +import FormControlLabel from "@material-ui/core/FormControlLabel" +import Switch from "@material-ui/core/Switch" const TTLHelperText = ({ ttl, @@ -32,6 +39,14 @@ const TTLHelperText = ({ const MAX_TTL_DAYS = 7 const MS_HOUR_CONVERSION = 3600000 +const MS_DAY_CONVERSION = 86400000 +const FAILURE_CLEANUP_DEFAULT = 7 +const INACTIVITY_CLEANUP_DEFAULT = 180 + +export interface TemplateScheduleFormValues extends UpdateTemplateMeta { + failure_cleanup_enabled: boolean + inactivity_cleanup_enabled: boolean +} export const getValidationSchema = (): Yup.AnyObjectSchema => Yup.object({ @@ -49,6 +64,36 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => 24 * MAX_TTL_DAYS /* 7 days in hours */, i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }), ), + failure_ttl_ms: Yup.number() + .integer() + .min(0, "Failure cleanup days must not be less than 0.") + .test( + "positive-if-enabled", + "Failure cleanup days must be greater than zero when enabled.", + function (value) { + const parent = this.parent as TemplateScheduleFormValues + if (parent.failure_cleanup_enabled) { + return Boolean(value) + } else { + return true + } + }, + ), + inactivity_ttl_ms: Yup.number() + .integer() + .min(0, "Inactivity cleanup days must not be less than 0.") + .test( + "positive-if-enabled", + "Inactivity cleanup days must be greater than zero when enabled.", + function (value) { + const parent = this.parent as TemplateScheduleFormValues + if (parent.inactivity_cleanup_enabled) { + return Boolean(value) + } else { + return true + } + }, + ), allow_user_autostart: Yup.boolean(), allow_user_autostop: Yup.boolean(), }) @@ -60,6 +105,7 @@ export interface TemplateScheduleForm { isSubmitting: boolean error?: unknown allowAdvancedScheduling: boolean + allowWorkspaceActions: boolean // Helpful to show field errors on Storybook initialTouched?: FormikTouched } @@ -70,22 +116,34 @@ export const TemplateScheduleForm: FC = ({ onCancel, error, allowAdvancedScheduling, + allowWorkspaceActions, isSubmitting, initialTouched, }) => { const { t: commonT } = useTranslation("common") const validationSchema = getValidationSchema() - const form = useFormik({ + const form = useFormik({ initialValues: { // on display, convert from ms => hours default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION, - // the API ignores this value, but to avoid tripping up validation set + // the API ignores these values, but to avoid tripping up validation set // it to zero if the user can't set the field. max_ttl_ms: allowAdvancedScheduling ? template.max_ttl_ms / MS_HOUR_CONVERSION : 0, + failure_ttl_ms: allowAdvancedScheduling + ? template.failure_ttl_ms / MS_DAY_CONVERSION + : 0, + inactivity_ttl_ms: allowAdvancedScheduling + ? template.inactivity_ttl_ms / MS_DAY_CONVERSION + : 0, + allow_user_autostart: template.allow_user_autostart, allow_user_autostop: template.allow_user_autostop, + failure_cleanup_enabled: + allowAdvancedScheduling && Boolean(template.failure_ttl_ms), + inactivity_cleanup_enabled: + allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms), }, validationSchema, onSubmit: (formData) => { @@ -97,16 +155,64 @@ export const TemplateScheduleForm: FC = ({ max_ttl_ms: formData.max_ttl_ms ? formData.max_ttl_ms * MS_HOUR_CONVERSION : undefined, + failure_ttl_ms: formData.failure_ttl_ms + ? formData.failure_ttl_ms * MS_DAY_CONVERSION + : undefined, + inactivity_ttl_ms: formData.inactivity_ttl_ms + ? formData.inactivity_ttl_ms * MS_DAY_CONVERSION + : undefined, + allow_user_autostart: formData.allow_user_autostart, allow_user_autostop: formData.allow_user_autostop, }) }, initialTouched, }) - const getFieldHelpers = getFormHelpers(form, error) + const getFieldHelpers = getFormHelpers( + form, + error, + ) const { t } = useTranslation("templateSettingsPage") const styles = useStyles() + const handleToggleFailureCleanup = async (e: ChangeEvent) => { + form.handleChange(e) + if (!form.values.failure_cleanup_enabled) { + // fill failure_ttl_ms with defaults + await form.setValues({ + ...form.values, + failure_cleanup_enabled: true, + failure_ttl_ms: FAILURE_CLEANUP_DEFAULT, + }) + } else { + // clear failure_ttl_ms + await form.setValues({ + ...form.values, + failure_cleanup_enabled: false, + failure_ttl_ms: 0, + }) + } + } + + const handleToggleInactivityCleanup = async (e: ChangeEvent) => { + form.handleChange(e) + if (!form.values.inactivity_cleanup_enabled) { + // fill inactivity_ttl_ms with defaults + await form.setValues({ + ...form.values, + inactivity_cleanup_enabled: true, + inactivity_ttl_ms: INACTIVITY_CLEANUP_DEFAULT, + }) + } else { + // clear inactivity_ttl_ms + await form.setValues({ + ...form.values, + inactivity_cleanup_enabled: false, + inactivity_ttl_ms: 0, + }) + } + } + return ( = ({ - - + {allowAdvancedScheduling && allowWorkspaceActions && ( + <> + + + + } + label="Enable Failure Cleanup" + /> + , + )} + disabled={isSubmitting || !form.values.failure_cleanup_enabled} + fullWidth + inputProps={{ min: 0, step: 1 }} + label="Time until cleanup (days)" + variant="outlined" + type="number" + aria-label="Failure Cleanup" + /> + + + + + + } + label="Enable Inactivity Cleanup" + /> + , + )} + disabled={ + isSubmitting || !form.values.inactivity_cleanup_enabled + } + fullWidth + inputProps={{ min: 0, step: 1 }} + label="Time until cleanup (days)" + variant="outlined" + type="number" + aria-label="Inactivity Cleanup" + /> + + + + )} + ) } diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx index b902002a15..c767c4c713 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.test.tsx @@ -6,11 +6,11 @@ import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter import { MockEntitlementsWithScheduling, MockTemplate, -} from "../../../testHelpers/entities" +} from "testHelpers/entities" import { renderWithTemplateSettingsLayout, waitForLoaderToBeRemoved, -} from "../../../testHelpers/renderHelpers" +} from "testHelpers/renderHelpers" import { getValidationSchema } from "./TemplateScheduleForm" import TemplateSchedulePage from "./TemplateSchedulePage" import i18next from "i18next" @@ -20,6 +20,8 @@ const { t } = i18next const validFormValues = { default_ttl_ms: 1, max_ttl_ms: 2, + failure_ttl_ms: 7, + inactivity_ttl_ms: 180, } const renderTemplateSchedulePage = async () => { @@ -33,9 +35,13 @@ const renderTemplateSchedulePage = async () => { const fillAndSubmitForm = async ({ default_ttl_ms, max_ttl_ms, + failure_ttl_ms, + inactivity_ttl_ms, }: { default_ttl_ms: number max_ttl_ms: number + failure_ttl_ms: number + inactivity_ttl_ms: number }) => { const user = userEvent.setup() const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" }) @@ -48,6 +54,16 @@ const fillAndSubmitForm = async ({ await user.clear(maxTtlField) await user.type(maxTtlField, max_ttl_ms.toString()) + const failureTtlField = screen.getByRole("checkbox", { + name: /Failure Cleanup/i, + }) + await user.type(failureTtlField, failure_ttl_ms.toString()) + + const inactivityTtlField = screen.getByRole("checkbox", { + name: /Inactivity Cleanup/i, + }) + await user.type(inactivityTtlField, inactivity_ttl_ms.toString()) + const submitButton = await screen.findByText( FooterFormLanguage.defaultSubmitLabel, ) @@ -59,6 +75,9 @@ describe("TemplateSchedulePage", () => { jest .spyOn(API, "getEntitlements") .mockResolvedValue(MockEntitlementsWithScheduling) + + // remove when https://github.com/coder/coder/milestone/19 is completed. + jest.spyOn(API, "getExperiments").mockResolvedValue(["workspace_actions"]) }) it("succeeds", async () => { @@ -71,7 +90,7 @@ describe("TemplateSchedulePage", () => { await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) }) - test("ttl is converted to and from hours", async () => { + test("default and max ttl is converted to and from hours", async () => { await renderTemplateSchedulePage() jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ @@ -92,7 +111,28 @@ describe("TemplateSchedulePage", () => { ) }) - it("allows a ttl of 7 days", () => { + test("failure and inactivity ttl converted to and from days", async () => { + await renderTemplateSchedulePage() + + jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({ + ...MockTemplate, + ...validFormValues, + }) + + await fillAndSubmitForm(validFormValues) + await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1)) + await waitFor(() => + expect(API.updateTemplateMeta).toBeCalledWith( + "test-template", + expect.objectContaining({ + failure_ttl_ms: validFormValues.failure_ttl_ms * 86400000, + inactivity_ttl_ms: validFormValues.inactivity_ttl_ms * 86400000, + }), + ), + ) + }) + + it("allows a default ttl of 7 days", () => { const values: UpdateTemplateMeta = { ...validFormValues, default_ttl_ms: 24 * 7, @@ -101,7 +141,7 @@ describe("TemplateSchedulePage", () => { expect(validate).not.toThrowError() }) - it("allows ttl of 0", () => { + it("allows default ttl of 0", () => { const values: UpdateTemplateMeta = { ...validFormValues, default_ttl_ms: 0, @@ -110,7 +150,7 @@ describe("TemplateSchedulePage", () => { expect(validate).not.toThrowError() }) - it("disallows a ttl of 7 days + 1 hour", () => { + it("disallows a default ttl of 7 days + 1 hour", () => { const values: UpdateTemplateMeta = { ...validFormValues, default_ttl_ms: 24 * 7 + 1, @@ -120,4 +160,62 @@ describe("TemplateSchedulePage", () => { t("defaultTTLMaxError", { ns: "templateSettingsPage" }), ) }) + + it("allows a failure ttl of 7 days", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + failure_ttl_ms: 86400000 * 7, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("allows failure ttl of 0", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + failure_ttl_ms: 0, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("disallows a negative failure ttl", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + failure_ttl_ms: -1, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).toThrowError( + "Failure cleanup days must not be less than 0.", + ) + }) + + it("allows an inactivity ttl of 7 days", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + inactivity_ttl_ms: 86400000 * 7, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("allows an inactivity ttl of 0", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + inactivity_ttl_ms: 0, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).not.toThrowError() + }) + + it("disallows a negative inactivity ttl", () => { + const values: UpdateTemplateMeta = { + ...validFormValues, + inactivity_ttl_ms: -1, + } + const validate = () => getValidationSchema().validateSync(values) + expect(validate).toThrowError( + "Inactivity cleanup days must not be less than 0.", + ) + }) }) diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx index f861ffc3c1..af5424ab8c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -14,9 +14,13 @@ const TemplateSchedulePage: FC = () => { const { template: templateName } = useParams() as { template: string } const navigate = useNavigate() const { template } = useTemplateSettingsContext() - const { entitlements } = useDashboard() + const { entitlements, experiments } = useDashboard() const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions") + const { mutate: updateTemplate, isLoading: isSubmitting, @@ -37,6 +41,7 @@ const TemplateSchedulePage: FC = () => { ["initialTouched"] allowAdvancedScheduling: boolean + allowWorkspaceActions: boolean } export const TemplateSchedulePageView: FC = ({ @@ -20,6 +21,7 @@ export const TemplateSchedulePageView: FC = ({ onSubmit, isSubmitting, allowAdvancedScheduling, + allowWorkspaceActions, submitError, initialTouched, }) => { @@ -33,6 +35,7 @@ export const TemplateSchedulePageView: FC = ({