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:
Steven Masley 2023-11-20 13:16:18 -06:00 committed by GitHub
parent d8df87d5ae
commit 5229d7fd3a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
41 changed files with 593 additions and 66 deletions

View File

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

View File

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

6
coderd/apidoc/docs.go generated
View File

@ -10057,6 +10057,12 @@ const docTemplate = `{
"default_ttl_ms": {
"type": "integer"
},
"deprecated": {
"type": "boolean"
},
"deprecation_message": {
"type": "string"
},
"description": {
"type": "string"
},

View File

@ -9085,6 +9085,12 @@
"default_ttl_ms": {
"type": "integer"
},
"deprecated": {
"type": "boolean"
},
"deprecation_message": {
"type": "string"
},
"description": {
"type": "string"
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

4
docs/api/schemas.md generated
View File

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

12
docs/api/templates.md generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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}
/>
</>
);

View File

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

View File

@ -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}
/>
</>
);

View File

@ -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: [],
},

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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