mirror of https://github.com/coder/coder.git
feat: implement deprecated flag for templates to prevent new workspaces (#10745)
* feat: implement deprecated flag for templates to prevent new workspaces * Add deprecated filter to template fetching * Add deprecated to template table * Add deprecated notice to template page * Add ui to deprecate a template
This commit is contained in:
parent
d8df87d5ae
commit
5229d7fd3a
|
@ -16,6 +16,7 @@ import (
|
|||
)
|
||||
|
||||
func (r *RootCmd) templateEdit() *clibase.Cmd {
|
||||
const deprecatedFlagName = "deprecated"
|
||||
var (
|
||||
name string
|
||||
displayName string
|
||||
|
@ -32,6 +33,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
|
|||
allowUserAutostart bool
|
||||
allowUserAutostop bool
|
||||
requireActiveVersion bool
|
||||
deprecationMessage string
|
||||
)
|
||||
client := new(codersdk.Client)
|
||||
|
||||
|
@ -118,6 +120,15 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
|
|||
autostopRequirementDaysOfWeek = []string{}
|
||||
}
|
||||
|
||||
// Only pass explicitly set deprecated values since the empty string
|
||||
// removes the deprecated message. By default if we pass a nil,
|
||||
// there is no change to this field.
|
||||
var deprecated *string
|
||||
opt := inv.Command.Options.ByName(deprecatedFlagName)
|
||||
if !(opt.ValueSource == "" || opt.ValueSource == clibase.ValueSourceDefault) {
|
||||
deprecated = &deprecationMessage
|
||||
}
|
||||
|
||||
// NOTE: coderd will ignore empty fields.
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
Name: name,
|
||||
|
@ -139,6 +150,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
|
|||
AllowUserAutostart: allowUserAutostart,
|
||||
AllowUserAutostop: allowUserAutostop,
|
||||
RequireActiveVersion: requireActiveVersion,
|
||||
DeprecationMessage: deprecated,
|
||||
}
|
||||
|
||||
_, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req)
|
||||
|
@ -166,6 +178,12 @@ func (r *RootCmd) templateEdit() *clibase.Cmd {
|
|||
Description: "Edit the template description.",
|
||||
Value: clibase.StringOf(&description),
|
||||
},
|
||||
{
|
||||
Name: deprecatedFlagName,
|
||||
Flag: "deprecated",
|
||||
Description: "Sets the template as deprecated. Must be a message explaining why the template is deprecated.",
|
||||
Value: clibase.StringOf(&deprecationMessage),
|
||||
},
|
||||
{
|
||||
Flag: "icon",
|
||||
Description: "Edit the template icon path.",
|
||||
|
|
|
@ -28,6 +28,10 @@ OPTIONS:
|
|||
from this template default to this value. Maps to "Default autostop"
|
||||
in the UI.
|
||||
|
||||
--deprecated string
|
||||
Sets the template as deprecated. Must be a message explaining why the
|
||||
template is deprecated.
|
||||
|
||||
--description string
|
||||
Edit the template description.
|
||||
|
||||
|
|
|
@ -10057,6 +10057,12 @@ const docTemplate = `{
|
|||
"default_ttl_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"deprecation_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -9085,6 +9085,12 @@
|
|||
"default_ttl_ms": {
|
||||
"type": "integer"
|
||||
},
|
||||
"deprecated": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"deprecation_message": {
|
||||
"type": "string"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
|
|
|
@ -614,6 +614,21 @@ func CreateAnotherUserMutators(t testing.TB, client *codersdk.Client, organizati
|
|||
return createAnotherUserRetry(t, client, organizationID, 5, roles, mutators...)
|
||||
}
|
||||
|
||||
// AuthzUserSubject does not include the user's groups.
|
||||
func AuthzUserSubject(user codersdk.User) rbac.Subject {
|
||||
roles := make(rbac.RoleNames, 0, len(user.Roles))
|
||||
for _, r := range user.Roles {
|
||||
roles = append(roles, r.Name)
|
||||
}
|
||||
|
||||
return rbac.Subject{
|
||||
ID: user.ID.String(),
|
||||
Roles: roles,
|
||||
Groups: []string{},
|
||||
Scope: rbac.ScopeAll,
|
||||
}
|
||||
}
|
||||
|
||||
func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationID uuid.UUID, retries int, roles []string, mutators ...func(r *codersdk.CreateUserRequest)) (*codersdk.Client, codersdk.User) {
|
||||
req := codersdk.CreateUserRequest{
|
||||
Email: namesgenerator.GetRandomName(10) + "@coder.com",
|
||||
|
@ -689,7 +704,7 @@ func createAnotherUserRetry(t testing.TB, client *codersdk.Client, organizationI
|
|||
siteRoles = append(siteRoles, r.Name)
|
||||
}
|
||||
|
||||
_, err := client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles})
|
||||
user, err = client.UpdateUserRoles(context.Background(), user.ID.String(), codersdk.UpdateRoles{Roles: siteRoles})
|
||||
require.NoError(t, err, "update site roles")
|
||||
|
||||
// Update org roles
|
||||
|
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"golang.org/x/xerrors"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
)
|
||||
|
@ -18,6 +19,11 @@ type AccessControlStore interface {
|
|||
|
||||
type TemplateAccessControl struct {
|
||||
RequireActiveVersion bool
|
||||
Deprecated string
|
||||
}
|
||||
|
||||
func (t TemplateAccessControl) IsDeprecated() bool {
|
||||
return t.Deprecated != ""
|
||||
}
|
||||
|
||||
// AGPLTemplateAccessControlStore always returns the defaults for access control
|
||||
|
@ -26,12 +32,38 @@ type AGPLTemplateAccessControlStore struct{}
|
|||
|
||||
var _ AccessControlStore = AGPLTemplateAccessControlStore{}
|
||||
|
||||
func (AGPLTemplateAccessControlStore) GetTemplateAccessControl(database.Template) TemplateAccessControl {
|
||||
func (AGPLTemplateAccessControlStore) GetTemplateAccessControl(t database.Template) TemplateAccessControl {
|
||||
return TemplateAccessControl{
|
||||
RequireActiveVersion: false,
|
||||
// AGPL cannot set deprecated templates, but it should return
|
||||
// existing deprecated templates. This is erroring on the safe side
|
||||
// if a license expires, we should not allow deprecated templates
|
||||
// to be used for new workspaces.
|
||||
Deprecated: t.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
func (AGPLTemplateAccessControlStore) SetTemplateAccessControl(context.Context, database.Store, uuid.UUID, TemplateAccessControl) error {
|
||||
func (AGPLTemplateAccessControlStore) SetTemplateAccessControl(ctx context.Context, store database.Store, id uuid.UUID, opts TemplateAccessControl) error {
|
||||
// AGPL is allowed to unset deprecated templates.
|
||||
if opts.Deprecated == "" {
|
||||
// This does require fetching again to ensure other fields are not
|
||||
// changed.
|
||||
tpl, err := store.GetTemplateByID(ctx, id)
|
||||
if err != nil {
|
||||
return xerrors.Errorf("get template: %w", err)
|
||||
}
|
||||
|
||||
if tpl.Deprecated != "" {
|
||||
err := store.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{
|
||||
ID: id,
|
||||
RequireActiveVersion: tpl.RequireActiveVersion,
|
||||
Deprecated: opts.Deprecated,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update template access control: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -5905,6 +5905,7 @@ func (q *FakeQuerier) UpdateTemplateAccessControlByID(_ context.Context, arg dat
|
|||
continue
|
||||
}
|
||||
q.templates[idx].RequireActiveVersion = arg.RequireActiveVersion
|
||||
q.templates[idx].Deprecated = arg.Deprecated
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -6887,6 +6888,9 @@ func (q *FakeQuerier) GetAuthorizedTemplates(ctx context.Context, arg database.G
|
|||
if arg.ExactName != "" && !strings.EqualFold(template.Name, arg.ExactName) {
|
||||
continue
|
||||
}
|
||||
if arg.Deprecated.Valid && arg.Deprecated.Bool == (template.Deprecated != "") {
|
||||
continue
|
||||
}
|
||||
|
||||
if len(arg.IDs) > 0 {
|
||||
match := false
|
||||
|
|
|
@ -805,7 +805,8 @@ CREATE TABLE templates (
|
|||
autostop_requirement_days_of_week smallint DEFAULT 0 NOT NULL,
|
||||
autostop_requirement_weeks bigint DEFAULT 0 NOT NULL,
|
||||
autostart_block_days_of_week smallint DEFAULT 0 NOT NULL,
|
||||
require_active_version boolean DEFAULT false NOT NULL
|
||||
require_active_version boolean DEFAULT false NOT NULL,
|
||||
deprecated text DEFAULT ''::text NOT NULL
|
||||
);
|
||||
|
||||
COMMENT ON COLUMN templates.default_ttl IS 'The default duration for autostop for workspaces created from this template.';
|
||||
|
@ -824,6 +825,8 @@ COMMENT ON COLUMN templates.autostop_requirement_weeks IS 'The number of weeks b
|
|||
|
||||
COMMENT ON COLUMN templates.autostart_block_days_of_week IS 'A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example).';
|
||||
|
||||
COMMENT ON COLUMN templates.deprecated IS 'If set to a non empty string, the template will no longer be able to be used. The message will be displayed to the user.';
|
||||
|
||||
CREATE VIEW template_with_users AS
|
||||
SELECT templates.id,
|
||||
templates.created_at,
|
||||
|
@ -851,6 +854,7 @@ CREATE VIEW template_with_users AS
|
|||
templates.autostop_requirement_weeks,
|
||||
templates.autostart_block_days_of_week,
|
||||
templates.require_active_version,
|
||||
templates.deprecated,
|
||||
COALESCE(visible_users.avatar_url, ''::text) AS created_by_avatar_url,
|
||||
COALESCE(visible_users.username, ''::text) AS created_by_username
|
||||
FROM (public.templates
|
||||
|
|
|
@ -0,0 +1,24 @@
|
|||
BEGIN;
|
||||
|
||||
DROP VIEW template_with_users;
|
||||
|
||||
ALTER TABLE templates
|
||||
DROP COLUMN deprecated;
|
||||
|
||||
CREATE VIEW
|
||||
template_with_users
|
||||
AS
|
||||
SELECT
|
||||
templates.*,
|
||||
coalesce(visible_users.avatar_url, '') AS created_by_avatar_url,
|
||||
coalesce(visible_users.username, '') AS created_by_username
|
||||
FROM
|
||||
templates
|
||||
LEFT JOIN
|
||||
visible_users
|
||||
ON
|
||||
templates.created_by = visible_users.id;
|
||||
|
||||
COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.';
|
||||
|
||||
COMMIT;
|
|
@ -0,0 +1,28 @@
|
|||
BEGIN;
|
||||
|
||||
-- The view will be rebuilt with the new column
|
||||
DROP VIEW template_with_users;
|
||||
|
||||
ALTER TABLE templates
|
||||
ADD COLUMN deprecated TEXT NOT NULL DEFAULT '';
|
||||
|
||||
COMMENT ON COLUMN templates.deprecated IS 'If set to a non empty string, the template will no longer be able to be used. The message will be displayed to the user.';
|
||||
|
||||
-- Restore the old version of the template_with_users view.
|
||||
CREATE VIEW
|
||||
template_with_users
|
||||
AS
|
||||
SELECT
|
||||
templates.*,
|
||||
coalesce(visible_users.avatar_url, '') AS created_by_avatar_url,
|
||||
coalesce(visible_users.username, '') AS created_by_username
|
||||
FROM
|
||||
templates
|
||||
LEFT JOIN
|
||||
visible_users
|
||||
ON
|
||||
templates.created_by = visible_users.id;
|
||||
|
||||
COMMENT ON VIEW template_with_users IS 'Joins in the username + avatar url of the created by user.';
|
||||
|
||||
COMMIT;
|
|
@ -52,6 +52,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
|
|||
arg.OrganizationID,
|
||||
arg.ExactName,
|
||||
pq.Array(arg.IDs),
|
||||
arg.Deprecated,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -87,6 +88,7 @@ func (q *sqlQuerier) GetAuthorizedTemplates(ctx context.Context, arg GetTemplate
|
|||
&i.AutostopRequirementWeeks,
|
||||
&i.AutostartBlockDaysOfWeek,
|
||||
&i.RequireActiveVersion,
|
||||
&i.Deprecated,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
); err != nil {
|
||||
|
|
|
@ -1966,6 +1966,7 @@ type Template struct {
|
|||
AutostopRequirementWeeks int64 `db:"autostop_requirement_weeks" json:"autostop_requirement_weeks"`
|
||||
AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"`
|
||||
RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"`
|
||||
Deprecated string `db:"deprecated" json:"deprecated"`
|
||||
CreatedByAvatarURL sql.NullString `db:"created_by_avatar_url" json:"created_by_avatar_url"`
|
||||
CreatedByUsername string `db:"created_by_username" json:"created_by_username"`
|
||||
}
|
||||
|
@ -2005,6 +2006,8 @@ type TemplateTable struct {
|
|||
// A bitmap of days of week that autostart of a workspace is not allowed. Default allows all days. This is intended as a cost savings measure to prevent auto start on weekends (for example).
|
||||
AutostartBlockDaysOfWeek int16 `db:"autostart_block_days_of_week" json:"autostart_block_days_of_week"`
|
||||
RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"`
|
||||
// If set to a non empty string, the template will no longer be able to be used. The message will be displayed to the user.
|
||||
Deprecated string `db:"deprecated" json:"deprecated"`
|
||||
}
|
||||
|
||||
// Joins in the username + avatar url of the created by user.
|
||||
|
|
|
@ -5183,7 +5183,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, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username
|
||||
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, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, created_by_avatar_url, created_by_username
|
||||
FROM
|
||||
template_with_users
|
||||
WHERE
|
||||
|
@ -5222,6 +5222,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
|
|||
&i.AutostopRequirementWeeks,
|
||||
&i.AutostartBlockDaysOfWeek,
|
||||
&i.RequireActiveVersion,
|
||||
&i.Deprecated,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
)
|
||||
|
@ -5230,7 +5231,7 @@ func (q *sqlQuerier) GetTemplateByID(ctx context.Context, id uuid.UUID) (Templat
|
|||
|
||||
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, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username
|
||||
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, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, created_by_avatar_url, created_by_username
|
||||
FROM
|
||||
template_with_users AS templates
|
||||
WHERE
|
||||
|
@ -5277,6 +5278,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
|
|||
&i.AutostopRequirementWeeks,
|
||||
&i.AutostartBlockDaysOfWeek,
|
||||
&i.RequireActiveVersion,
|
||||
&i.Deprecated,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
)
|
||||
|
@ -5284,7 +5286,7 @@ func (q *sqlQuerier) GetTemplateByOrganizationAndName(ctx context.Context, arg G
|
|||
}
|
||||
|
||||
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, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username FROM template_with_users AS 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, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, created_by_avatar_url, created_by_username FROM template_with_users AS templates
|
||||
ORDER BY (name, id) ASC
|
||||
`
|
||||
|
||||
|
@ -5324,6 +5326,7 @@ func (q *sqlQuerier) GetTemplates(ctx context.Context) ([]Template, error) {
|
|||
&i.AutostopRequirementWeeks,
|
||||
&i.AutostartBlockDaysOfWeek,
|
||||
&i.RequireActiveVersion,
|
||||
&i.Deprecated,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
); err != nil {
|
||||
|
@ -5342,7 +5345,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, failure_ttl, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, created_by_avatar_url, created_by_username
|
||||
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, time_til_dormant, time_til_dormant_autodelete, autostop_requirement_days_of_week, autostop_requirement_weeks, autostart_block_days_of_week, require_active_version, deprecated, created_by_avatar_url, created_by_username
|
||||
FROM
|
||||
template_with_users AS templates
|
||||
WHERE
|
||||
|
@ -5366,16 +5369,28 @@ WHERE
|
|||
id = ANY($4)
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by deprecated
|
||||
AND CASE
|
||||
WHEN $5 :: boolean IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN $5 :: boolean THEN
|
||||
deprecated != ''
|
||||
ELSE
|
||||
deprecated = ''
|
||||
END
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedTemplates
|
||||
-- @authorize_filter
|
||||
ORDER BY (name, id) ASC
|
||||
`
|
||||
|
||||
type GetTemplatesWithFilterParams struct {
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
ExactName string `db:"exact_name" json:"exact_name"`
|
||||
IDs []uuid.UUID `db:"ids" json:"ids"`
|
||||
Deleted bool `db:"deleted" json:"deleted"`
|
||||
OrganizationID uuid.UUID `db:"organization_id" json:"organization_id"`
|
||||
ExactName string `db:"exact_name" json:"exact_name"`
|
||||
IDs []uuid.UUID `db:"ids" json:"ids"`
|
||||
Deprecated sql.NullBool `db:"deprecated" json:"deprecated"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplatesWithFilterParams) ([]Template, error) {
|
||||
|
@ -5384,6 +5399,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
|
|||
arg.OrganizationID,
|
||||
arg.ExactName,
|
||||
pq.Array(arg.IDs),
|
||||
arg.Deprecated,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
@ -5419,6 +5435,7 @@ func (q *sqlQuerier) GetTemplatesWithFilter(ctx context.Context, arg GetTemplate
|
|||
&i.AutostopRequirementWeeks,
|
||||
&i.AutostartBlockDaysOfWeek,
|
||||
&i.RequireActiveVersion,
|
||||
&i.Deprecated,
|
||||
&i.CreatedByAvatarURL,
|
||||
&i.CreatedByUsername,
|
||||
); err != nil {
|
||||
|
@ -5519,7 +5536,8 @@ const updateTemplateAccessControlByID = `-- name: UpdateTemplateAccessControlByI
|
|||
UPDATE
|
||||
templates
|
||||
SET
|
||||
require_active_version = $2
|
||||
require_active_version = $2,
|
||||
deprecated = $3
|
||||
WHERE
|
||||
id = $1
|
||||
`
|
||||
|
@ -5527,10 +5545,11 @@ WHERE
|
|||
type UpdateTemplateAccessControlByIDParams struct {
|
||||
ID uuid.UUID `db:"id" json:"id"`
|
||||
RequireActiveVersion bool `db:"require_active_version" json:"require_active_version"`
|
||||
Deprecated string `db:"deprecated" json:"deprecated"`
|
||||
}
|
||||
|
||||
func (q *sqlQuerier) UpdateTemplateAccessControlByID(ctx context.Context, arg UpdateTemplateAccessControlByIDParams) error {
|
||||
_, err := q.db.ExecContext(ctx, updateTemplateAccessControlByID, arg.ID, arg.RequireActiveVersion)
|
||||
_, err := q.db.ExecContext(ctx, updateTemplateAccessControlByID, arg.ID, arg.RequireActiveVersion, arg.Deprecated)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -34,6 +34,17 @@ WHERE
|
|||
id = ANY(@ids)
|
||||
ELSE true
|
||||
END
|
||||
-- Filter by deprecated
|
||||
AND CASE
|
||||
WHEN sqlc.narg('deprecated') :: boolean IS NOT NULL THEN
|
||||
CASE
|
||||
WHEN sqlc.narg('deprecated') :: boolean THEN
|
||||
deprecated != ''
|
||||
ELSE
|
||||
deprecated = ''
|
||||
END
|
||||
ELSE true
|
||||
END
|
||||
-- Authorize Filter clause will be injected below in GetAuthorizedTemplates
|
||||
-- @authorize_filter
|
||||
ORDER BY (name, id) ASC
|
||||
|
@ -174,7 +185,8 @@ FROM build_times
|
|||
UPDATE
|
||||
templates
|
||||
SET
|
||||
require_active_version = $2
|
||||
require_active_version = $2,
|
||||
deprecated = $3
|
||||
WHERE
|
||||
id = $1
|
||||
;
|
||||
|
|
|
@ -437,6 +437,24 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
|
|||
ctx := r.Context()
|
||||
organization := httpmw.OrganizationParam(r)
|
||||
|
||||
p := httpapi.NewQueryParamParser()
|
||||
values := r.URL.Query()
|
||||
|
||||
deprecated := sql.NullBool{}
|
||||
if values.Has("deprecated") {
|
||||
deprecated = sql.NullBool{
|
||||
Bool: p.Boolean(values, false, "deprecated"),
|
||||
Valid: true,
|
||||
}
|
||||
}
|
||||
if len(p.Errors) > 0 {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: "Invalid query params.",
|
||||
Validations: p.Errors,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
prepared, err := api.HTTPAuth.AuthorizeSQLFilter(r, rbac.ActionRead, rbac.ResourceTemplate.Type)
|
||||
if err != nil {
|
||||
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
|
||||
|
@ -449,6 +467,7 @@ func (api *API) templatesByOrganization(rw http.ResponseWriter, r *http.Request)
|
|||
// Filter templates based on rbac permissions
|
||||
templates, err := api.Database.GetAuthorizedTemplates(ctx, database.GetTemplatesWithFilterParams{
|
||||
OrganizationID: organization.ID,
|
||||
Deprecated: deprecated,
|
||||
}, prepared)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
err = nil
|
||||
|
@ -584,6 +603,11 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
if req.AutostopRequirement.Weeks > schedule.MaxTemplateAutostopRequirementWeeks {
|
||||
validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)})
|
||||
}
|
||||
// Defaults to the existing.
|
||||
deprecationMessage := template.Deprecated
|
||||
if req.DeprecationMessage != nil {
|
||||
deprecationMessage = *req.DeprecationMessage
|
||||
}
|
||||
|
||||
// The minimum valid value for a dormant TTL is 1 minute. This is
|
||||
// to ensure an uninformed user does not send an unintentionally
|
||||
|
@ -624,7 +648,8 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
req.FailureTTLMillis == time.Duration(template.FailureTTL).Milliseconds() &&
|
||||
req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() &&
|
||||
req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() &&
|
||||
req.RequireActiveVersion == template.RequireActiveVersion {
|
||||
req.RequireActiveVersion == template.RequireActiveVersion &&
|
||||
(deprecationMessage == template.Deprecated) {
|
||||
return nil
|
||||
}
|
||||
|
||||
|
@ -648,9 +673,10 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) {
|
|||
return xerrors.Errorf("update template metadata: %w", err)
|
||||
}
|
||||
|
||||
if template.RequireActiveVersion != req.RequireActiveVersion {
|
||||
if template.RequireActiveVersion != req.RequireActiveVersion || deprecationMessage != template.Deprecated {
|
||||
err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{
|
||||
RequireActiveVersion: req.RequireActiveVersion,
|
||||
Deprecated: deprecationMessage,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("set template access control: %w", err)
|
||||
|
@ -804,6 +830,7 @@ func (api *API) convertTemplates(templates []database.Template) []codersdk.Templ
|
|||
func (api *API) convertTemplate(
|
||||
template database.Template,
|
||||
) codersdk.Template {
|
||||
templateAccessControl := (*(api.Options.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||||
activeCount, _ := api.metricsCache.TemplateUniqueUsers(template.ID)
|
||||
|
||||
buildTimeStats := api.metricsCache.TemplateBuildTimeStats(template.ID)
|
||||
|
@ -843,6 +870,9 @@ func (api *API) convertTemplate(
|
|||
AutostartRequirement: codersdk.TemplateAutostartRequirement{
|
||||
DaysOfWeek: codersdk.BitmapToWeekdays(template.AutostartAllowedDays()),
|
||||
},
|
||||
RequireActiveVersion: template.RequireActiveVersion,
|
||||
// These values depend on entitlements and come from the templateAccessControl
|
||||
RequireActiveVersion: templateAccessControl.RequireActiveVersion,
|
||||
Deprecated: templateAccessControl.IsDeprecated(),
|
||||
DeprecationMessage: templateAccessControl.Deprecated,
|
||||
}
|
||||
}
|
||||
|
|
|
@ -16,7 +16,9 @@ import (
|
|||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/database/dbauthz"
|
||||
"github.com/coder/coder/v2/coderd/database/dbtime"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/schedule"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
|
@ -516,6 +518,66 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
assert.Equal(t, database.AuditActionWrite, auditor.AuditLogs()[4].Action)
|
||||
})
|
||||
|
||||
t.Run("AGPL_Deprecated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
client := coderdtest.New(t, &coderdtest.Options{IncludeProvisionerDaemon: false})
|
||||
user := coderdtest.CreateFirstUser(t, client)
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
DeprecationMessage: ptr.Ref("APGL cannot deprecate"),
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
|
||||
// AGPL cannot deprecate, expect no change
|
||||
assert.False(t, updated.Deprecated)
|
||||
assert.Empty(t, updated.DeprecationMessage)
|
||||
})
|
||||
|
||||
// AGPL cannot deprecate, but it can be unset
|
||||
t.Run("AGPL_Unset_Deprecated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner, db := coderdtest.NewWithDatabase(t, &coderdtest.Options{IncludeProvisionerDaemon: false})
|
||||
user := coderdtest.CreateFirstUser(t, owner)
|
||||
client, tplAdmin := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
// nolint:gocritic // Setting up unit test data
|
||||
err := db.UpdateTemplateAccessControlByID(dbauthz.As(ctx, coderdtest.AuthzUserSubject(tplAdmin)), database.UpdateTemplateAccessControlByIDParams{
|
||||
ID: template.ID,
|
||||
RequireActiveVersion: false,
|
||||
Deprecated: "Some deprecated message",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Check that it is deprecated
|
||||
got, err := client.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotEmpty(t, got.DeprecationMessage, "template is deprecated to start")
|
||||
require.True(t, got.Deprecated, "template is deprecated to start")
|
||||
|
||||
req := codersdk.UpdateTemplateMeta{
|
||||
DeprecationMessage: ptr.Ref(""),
|
||||
}
|
||||
|
||||
updated, err := client.UpdateTemplateMeta(ctx, template.ID, req)
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
|
||||
assert.False(t, updated.Deprecated)
|
||||
assert.Empty(t, updated.DeprecationMessage)
|
||||
})
|
||||
|
||||
t.Run("NoDefaultTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
@ -543,6 +605,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
|
||||
assert.Equal(t, req.DefaultTTLMillis, updated.DefaultTTLMillis)
|
||||
assert.Empty(t, updated.DeprecationMessage)
|
||||
assert.False(t, updated.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("DefaultTTLTooLow", func(t *testing.T) {
|
||||
|
@ -569,6 +633,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.Equal(t, updated.UpdatedAt, template.UpdatedAt)
|
||||
assert.Equal(t, updated.DefaultTTLMillis, template.DefaultTTLMillis)
|
||||
assert.Empty(t, updated.DeprecationMessage)
|
||||
assert.False(t, updated.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("MaxTTL", func(t *testing.T) {
|
||||
|
@ -634,6 +700,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.EqualValues(t, 2, atomic.LoadInt64(&setCalled))
|
||||
require.EqualValues(t, 0, got.DefaultTTLMillis)
|
||||
require.Equal(t, maxTTL.Milliseconds(), got.MaxTTLMillis)
|
||||
require.Empty(t, got.DeprecationMessage)
|
||||
require.False(t, got.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("DefaultTTLBigger", func(t *testing.T) {
|
||||
|
@ -692,6 +760,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Equal(t, defaultTTL.Milliseconds(), got.DefaultTTLMillis)
|
||||
require.Zero(t, got.MaxTTLMillis)
|
||||
require.Empty(t, got.DeprecationMessage)
|
||||
require.False(t, got.Deprecated)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -785,6 +855,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.Zero(t, got.FailureTTLMillis)
|
||||
require.Zero(t, got.TimeTilDormantMillis)
|
||||
require.Zero(t, got.TimeTilDormantAutoDeleteMillis)
|
||||
require.Empty(t, got.DeprecationMessage)
|
||||
require.False(t, got.Deprecated)
|
||||
})
|
||||
})
|
||||
|
||||
|
@ -1036,6 +1108,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"friday", "saturday"}, template.AutostopRequirement.DaysOfWeek)
|
||||
require.EqualValues(t, 2, template.AutostopRequirement.Weeks)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("Unset", func(t *testing.T) {
|
||||
|
@ -1146,6 +1220,8 @@ func TestPatchTemplateMeta(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Empty(t, template.AutostopRequirement.DaysOfWeek)
|
||||
require.EqualValues(t, 1, template.AutostopRequirement.Weeks)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
@ -394,6 +394,17 @@ func (api *API) postWorkspacesByOrganization(rw http.ResponseWriter, r *http.Req
|
|||
return
|
||||
}
|
||||
|
||||
templateAccessControl := (*(api.AccessControlStore.Load())).GetTemplateAccessControl(template)
|
||||
if templateAccessControl.IsDeprecated() {
|
||||
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template %q has been deprecated, and cannot be used to create a new workspace.", template.Name),
|
||||
// Pass the deprecated message to the user.
|
||||
Detail: templateAccessControl.Deprecated,
|
||||
Validations: nil,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if organization.ID != template.OrganizationID {
|
||||
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
|
||||
Message: fmt.Sprintf("Template is not in organization %q.", organization.Name),
|
||||
|
|
|
@ -24,11 +24,13 @@ type Template struct {
|
|||
Provisioner ProvisionerType `json:"provisioner" enums:"terraform"`
|
||||
ActiveVersionID uuid.UUID `json:"active_version_id" format:"uuid"`
|
||||
// ActiveUserCount is set to -1 when loading.
|
||||
ActiveUserCount int `json:"active_user_count"`
|
||||
BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"`
|
||||
Description string `json:"description"`
|
||||
Icon string `json:"icon"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
||||
ActiveUserCount int `json:"active_user_count"`
|
||||
BuildTimeStats TemplateBuildTimeStats `json:"build_time_stats"`
|
||||
Description string `json:"description"`
|
||||
Deprecated bool `json:"deprecated"`
|
||||
DeprecationMessage string `json:"deprecation_message"`
|
||||
Icon string `json:"icon"`
|
||||
DefaultTTLMillis int64 `json:"default_ttl_ms"`
|
||||
// TODO(@dean): remove max_ttl once autostop_requirement is matured
|
||||
MaxTTLMillis int64 `json:"max_ttl_ms"`
|
||||
// AutostopRequirement and AutostartRequirement are enterprise features. Its
|
||||
|
@ -229,6 +231,11 @@ type UpdateTemplateMeta struct {
|
|||
// use the active version of the template. This option has no
|
||||
// effect on template admins.
|
||||
RequireActiveVersion bool `json:"require_active_version"`
|
||||
// DeprecationMessage if set, will mark the template as deprecated and block
|
||||
// any new workspaces from using this template.
|
||||
// If passed an empty string, will remove the deprecated message, making
|
||||
// the template usable for new workspaces again.
|
||||
DeprecationMessage *string `json:"deprecation_message"`
|
||||
}
|
||||
|
||||
type TemplateExample struct {
|
||||
|
|
|
@ -8,19 +8,19 @@ 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> |
|
||||
| AuditOAuthConvertState<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>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</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>display_name</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><tr><td>source</td><td>false</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>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</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>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>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</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>archived</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_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>message</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>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</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>automatic_updates</td><td>true</td></tr><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>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</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_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</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>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</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>region_id</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> |
|
||||
| AuditOAuthConvertState<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>expires_at</td><td>true</td></tr><tr><td>from_login_type</td><td>true</td></tr><tr><td>to_login_type</td><td>true</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>display_name</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><tr><td>source</td><td>false</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>autostart_block_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_days_of_week</td><td>true</td></tr><tr><td>autostop_requirement_weeks</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>default_ttl</td><td>true</td></tr><tr><td>deleted</td><td>false</td></tr><tr><td>deprecated</td><td>true</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>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>require_active_version</td><td>true</td></tr><tr><td>time_til_dormant</td><td>true</td></tr><tr><td>time_til_dormant_autodelete</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>archived</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>created_by_avatar_url</td><td>false</td></tr><tr><td>created_by_username</td><td>false</td></tr><tr><td>external_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>message</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>true</td></tr><tr><td>quiet_hours_schedule</td><td>true</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>automatic_updates</td><td>true</td></tr><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>deleting_at</td><td>true</td></tr><tr><td>dormant_at</td><td>true</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_by_avatar_url</td><td>false</td></tr><tr><td>initiator_by_username</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>derp_enabled</td><td>true</td></tr><tr><td>derp_only</td><td>true</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>region_id</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'. -->
|
||||
|
||||
|
|
|
@ -4411,6 +4411,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
@ -4443,6 +4445,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in
|
|||
| `created_by_id` | string | false | | |
|
||||
| `created_by_name` | string | false | | |
|
||||
| `default_ttl_ms` | integer | false | | |
|
||||
| `deprecated` | boolean | false | | |
|
||||
| `deprecation_message` | string | false | | |
|
||||
| `description` | string | false | | |
|
||||
| `display_name` | string | false | | |
|
||||
| `failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
|
||||
|
|
|
@ -52,6 +52,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
@ -101,6 +103,8 @@ Status Code **200**
|
|||
| `» created_by_id` | string(uuid) | false | | |
|
||||
| `» created_by_name` | string | false | | |
|
||||
| `» default_ttl_ms` | integer | false | | |
|
||||
| `» deprecated` | boolean | false | | |
|
||||
| `» deprecation_message` | string | false | | |
|
||||
| `» description` | string | false | | |
|
||||
| `» display_name` | string | false | | |
|
||||
| `» failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. |
|
||||
|
@ -205,6 +209,8 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/templa
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
@ -341,6 +347,8 @@ curl -X GET http://coder-server:8080/api/v2/organizations/{organization}/templat
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
@ -653,6 +661,8 @@ curl -X GET http://coder-server:8080/api/v2/templates/{template} \
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
@ -772,6 +782,8 @@ curl -X PATCH http://coder-server:8080/api/v2/templates/{template} \
|
|||
"created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f",
|
||||
"created_by_name": "string",
|
||||
"default_ttl_ms": 0,
|
||||
"deprecated": true,
|
||||
"deprecation_message": "string",
|
||||
"description": "string",
|
||||
"display_name": "string",
|
||||
"failure_ttl_ms": 0,
|
||||
|
|
|
@ -55,6 +55,14 @@ Edit the template autostart requirement weekdays - workspaces created from this
|
|||
|
||||
Edit the template default time before shutdown - workspaces created from this template default to this value. Maps to "Default autostop" in the UI.
|
||||
|
||||
### --deprecated
|
||||
|
||||
| | |
|
||||
| ---- | ------------------- |
|
||||
| Type | <code>string</code> |
|
||||
|
||||
Sets the template as deprecated. Must be a message explaining why the template is deprecated.
|
||||
|
||||
### --description
|
||||
|
||||
| | |
|
||||
|
|
|
@ -86,6 +86,7 @@ var auditableResourcesTypes = map[any]map[string]Action{
|
|||
"time_til_dormant": ActionTrack,
|
||||
"time_til_dormant_autodelete": ActionTrack,
|
||||
"require_active_version": ActionTrack,
|
||||
"deprecated": ActionTrack,
|
||||
},
|
||||
&database.TemplateVersion{}: {
|
||||
"id": ActionTrack,
|
||||
|
|
|
@ -15,6 +15,7 @@ type EnterpriseTemplateAccessControlStore struct{}
|
|||
func (EnterpriseTemplateAccessControlStore) GetTemplateAccessControl(t database.Template) agpldbz.TemplateAccessControl {
|
||||
return agpldbz.TemplateAccessControl{
|
||||
RequireActiveVersion: t.RequireActiveVersion,
|
||||
Deprecated: t.Deprecated,
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -22,6 +23,7 @@ func (EnterpriseTemplateAccessControlStore) SetTemplateAccessControl(ctx context
|
|||
err := store.UpdateTemplateAccessControlByID(ctx, database.UpdateTemplateAccessControlByIDParams{
|
||||
ID: id,
|
||||
RequireActiveVersion: opts.RequireActiveVersion,
|
||||
Deprecated: opts.Deprecated,
|
||||
})
|
||||
if err != nil {
|
||||
return xerrors.Errorf("update template access control: %w", err)
|
||||
|
|
|
@ -8,12 +8,14 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/coder/coder/v2/coderd/audit"
|
||||
"github.com/coder/coder/v2/coderd/coderdtest"
|
||||
"github.com/coder/coder/v2/coderd/database"
|
||||
"github.com/coder/coder/v2/coderd/rbac"
|
||||
"github.com/coder/coder/v2/coderd/util/ptr"
|
||||
"github.com/coder/coder/v2/codersdk"
|
||||
"github.com/coder/coder/v2/cryptorand"
|
||||
"github.com/coder/coder/v2/enterprise/coderd/coderdenttest"
|
||||
|
@ -89,6 +91,55 @@ func TestTemplates(t *testing.T) {
|
|||
require.Contains(t, apiErr.Validations[0].Detail, "time until shutdown must be less than or equal to the template's maximum TTL")
|
||||
})
|
||||
|
||||
t.Run("Deprecated", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
owner, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
Options: &coderdtest.Options{
|
||||
IncludeProvisionerDaemon: true,
|
||||
},
|
||||
LicenseOptions: &coderdenttest.LicenseOptions{
|
||||
Features: license.Features{
|
||||
codersdk.FeatureAccessControl: 1,
|
||||
},
|
||||
},
|
||||
})
|
||||
client, _ := coderdtest.CreateAnotherUser(t, owner, user.OrganizationID, rbac.RoleTemplateAdmin())
|
||||
version := coderdtest.CreateTemplateVersion(t, client, user.OrganizationID, nil)
|
||||
template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID)
|
||||
coderdtest.AwaitTemplateVersionJobCompleted(t, client, version.ID)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong)
|
||||
defer cancel()
|
||||
|
||||
updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{
|
||||
DeprecationMessage: ptr.Ref("Stop using this template"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Greater(t, updated.UpdatedAt, template.UpdatedAt)
|
||||
// AGPL cannot deprecate, expect no change
|
||||
assert.True(t, updated.Deprecated)
|
||||
assert.NotEmpty(t, updated.DeprecationMessage)
|
||||
|
||||
_, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "foobar",
|
||||
})
|
||||
require.ErrorContains(t, err, "deprecated")
|
||||
|
||||
// Unset deprecated and try again
|
||||
updated, err = client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{DeprecationMessage: ptr.Ref("")})
|
||||
require.NoError(t, err)
|
||||
assert.False(t, updated.Deprecated)
|
||||
assert.Empty(t, updated.DeprecationMessage)
|
||||
|
||||
_, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{
|
||||
TemplateID: template.ID,
|
||||
Name: "foobar",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
client, user := coderdenttest.New(t, &coderdenttest.Options{
|
||||
|
@ -193,6 +244,8 @@ func TestTemplates(t *testing.T) {
|
|||
template, err = anotherClient.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"monday", "saturday"}, template.AutostartRequirement.DaysOfWeek)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("SetInvalidAutostartRequirement", func(t *testing.T) {
|
||||
|
@ -226,6 +279,8 @@ func TestTemplates(t *testing.T) {
|
|||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("SetAutostopRequirement", func(t *testing.T) {
|
||||
|
@ -270,6 +325,8 @@ func TestTemplates(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
require.Equal(t, []string{"monday", "saturday"}, template.AutostopRequirement.DaysOfWeek)
|
||||
require.EqualValues(t, 3, template.AutostopRequirement.Weeks)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
|
||||
t.Run("CleanupTTLs", func(t *testing.T) {
|
||||
|
@ -627,6 +684,8 @@ func TestTemplates(t *testing.T) {
|
|||
template, err = anotherClient.Template(ctx, template.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, updatedTemplate, template)
|
||||
require.Empty(t, template.DeprecationMessage)
|
||||
require.False(t, template.Deprecated)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -220,11 +220,27 @@ export const getTemplate = async (
|
|||
return response.data;
|
||||
};
|
||||
|
||||
export interface TemplateOptions {
|
||||
readonly deprecated?: boolean;
|
||||
}
|
||||
|
||||
export const getTemplates = async (
|
||||
organizationId: string,
|
||||
options?: TemplateOptions,
|
||||
): Promise<TypesGen.Template[]> => {
|
||||
const params = {} as Record<string, string>;
|
||||
if (options && options.deprecated !== undefined) {
|
||||
// Just want to check if it isn't undefined. If it has
|
||||
// a boolean value, convert it to a string and include
|
||||
// it as a param.
|
||||
params["deprecated"] = String(options.deprecated);
|
||||
}
|
||||
|
||||
const response = await axios.get<TypesGen.Template[]>(
|
||||
`/api/v2/organizations/${organizationId}/templates`,
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
return response.data;
|
||||
};
|
||||
|
|
|
@ -33,12 +33,16 @@ export const templateByName = (
|
|||
};
|
||||
};
|
||||
|
||||
const getTemplatesQueryKey = (orgId: string) => [orgId, "templates"];
|
||||
const getTemplatesQueryKey = (orgId: string, deprecated?: boolean) => [
|
||||
orgId,
|
||||
"templates",
|
||||
deprecated,
|
||||
];
|
||||
|
||||
export const templates = (orgId: string) => {
|
||||
export const templates = (orgId: string, deprecated?: boolean) => {
|
||||
return {
|
||||
queryKey: getTemplatesQueryKey(orgId),
|
||||
queryFn: () => API.getTemplates(orgId),
|
||||
queryKey: getTemplatesQueryKey(orgId, deprecated),
|
||||
queryFn: () => API.getTemplates(orgId, { deprecated }),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -918,6 +918,8 @@ export interface Template {
|
|||
readonly active_user_count: number;
|
||||
readonly build_time_stats: TemplateBuildTimeStats;
|
||||
readonly description: string;
|
||||
readonly deprecated: boolean;
|
||||
readonly deprecation_message: string;
|
||||
readonly icon: string;
|
||||
readonly default_ttl_ms: number;
|
||||
readonly max_ttl_ms: number;
|
||||
|
@ -1183,6 +1185,7 @@ export interface UpdateTemplateMeta {
|
|||
readonly update_workspace_last_used_at: boolean;
|
||||
readonly update_workspace_dormant_at: boolean;
|
||||
readonly require_active_version: boolean;
|
||||
readonly deprecation_message?: string;
|
||||
}
|
||||
|
||||
// From codersdk/users.go
|
||||
|
|
|
@ -34,6 +34,7 @@ import {
|
|||
ThreeDotsButton,
|
||||
} from "components/MoreMenu/MoreMenu";
|
||||
import Divider from "@mui/material/Divider";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
|
||||
type TemplateMenuProps = {
|
||||
templateName: string;
|
||||
|
@ -172,14 +173,16 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
|
|||
<PageHeader
|
||||
actions={
|
||||
<>
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to={`/templates/${template.name}/workspace`}
|
||||
>
|
||||
Create Workspace
|
||||
</Button>
|
||||
{!template.deprecated && (
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
component={RouterLink}
|
||||
to={`/templates/${template.name}/workspace`}
|
||||
>
|
||||
Create Workspace
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{permissions.canUpdateTemplate && (
|
||||
<TemplateMenu
|
||||
|
@ -212,6 +215,8 @@ export const TemplatePageHeader: FC<TemplatePageHeaderProps> = ({
|
|||
</PageHeaderSubtitle>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{template.deprecated && <Pill text="Deprecated" type="warning" />}
|
||||
</Stack>
|
||||
</PageHeader>
|
||||
</Margins>
|
||||
|
|
|
@ -24,6 +24,7 @@ import {
|
|||
HelpTooltip,
|
||||
HelpTooltipText,
|
||||
} from "components/HelpTooltip/HelpTooltip";
|
||||
import { EnterpriseBadge } from "components/Badges/Badges";
|
||||
|
||||
const MAX_DESCRIPTION_CHAR_LIMIT = 128;
|
||||
|
||||
|
@ -49,6 +50,7 @@ export interface TemplateSettingsForm {
|
|||
// Helpful to show field errors on Storybook
|
||||
initialTouched?: FormikTouched<UpdateTemplateMeta>;
|
||||
accessControlEnabled: boolean;
|
||||
templatePoliciesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
||||
|
@ -59,6 +61,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
isSubmitting,
|
||||
initialTouched,
|
||||
accessControlEnabled,
|
||||
templatePoliciesEnabled,
|
||||
}) => {
|
||||
const validationSchema = getValidationSchema();
|
||||
const form: FormikContextType<UpdateTemplateMeta> =
|
||||
|
@ -73,6 +76,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
update_workspace_last_used_at: false,
|
||||
update_workspace_dormant_at: false,
|
||||
require_active_version: template.require_active_version,
|
||||
deprecation_message: template.deprecation_message,
|
||||
},
|
||||
validationSchema,
|
||||
onSubmit,
|
||||
|
@ -170,7 +174,7 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
</Stack>
|
||||
</Stack>
|
||||
</label>
|
||||
{accessControlEnabled && (
|
||||
{templatePoliciesEnabled && (
|
||||
<label htmlFor="require_active_version">
|
||||
<Stack direction="row" spacing={1}>
|
||||
<Checkbox
|
||||
|
@ -205,6 +209,45 @@ export const TemplateSettingsForm: FC<TemplateSettingsForm> = ({
|
|||
</Stack>
|
||||
</FormSection>
|
||||
|
||||
<FormSection
|
||||
title="Deprecate"
|
||||
description="Deprecating a template prevents any new workspaces from being created. Existing workspaces will continue to function."
|
||||
>
|
||||
<FormFields>
|
||||
<Stack direction="column" spacing={0.5}>
|
||||
<Stack
|
||||
direction="row"
|
||||
alignItems="center"
|
||||
spacing={0.5}
|
||||
css={styles.optionText}
|
||||
>
|
||||
Deprecation Message
|
||||
</Stack>
|
||||
<span css={styles.optionHelperText}>
|
||||
Leave the message empty to keep the template active. Any message
|
||||
provided will mark the template as deprecated. Use this message to
|
||||
inform users of the deprecation and how to migrate to a new
|
||||
template.
|
||||
</span>
|
||||
</Stack>
|
||||
<TextField
|
||||
{...getFieldHelpers("deprecation_message")}
|
||||
disabled={isSubmitting || !accessControlEnabled}
|
||||
autoFocus
|
||||
fullWidth
|
||||
label="Deprecation Message"
|
||||
/>
|
||||
{!accessControlEnabled && (
|
||||
<Stack direction="row">
|
||||
<EnterpriseBadge />
|
||||
<span css={styles.optionHelperText}>
|
||||
Enterprise license required to deprecate templates.
|
||||
</span>
|
||||
</Stack>
|
||||
)}
|
||||
</FormFields>
|
||||
</FormSection>
|
||||
|
||||
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
|
||||
</HorizontalForm>
|
||||
);
|
||||
|
|
|
@ -12,7 +12,10 @@ import { getValidationSchema } from "./TemplateSettingsForm";
|
|||
import { TemplateSettingsPage } from "./TemplateSettingsPage";
|
||||
|
||||
type FormValues = Required<
|
||||
Omit<UpdateTemplateMeta, "default_ttl_ms" | "max_ttl_ms">
|
||||
Omit<
|
||||
UpdateTemplateMeta,
|
||||
"default_ttl_ms" | "max_ttl_ms" | "deprecation_message"
|
||||
>
|
||||
>;
|
||||
|
||||
const validFormValues: FormValues = {
|
||||
|
|
|
@ -10,7 +10,10 @@ import { useTemplateSettings } from "../TemplateSettingsLayout";
|
|||
import { TemplateSettingsPageView } from "./TemplateSettingsPageView";
|
||||
import { templateByNameKey } from "api/queries/templates";
|
||||
import { useOrganizationId } from "hooks";
|
||||
import { useTemplatePoliciesEnabled } from "components/Dashboard/DashboardProvider";
|
||||
import {
|
||||
useDashboard,
|
||||
useTemplatePoliciesEnabled,
|
||||
} from "components/Dashboard/DashboardProvider";
|
||||
|
||||
export const TemplateSettingsPage: FC = () => {
|
||||
const { template: templateName } = useParams() as { template: string };
|
||||
|
@ -18,7 +21,9 @@ export const TemplateSettingsPage: FC = () => {
|
|||
const orgId = useOrganizationId();
|
||||
const { template } = useTemplateSettings();
|
||||
const queryClient = useQueryClient();
|
||||
const accessControlEnabled = useTemplatePoliciesEnabled();
|
||||
const { entitlements } = useDashboard();
|
||||
const accessControlEnabled = entitlements.features.access_control.enabled;
|
||||
const templatePoliciesEnabled = useTemplatePoliciesEnabled();
|
||||
|
||||
const {
|
||||
mutate: updateTemplate,
|
||||
|
@ -55,6 +60,7 @@ export const TemplateSettingsPage: FC = () => {
|
|||
});
|
||||
}}
|
||||
accessControlEnabled={accessControlEnabled}
|
||||
templatePoliciesEnabled={templatePoliciesEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -7,6 +7,8 @@ const meta: Meta<typeof TemplateSettingsPageView> = {
|
|||
component: TemplateSettingsPageView,
|
||||
args: {
|
||||
template: MockTemplate,
|
||||
accessControlEnabled: true,
|
||||
templatePoliciesEnabled: true,
|
||||
},
|
||||
};
|
||||
|
||||
|
@ -31,3 +33,9 @@ export const SaveTemplateSettingsError: Story = {
|
|||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const NoEntitlements: Story = {
|
||||
args: {
|
||||
accessControlEnabled: false,
|
||||
},
|
||||
};
|
||||
|
|
|
@ -13,6 +13,7 @@ export interface TemplateSettingsPageViewProps {
|
|||
typeof TemplateSettingsForm
|
||||
>["initialTouched"];
|
||||
accessControlEnabled: boolean;
|
||||
templatePoliciesEnabled: boolean;
|
||||
}
|
||||
|
||||
export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
||||
|
@ -23,6 +24,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
submitError,
|
||||
initialTouched,
|
||||
accessControlEnabled,
|
||||
templatePoliciesEnabled,
|
||||
}) => {
|
||||
return (
|
||||
<>
|
||||
|
@ -38,6 +40,7 @@ export const TemplateSettingsPageView: FC<TemplateSettingsPageViewProps> = ({
|
|||
onCancel={onCancel}
|
||||
error={submitError}
|
||||
accessControlEnabled={accessControlEnabled}
|
||||
templatePoliciesEnabled={templatePoliciesEnabled}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
|
|
@ -38,6 +38,28 @@ export const WithTemplates: Story = {
|
|||
description:
|
||||
"Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. ",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
name: "template-without-icon",
|
||||
display_name: "No Icon",
|
||||
description: "This one has no icon",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
name: "template-without-icon-deprecated",
|
||||
display_name: "Deprecated No Icon",
|
||||
description: "This one has no icon and is deprecated",
|
||||
deprecated: true,
|
||||
deprecation_message: "This template is so old, it's deprecated",
|
||||
icon: "",
|
||||
},
|
||||
{
|
||||
...MockTemplate,
|
||||
name: "deprecated-template",
|
||||
display_name: "Deprecated",
|
||||
description: "Template is incompatible",
|
||||
},
|
||||
],
|
||||
examples: [],
|
||||
},
|
||||
|
|
|
@ -44,6 +44,7 @@ import { docs } from "utils/docs";
|
|||
import Skeleton from "@mui/material/Skeleton";
|
||||
import { Box } from "@mui/system";
|
||||
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
|
||||
import { Pill } from "components/Pill/Pill";
|
||||
|
||||
export const Language = {
|
||||
developerCount: (activeCount: number): string => {
|
||||
|
@ -118,19 +119,23 @@ const TemplateRow: FC<{ template: Template }> = ({ template }) => {
|
|||
</TableCell>
|
||||
|
||||
<TableCell css={styles.actionCell}>
|
||||
<Button
|
||||
size="small"
|
||||
css={styles.actionButton}
|
||||
className="actionButton"
|
||||
startIcon={<ArrowForwardOutlined />}
|
||||
title={`Create a workspace using the ${template.display_name} template`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/templates/${template.name}/workspace`);
|
||||
}}
|
||||
>
|
||||
Create Workspace
|
||||
</Button>
|
||||
{template.deprecated ? (
|
||||
<Pill text="Deprecated" type="warning" />
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
css={styles.actionButton}
|
||||
className="actionButton"
|
||||
startIcon={<ArrowForwardOutlined />}
|
||||
title={`Create a workspace using the ${template.display_name} template`}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
navigate(`/templates/${template.name}/workspace`);
|
||||
}}
|
||||
>
|
||||
Create Workspace
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
|
|
|
@ -243,6 +243,17 @@ export const CancellationError: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
export const Deprecated: Story = {
|
||||
args: {
|
||||
...Running.args,
|
||||
template: {
|
||||
...Mocks.MockTemplate,
|
||||
deprecated: true,
|
||||
deprecation_message: "Template deprecated due to reasons",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export const Unhealthy: Story = {
|
||||
args: {
|
||||
...Running.args,
|
||||
|
|
|
@ -328,6 +328,13 @@ export const Workspace: FC<React.PropsWithChildren<WorkspaceProps>> = ({
|
|||
</Alert>
|
||||
)}
|
||||
|
||||
{template?.deprecated && (
|
||||
<Alert severity="warning">
|
||||
<AlertTitle>Workspace using deprecated template</AlertTitle>
|
||||
<AlertDetail>{template?.deprecation_message}</AlertDetail>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{transitionStats !== undefined && (
|
||||
<WorkspaceBuildProgress
|
||||
workspace={workspace}
|
||||
|
|
|
@ -43,7 +43,7 @@ const WorkspacesPage: FC = () => {
|
|||
const pagination = usePagination({ searchParamsResult });
|
||||
|
||||
const organizationId = useOrganizationId();
|
||||
const templatesQuery = useQuery(templates(organizationId));
|
||||
const templatesQuery = useQuery(templates(organizationId, false));
|
||||
|
||||
const filterProps = useWorkspacesFilter({
|
||||
searchParamsResult,
|
||||
|
|
|
@ -17,6 +17,7 @@ export const useTemplateFilterMenu = ({
|
|||
value,
|
||||
id: "template",
|
||||
getSelectedOption: async () => {
|
||||
// Show all templates including deprecated
|
||||
const templates = await getTemplates(orgId);
|
||||
const template = templates.find((template) => template.name === value);
|
||||
if (template) {
|
||||
|
@ -32,6 +33,7 @@ export const useTemplateFilterMenu = ({
|
|||
return null;
|
||||
},
|
||||
getOptions: async (query) => {
|
||||
// Show all templates including deprecated
|
||||
const templates = await getTemplates(orgId);
|
||||
const filteredTemplates = templates.filter(
|
||||
(template) =>
|
||||
|
|
|
@ -452,6 +452,8 @@ export const MockTemplate: TypesGen.Template = {
|
|||
allow_user_autostart: true,
|
||||
allow_user_autostop: true,
|
||||
require_active_version: false,
|
||||
deprecated: false,
|
||||
deprecation_message: "",
|
||||
};
|
||||
|
||||
export const MockTemplateVersionFiles: TemplateVersionFiles = {
|
||||
|
|
Loading…
Reference in New Issue