feat: add inactivity cleanup and failure cleanup configuration fields to Template Schedule Form (#7402)

* added workspace actions entitlement

* added workspace actions experiment

* added new route for template enterprise meta

* removing new route; repurposing old

* add new fields to get endpoints

* removed workspace actions experiment

* added logic to enterprise template store

* added new form fields

* feature flagged new fields

* fix validation

* fixed submit btn

* fix tests

* changed ttl defaults

* added FE tests

* added BE tests

* fixed lint

* adjusted comment language

* fixing unstaged changes check

* fix test

* Update coderd/database/migrations/000122_add_template_cleanup_ttls.down.sql

Co-authored-by: Dean Sheather <dean@deansheather.com>

* Update coderd/database/migrations/000122_add_template_cleanup_ttls.up.sql

Co-authored-by: Dean Sheather <dean@deansheather.com>

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Kira Pilot 2023-05-05 08:19:26 -07:00 committed by GitHub
parent 3632ac8c01
commit 5ffa6dae50
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
33 changed files with 578 additions and 59 deletions

View File

@ -34,6 +34,7 @@
"Dsts",
"embeddedpostgres",
"enablements",
"enterprisemeta",
"errgroup",
"eventsourcemock",
"Failf",

15
coderd/apidoc/docs.go generated
View File

@ -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"

View File

@ -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"

View File

@ -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
}

View File

@ -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.';

View File

@ -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;

View File

@ -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;

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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

View File

@ -51,6 +51,8 @@ overrides:
template_max_ttl: TemplateMaxTTL
motd_file: MOTDFile
uuid: UUID
failure_ttl: FailureTTL
inactivity_ttl: InactivityTTL
sql:
- schema: "./dump.sql"

View File

@ -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,
})
}

View File

@ -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(),
}
}

View File

@ -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()

View File

@ -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.

View File

@ -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.

View File

@ -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 {

View File

@ -9,18 +9,18 @@ We track the following resources:
<!-- Code generated by 'make docs/admin/audit-logs.md'. DO NOT EDIT -->
| <b>Resource<b> | |
| -------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
| <b>Resource<b> | |
| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| APIKey<br><i>login, logout, register, create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>expires_at</td><td>true</td></tr><tr><td>hashed_secret</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>ip_address</td><td>false</td></tr><tr><td>last_used</td><td>true</td></tr><tr><td>lifetime_seconds</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>scope</td><td>false</td></tr><tr><td>token_name</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| Group<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>members</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>quota_allowance</td><td>true</td></tr></tbody></table> |
| GitSSHKey<br><i>create</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>private_key</td><td>true</td></tr><tr><td>public_key</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_id</td><td>true</td></tr></tbody></table> |
| License<br><i>create, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>exp</td><td>true</td></tr><tr><td>id</td><td>false</td></tr><tr><td>jwt</td><td>false</td></tr><tr><td>uploaded_at</td><td>true</td></tr><tr><td>uuid</td><td>true</td></tr></tbody></table> |
| Template<br><i>write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>active_version_id</td><td>true</td></tr><tr><td>allow_user_autostart</td><td>true</td></tr><tr><td>allow_user_autostop</td><td>true</td></tr><tr><td>allow_user_cancel_workspace_jobs</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>description</td><td>true</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>failure_ttl</td><td>true</td></tr><tr><td>group_acl</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>inactivity_ttl</td><td>true</td></tr><tr><td>max_ttl</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>provisioner</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>user_acl</td><td>true</td></tr></tbody></table> |
| TemplateVersion<br><i>create, write</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>false</td></tr><tr><td>created_by</td><td>true</td></tr><tr><td>git_auth_providers</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>readme</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| User<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>avatar_url</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>true</td></tr><tr><td>email</td><td>true</td></tr><tr><td>hashed_password</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_seen_at</td><td>false</td></tr><tr><td>login_type</td><td>false</td></tr><tr><td>rbac_roles</td><td>true</td></tr><tr><td>status</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>username</td><td>true</td></tr></tbody></table> |
| Workspace<br><i>create, write, delete</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>autostart_schedule</td><td>true</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>id</td><td>true</td></tr><tr><td>last_used_at</td><td>false</td></tr><tr><td>name</td><td>true</td></tr><tr><td>organization_id</td><td>false</td></tr><tr><td>owner_id</td><td>true</td></tr><tr><td>template_id</td><td>true</td></tr><tr><td>ttl</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr></tbody></table> |
| WorkspaceBuild<br><i>start, stop</i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>build_number</td><td>false</td></tr><tr><td>created_at</td><td>false</td></tr><tr><td>daily_cost</td><td>false</td></tr><tr><td>deadline</td><td>false</td></tr><tr><td>id</td><td>false</td></tr><tr><td>initiator_id</td><td>false</td></tr><tr><td>job_id</td><td>false</td></tr><tr><td>max_deadline</td><td>false</td></tr><tr><td>provisioner_state</td><td>false</td></tr><tr><td>reason</td><td>false</td></tr><tr><td>template_version_id</td><td>true</td></tr><tr><td>transition</td><td>false</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>workspace_id</td><td>false</td></tr></tbody></table> |
| WorkspaceProxy<br><i></i> | <table><thead><tr><th>Field</th><th>Tracked</th></tr></thead><tbody><tr><td>created_at</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>display_name</td><td>true</td></tr><tr><td>icon</td><td>true</td></tr><tr><td>id</td><td>true</td></tr><tr><td>name</td><td>true</td></tr><tr><td>token_hashed_secret</td><td>true</td></tr><tr><td>updated_at</td><td>false</td></tr><tr><td>url</td><td>true</td></tr><tr><td>wildcard_hostname</td><td>true</td></tr></tbody></table> |
<!-- End generated by 'make docs/admin/audit-logs.md'. -->

View File

@ -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 | | |

View File

@ -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",

View File

@ -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,

View File

@ -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

View File

@ -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),
})

View File

@ -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)

View File

@ -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",
]

View File

@ -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.",

View File

@ -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<UpdateTemplateMeta>
}
@ -70,22 +116,34 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
onCancel,
error,
allowAdvancedScheduling,
allowWorkspaceActions,
isSubmitting,
initialTouched,
}) => {
const { t: commonT } = useTranslation("common")
const validationSchema = getValidationSchema()
const form = useFormik<UpdateTemplateMeta>({
const form = useFormik<TemplateScheduleFormValues>({
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<TemplateScheduleForm> = ({
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<UpdateTemplateMeta>(form, error)
const getFieldHelpers = getFormHelpers<TemplateScheduleFormValues>(
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 (
<HorizontalForm
onSubmit={form.handleSubmit}
@ -215,8 +321,85 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</Stack>
</Stack>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
{allowAdvancedScheduling && allowWorkspaceActions && (
<>
<FormSection
title="Failure Cleanup"
description="When enabled, Coder will automatically stop workspaces that are in a failed state after a specified number of days."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="failureCleanupEnabled"
checked={form.values.failure_cleanup_enabled}
onChange={handleToggleFailureCleanup}
color="primary"
/>
}
label="Enable Failure Cleanup"
/>
<TextField
{...getFieldHelpers(
"failure_ttl_ms",
<TTLHelperText
translationName="failureTTLHelperText"
ttl={form.values.failure_ttl_ms}
/>,
)}
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"
/>
</FormFields>
</FormSection>
<FormSection
title="Inactivity Cleanup"
description="When enabled, Coder will automatically delete workspaces that are in an inactive state after a specified number of days."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="inactivityCleanupEnabled"
checked={form.values.inactivity_cleanup_enabled}
onChange={handleToggleInactivityCleanup}
color="primary"
/>
}
label="Enable Inactivity Cleanup"
/>
<TextField
{...getFieldHelpers(
"inactivity_ttl_ms",
<TTLHelperText
translationName="inactivityTTLHelperText"
ttl={form.values.inactivity_ttl_ms}
/>,
)}
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"
/>
</FormFields>
</FormSection>
</>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitDisabled={!form.isValid || !form.dirty}
/>
</HorizontalForm>
)
}

View File

@ -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.",
)
})
})

View File

@ -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 = () => {
</Helmet>
<TemplateSchedulePageView
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
isSubmitting={isSubmitting}
template={template}
submitError={submitError}

View File

@ -11,6 +11,7 @@ export default {
component: TemplateSchedulePageView,
args: {
allowAdvancedScheduling: true,
allowWorkspaceActions: true,
template: MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),

View File

@ -12,6 +12,7 @@ export interface TemplateSchedulePageViewProps {
submitError?: unknown
initialTouched?: ComponentProps<typeof TemplateScheduleForm>["initialTouched"]
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
}
export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
@ -20,6 +21,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
onSubmit,
isSubmitting,
allowAdvancedScheduling,
allowWorkspaceActions,
submitError,
initialTouched,
}) => {
@ -33,6 +35,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
<TemplateScheduleForm
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}

View File

@ -341,6 +341,8 @@ export const MockTemplate: TypesGen.Template = {
created_by_name: "test_creator",
icon: "/icon/code.svg",
allow_user_cancel_workspace_jobs: true,
failure_ttl_ms: 0,
inactivity_ttl_ms: 0,
allow_user_autostart: false,
allow_user_autostop: false,
}
@ -1340,7 +1342,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
}),
}
export const MockExperiments: TypesGen.Experiment[] = []
export const MockExperiments: TypesGen.Experiment[] = ["workspace_actions"]
export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",