From c2b05f082e18644821ca486c158738f4569f55ea Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Thu, 16 Nov 2023 16:42:28 -0600 Subject: [PATCH 01/15] feat: implement deprecated flag for templates to prevent new workspaces --- cli/templateedit.go | 18 +++++++++ coderd/database/dbauthz/accesscontrol.go | 12 +++++- coderd/database/dbmem/dbmem.go | 1 + coderd/database/dump.sql | 6 ++- .../000169_deprecate_template.down.sql | 24 ++++++++++++ .../000169_deprecate_template.up.sql | 28 ++++++++++++++ coderd/database/models.go | 3 ++ coderd/database/queries.sql.go | 18 ++++++--- coderd/database/queries/templates.sql | 3 +- coderd/templates.go | 17 +++++++-- coderd/templates_test.go | 23 +++++++++++ coderd/workspaces.go | 11 ++++++ codersdk/templates.go | 17 ++++++--- enterprise/audit/table.go | 1 + enterprise/coderd/dbauthz/accesscontrol.go | 2 + enterprise/coderd/templates_test.go | 38 +++++++++++++++++++ 16 files changed, 205 insertions(+), 17 deletions(-) create mode 100644 coderd/database/migrations/000169_deprecate_template.down.sql create mode 100644 coderd/database/migrations/000169_deprecate_template.up.sql diff --git a/cli/templateedit.go b/cli/templateedit.go index 1c17ec52bcab3..126f87a0c3b79 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -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 + deprecatedMessage 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 = &deprecatedMessage + } + // 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, + DeprecatedMessage: 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(&deprecatedMessage), + }, { Flag: "icon", Description: "Edit the template icon path.", diff --git a/coderd/database/dbauthz/accesscontrol.go b/coderd/database/dbauthz/accesscontrol.go index 92417ff4114ba..096676a43a1b5 100644 --- a/coderd/database/dbauthz/accesscontrol.go +++ b/coderd/database/dbauthz/accesscontrol.go @@ -18,6 +18,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,9 +31,14 @@ 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, } } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index cfeebdc928b1c..59dda6dc14e8a 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -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 } diff --git a/coderd/database/dump.sql b/coderd/database/dump.sql index c4e1c26f1b389..a475d82f3d805 100644 --- a/coderd/database/dump.sql +++ b/coderd/database/dump.sql @@ -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 diff --git a/coderd/database/migrations/000169_deprecate_template.down.sql b/coderd/database/migrations/000169_deprecate_template.down.sql new file mode 100644 index 0000000000000..3d944135ae30b --- /dev/null +++ b/coderd/database/migrations/000169_deprecate_template.down.sql @@ -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; diff --git a/coderd/database/migrations/000169_deprecate_template.up.sql b/coderd/database/migrations/000169_deprecate_template.up.sql new file mode 100644 index 0000000000000..3e688a6bef2a1 --- /dev/null +++ b/coderd/database/migrations/000169_deprecate_template.up.sql @@ -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; diff --git a/coderd/database/models.go b/coderd/database/models.go index 84147dc28fcc2..8ab3417dd6406 100644 --- a/coderd/database/models.go +++ b/coderd/database/models.go @@ -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. diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fb42ca657e6e7..fa65e4f236979 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -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 @@ -5419,6 +5422,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 +5523,8 @@ const updateTemplateAccessControlByID = `-- name: UpdateTemplateAccessControlByI UPDATE templates SET - require_active_version = $2 + require_active_version = $2, + deprecated = $3 WHERE id = $1 ` @@ -5527,10 +5532,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 } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index c5bc72d7911d6..6e1518baf9ef0 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -174,7 +174,8 @@ FROM build_times UPDATE templates SET - require_active_version = $2 + require_active_version = $2, + deprecated = $3 WHERE id = $1 ; diff --git a/coderd/templates.go b/coderd/templates.go index c1f3bc97a01c3..a29b47a2bf46b 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -584,6 +584,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. + deprecatedMessage := template.Deprecated + if req.DeprecatedMessage != nil { + deprecatedMessage = *req.DeprecatedMessage + } // 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 +629,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 && + (deprecatedMessage == template.Deprecated) { return nil } @@ -648,9 +654,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 || deprecatedMessage != template.Deprecated { err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ RequireActiveVersion: req.RequireActiveVersion, + Deprecated: deprecatedMessage, }) if err != nil { return xerrors.Errorf("set template access control: %w", err) @@ -804,6 +811,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 +851,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(), + DeprecatedMessage: templateAccessControl.Deprecated, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index a218119e266e4..447d6a0ae78a7 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -516,6 +516,29 @@ 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{ + DeprecatedMessage: 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.DeprecatedMessage) + }) + t.Run("NoDefaultTTL", func(t *testing.T) { t.Parallel() diff --git a/coderd/workspaces.go b/coderd/workspaces.go index 6a86e9e735501..e55879338e5e7 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -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), diff --git a/codersdk/templates.go b/codersdk/templates.go index 3a3240ca711b2..f59175ac3fdda 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -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"` + DeprecatedMessage string `json:"deprecated_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"` + // DeprecatedMessage 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. + DeprecatedMessage *string `json:"deprecated_message"` } type TemplateExample struct { diff --git a/enterprise/audit/table.go b/enterprise/audit/table.go index f272354d649ac..e49432091d6db 100644 --- a/enterprise/audit/table.go +++ b/enterprise/audit/table.go @@ -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, diff --git a/enterprise/coderd/dbauthz/accesscontrol.go b/enterprise/coderd/dbauthz/accesscontrol.go index 454f416ab8736..7ba49bf03f5c3 100644 --- a/enterprise/coderd/dbauthz/accesscontrol.go +++ b/enterprise/coderd/dbauthz/accesscontrol.go @@ -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) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index 9ff7799553d03..c6f55d766d92d 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -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,42 @@ 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() + + client, user := coderdenttest.New(t, &coderdenttest.Options{ + Options: &coderdtest.Options{ + IncludeProvisionerDaemon: true, + }, + LicenseOptions: &coderdenttest.LicenseOptions{ + Features: license.Features{ + codersdk.FeatureAccessControl: 1, + }, + }, + }) + 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{ + DeprecatedMessage: 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.DeprecatedMessage) + + _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ + TemplateID: template.ID, + Name: "foobar", + }) + require.ErrorContains(t, err, "deprecated") + }) + t.Run("BlockDisablingAutoOffWithMaxTTL", func(t *testing.T) { t.Parallel() client, user := coderdenttest.New(t, &coderdenttest.Options{ From 628b97a1a7436f22fba2d438de7fb1ba661c9615 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 17 Nov 2023 09:59:29 -0600 Subject: [PATCH 02/15] Fix which user is used in unit tests --- enterprise/coderd/templates_test.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index c6f55d766d92d..f76a9a4640455 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -94,7 +94,7 @@ func TestTemplates(t *testing.T) { t.Run("Deprecated", func(t *testing.T) { t.Parallel() - client, user := coderdenttest.New(t, &coderdenttest.Options{ + owner, user := coderdenttest.New(t, &coderdenttest.Options{ Options: &coderdtest.Options{ IncludeProvisionerDaemon: true, }, @@ -104,6 +104,7 @@ func TestTemplates(t *testing.T) { }, }, }) + 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) From 0935732e691298b00aab72954cc1237b1e1fd847 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 17 Nov 2023 10:46:17 -0600 Subject: [PATCH 03/15] Update golden files --- cli/testdata/coder_templates_edit_--help.golden | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cli/testdata/coder_templates_edit_--help.golden b/cli/testdata/coder_templates_edit_--help.golden index fd5841125e708..bd4cdee8d8482 100644 --- a/cli/testdata/coder_templates_edit_--help.golden +++ b/cli/testdata/coder_templates_edit_--help.golden @@ -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. From 33f54f87d0573251add4c221d8961e2d1811a9f2 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 17 Nov 2023 11:45:01 -0600 Subject: [PATCH 04/15] Add deprecated filter to template fetching --- coderd/database/dbmem/dbmem.go | 3 +++ coderd/database/modelqueries.go | 2 ++ coderd/database/queries.sql.go | 21 +++++++++++++++---- coderd/database/queries/templates.sql | 11 ++++++++++ coderd/templates.go | 19 +++++++++++++++++ site/src/api/api.ts | 12 +++++++++++ site/src/api/queries/templates.ts | 8 +++---- .../pages/WorkspacesPage/WorkspacesPage.tsx | 2 +- site/src/pages/WorkspacesPage/filter/menus.ts | 6 ++++-- 9 files changed, 73 insertions(+), 11 deletions(-) diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index 59dda6dc14e8a..1837c6f2588b5 100644 --- a/coderd/database/dbmem/dbmem.go +++ b/coderd/database/dbmem/dbmem.go @@ -6888,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 diff --git a/coderd/database/modelqueries.go b/coderd/database/modelqueries.go index 5c78600237e1d..a050997a17ba1 100644 --- a/coderd/database/modelqueries.go +++ b/coderd/database/modelqueries.go @@ -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 { diff --git a/coderd/database/queries.sql.go b/coderd/database/queries.sql.go index fa65e4f236979..842b05380258e 100644 --- a/coderd/database/queries.sql.go +++ b/coderd/database/queries.sql.go @@ -5369,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) { @@ -5387,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 diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index 6e1518baf9ef0..87d827b0daea6 100644 --- a/coderd/database/queries/templates.sql +++ b/coderd/database/queries/templates.sql @@ -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 diff --git a/coderd/templates.go b/coderd/templates.go index a29b47a2bf46b..91a528809f34f 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -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 diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 6f0e4d38327df..13994306e7842 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -222,9 +222,21 @@ export const getTemplate = async ( export const getTemplates = async ( organizationId: string, + deprecated?: boolean, ): Promise => { + const params = {} as Record + if(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(deprecated) + } + const response = await axios.get( `/api/v2/organizations/${organizationId}/templates`, + { + params, + } ); return response.data; }; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 406f7a0bb53e1..9a18ec5e17a30 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -33,12 +33,12 @@ 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), }; }; diff --git a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx index e237fc7867215..fa2ae3ea9e8f5 100644 --- a/site/src/pages/WorkspacesPage/WorkspacesPage.tsx +++ b/site/src/pages/WorkspacesPage/WorkspacesPage.tsx @@ -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, diff --git a/site/src/pages/WorkspacesPage/filter/menus.ts b/site/src/pages/WorkspacesPage/filter/menus.ts index d83a00aba6afd..48696b53b25bd 100644 --- a/site/src/pages/WorkspacesPage/filter/menus.ts +++ b/site/src/pages/WorkspacesPage/filter/menus.ts @@ -17,7 +17,8 @@ export const useTemplateFilterMenu = ({ value, id: "template", getSelectedOption: async () => { - const templates = await getTemplates(orgId); + // Show all templates including deprecated + const templates = await getTemplates(orgId, undefined); const template = templates.find((template) => template.name === value); if (template) { return { @@ -32,7 +33,8 @@ export const useTemplateFilterMenu = ({ return null; }, getOptions: async (query) => { - const templates = await getTemplates(orgId); + // Show all templates including deprecated + const templates = await getTemplates(orgId, undefined); const filteredTemplates = templates.filter( (template) => template.name.toLowerCase().includes(query.toLowerCase()) || From f5dc43f3768a81b47f6e1df57ab0d1562ddd0b7a Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 17 Nov 2023 14:30:58 -0600 Subject: [PATCH 05/15] Add deprecated to template table --- cli/templateedit.go | 8 ++--- coderd/apidoc/docs.go | 6 ++++ coderd/apidoc/swagger.json | 6 ++++ coderd/templates.go | 14 ++++----- coderd/templates_test.go | 4 +-- codersdk/templates.go | 18 +++++------ docs/admin/audit-logs.md | 26 ++++++++-------- docs/api/schemas.md | 4 +++ docs/api/templates.md | 12 +++++++ docs/cli/templates_edit.md | 8 +++++ enterprise/coderd/templates_test.go | 4 +-- site/src/api/typesGenerated.ts | 3 ++ .../TemplateSettingsPage.test.tsx | 5 ++- .../TemplatesPageView.stories.tsx | 20 ++++++++++++ .../pages/TemplatesPage/TemplatesPageView.tsx | 31 +++++++++++-------- site/src/testHelpers/entities.ts | 2 ++ 16 files changed, 120 insertions(+), 51 deletions(-) diff --git a/cli/templateedit.go b/cli/templateedit.go index 126f87a0c3b79..9c46e98427f8d 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -33,7 +33,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { allowUserAutostart bool allowUserAutostop bool requireActiveVersion bool - deprecatedMessage string + deprecationMessage string ) client := new(codersdk.Client) @@ -126,7 +126,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { var deprecated *string opt := inv.Command.Options.ByName(deprecatedFlagName) if !(opt.ValueSource == "" || opt.ValueSource == clibase.ValueSourceDefault) { - deprecated = &deprecatedMessage + deprecated = &deprecationMessage } // NOTE: coderd will ignore empty fields. @@ -150,7 +150,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { AllowUserAutostart: allowUserAutostart, AllowUserAutostop: allowUserAutostop, RequireActiveVersion: requireActiveVersion, - DeprecatedMessage: deprecated, + DeprecationMessage: deprecated, } _, err = client.UpdateTemplateMeta(inv.Context(), template.ID, req) @@ -182,7 +182,7 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { Name: deprecatedFlagName, Flag: "deprecated", Description: "Sets the template as deprecated. Must be a message explaining why the template is deprecated.", - Value: clibase.StringOf(&deprecatedMessage), + Value: clibase.StringOf(&deprecationMessage), }, { Flag: "icon", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 7d583695b66c4..ef50a4aae6bc7 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10059,6 +10059,12 @@ const docTemplate = `{ "default_ttl_ms": { "type": "integer" }, + "deprecated": { + "type": "boolean" + }, + "deprecation_message": { + "type": "string" + }, "description": { "type": "string" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index 550a90c6805d7..91661d1c2c7c9 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9087,6 +9087,12 @@ "default_ttl_ms": { "type": "integer" }, + "deprecated": { + "type": "boolean" + }, + "deprecation_message": { + "type": "string" + }, "description": { "type": "string" }, diff --git a/coderd/templates.go b/coderd/templates.go index 91a528809f34f..227e5934af257 100644 --- a/coderd/templates.go +++ b/coderd/templates.go @@ -604,9 +604,9 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { validErrs = append(validErrs, codersdk.ValidationError{Field: "autostop_requirement.weeks", Detail: fmt.Sprintf("Must be less than %d.", schedule.MaxTemplateAutostopRequirementWeeks)}) } // Defaults to the existing. - deprecatedMessage := template.Deprecated - if req.DeprecatedMessage != nil { - deprecatedMessage = *req.DeprecatedMessage + deprecationMessage := template.Deprecated + if req.DeprecationMessage != nil { + deprecationMessage = *req.DeprecationMessage } // The minimum valid value for a dormant TTL is 1 minute. This is @@ -649,7 +649,7 @@ func (api *API) patchTemplateMeta(rw http.ResponseWriter, r *http.Request) { req.TimeTilDormantMillis == time.Duration(template.TimeTilDormant).Milliseconds() && req.TimeTilDormantAutoDeleteMillis == time.Duration(template.TimeTilDormantAutoDelete).Milliseconds() && req.RequireActiveVersion == template.RequireActiveVersion && - (deprecatedMessage == template.Deprecated) { + (deprecationMessage == template.Deprecated) { return nil } @@ -673,10 +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 || deprecatedMessage != template.Deprecated { + if template.RequireActiveVersion != req.RequireActiveVersion || deprecationMessage != template.Deprecated { err = (*api.AccessControlStore.Load()).SetTemplateAccessControl(ctx, tx, template.ID, dbauthz.TemplateAccessControl{ RequireActiveVersion: req.RequireActiveVersion, - Deprecated: deprecatedMessage, + Deprecated: deprecationMessage, }) if err != nil { return xerrors.Errorf("set template access control: %w", err) @@ -873,6 +873,6 @@ func (api *API) convertTemplate( // These values depend on entitlements and come from the templateAccessControl RequireActiveVersion: templateAccessControl.RequireActiveVersion, Deprecated: templateAccessControl.IsDeprecated(), - DeprecatedMessage: templateAccessControl.Deprecated, + DeprecationMessage: templateAccessControl.Deprecated, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index 447d6a0ae78a7..23c144278495e 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -525,7 +525,7 @@ func TestPatchTemplateMeta(t *testing.T) { template := coderdtest.CreateTemplate(t, client, user.OrganizationID, version.ID) req := codersdk.UpdateTemplateMeta{ - DeprecatedMessage: ptr.Ref("APGL cannot deprecate"), + DeprecationMessage: ptr.Ref("APGL cannot deprecate"), } ctx, cancel := context.WithTimeout(context.Background(), testutil.WaitLong) @@ -536,7 +536,7 @@ func TestPatchTemplateMeta(t *testing.T) { assert.Greater(t, updated.UpdatedAt, template.UpdatedAt) // AGPL cannot deprecate, expect no change assert.False(t, updated.Deprecated) - assert.Empty(t, updated.DeprecatedMessage) + assert.Empty(t, updated.DeprecationMessage) }) t.Run("NoDefaultTTL", func(t *testing.T) { diff --git a/codersdk/templates.go b/codersdk/templates.go index f59175ac3fdda..3ebe2359c8600 100644 --- a/codersdk/templates.go +++ b/codersdk/templates.go @@ -24,13 +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"` - Deprecated bool `json:"deprecated"` - DeprecatedMessage string `json:"deprecated_message"` - 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 @@ -231,11 +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"` - // DeprecatedMessage if set, will mark the template as deprecated and block + // 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. - DeprecatedMessage *string `json:"deprecated_message"` + DeprecationMessage *string `json:"deprecation_message"` } type TemplateExample struct { diff --git a/docs/admin/audit-logs.md b/docs/admin/audit-logs.md index af7a5724458d7..69c61ad69deed 100644 --- a/docs/admin/audit-logs.md +++ b/docs/admin/audit-logs.md @@ -8,19 +8,19 @@ We track the following resources: -| Resource | | -| -------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| -| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| -| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| -| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| -| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| -| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| -| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| -| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| -| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| -| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| -| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| +| Resource | | +| -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| APIKey
login, logout, register, create, delete |
FieldTracked
created_attrue
expires_attrue
hashed_secretfalse
idfalse
ip_addressfalse
last_usedtrue
lifetime_secondsfalse
login_typefalse
scopefalse
token_namefalse
updated_atfalse
user_idtrue
| +| AuditOAuthConvertState
|
FieldTracked
created_attrue
expires_attrue
from_login_typetrue
to_login_typetrue
user_idtrue
| +| Group
create, write, delete |
FieldTracked
avatar_urltrue
display_nametrue
idtrue
memberstrue
nametrue
organization_idfalse
quota_allowancetrue
sourcefalse
| +| GitSSHKey
create |
FieldTracked
created_atfalse
private_keytrue
public_keytrue
updated_atfalse
user_idtrue
| +| License
create, delete |
FieldTracked
exptrue
idfalse
jwtfalse
uploaded_attrue
uuidtrue
| +| Template
write, delete |
FieldTracked
active_version_idtrue
allow_user_autostarttrue
allow_user_autostoptrue
allow_user_cancel_workspace_jobstrue
autostart_block_days_of_weektrue
autostop_requirement_days_of_weektrue
autostop_requirement_weekstrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
default_ttltrue
deletedfalse
deprecatedtrue
descriptiontrue
display_nametrue
failure_ttltrue
group_acltrue
icontrue
idtrue
max_ttltrue
nametrue
organization_idfalse
provisionertrue
require_active_versiontrue
time_til_dormanttrue
time_til_dormant_autodeletetrue
updated_atfalse
user_acltrue
| +| TemplateVersion
create, write |
FieldTracked
archivedtrue
created_atfalse
created_bytrue
created_by_avatar_urlfalse
created_by_usernamefalse
external_auth_providersfalse
idtrue
job_idfalse
messagefalse
nametrue
organization_idfalse
readmetrue
template_idtrue
updated_atfalse
| +| User
create, write, delete |
FieldTracked
avatar_urlfalse
created_atfalse
deletedtrue
emailtrue
hashed_passwordtrue
idtrue
last_seen_atfalse
login_typetrue
quiet_hours_scheduletrue
rbac_rolestrue
statustrue
updated_atfalse
usernametrue
| +| Workspace
create, write, delete |
FieldTracked
automatic_updatestrue
autostart_scheduletrue
created_atfalse
deletedfalse
deleting_attrue
dormant_attrue
idtrue
last_used_atfalse
nametrue
organization_idfalse
owner_idtrue
template_idtrue
ttltrue
updated_atfalse
| +| WorkspaceBuild
start, stop |
FieldTracked
build_numberfalse
created_atfalse
daily_costfalse
deadlinefalse
idfalse
initiator_by_avatar_urlfalse
initiator_by_usernamefalse
initiator_idfalse
job_idfalse
max_deadlinefalse
provisioner_statefalse
reasonfalse
template_version_idtrue
transitionfalse
updated_atfalse
workspace_idfalse
| +| WorkspaceProxy
|
FieldTracked
created_attrue
deletedfalse
derp_enabledtrue
derp_onlytrue
display_nametrue
icontrue
idtrue
nametrue
region_idtrue
token_hashed_secrettrue
updated_atfalse
urltrue
wildcard_hostnametrue
| diff --git a/docs/api/schemas.md b/docs/api/schemas.md index d3a61585d096c..e047926f31dd6 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4412,6 +4412,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, @@ -4444,6 +4446,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. | diff --git a/docs/api/templates.md b/docs/api/templates.md index 279ab1ff5cfb7..0fcfd2a8b3cc5 100644 --- a/docs/api/templates.md +++ b/docs/api/templates.md @@ -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, diff --git a/docs/cli/templates_edit.md b/docs/cli/templates_edit.md index cd65ac99ef9d0..0157b06404720 100644 --- a/docs/cli/templates_edit.md +++ b/docs/cli/templates_edit.md @@ -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 | string | + +Sets the template as deprecated. Must be a message explaining why the template is deprecated. + ### --description | | | diff --git a/enterprise/coderd/templates_test.go b/enterprise/coderd/templates_test.go index f76a9a4640455..3268739f2560f 100644 --- a/enterprise/coderd/templates_test.go +++ b/enterprise/coderd/templates_test.go @@ -113,13 +113,13 @@ func TestTemplates(t *testing.T) { defer cancel() updated, err := client.UpdateTemplateMeta(ctx, template.ID, codersdk.UpdateTemplateMeta{ - DeprecatedMessage: ptr.Ref("Stop using this template"), + 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.DeprecatedMessage) + assert.NotEmpty(t, updated.DeprecationMessage) _, err = client.CreateWorkspace(ctx, user.OrganizationID, codersdk.Me, codersdk.CreateWorkspaceRequest{ TemplateID: template.ID, diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 032c3854138dd..524c9f7cacf51 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -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 diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx index c9f2059672fce..7449457aa998f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.test.tsx @@ -12,7 +12,10 @@ import { getValidationSchema } from "./TemplateSettingsForm"; import { TemplateSettingsPage } from "./TemplateSettingsPage"; type FormValues = Required< - Omit + Omit< + UpdateTemplateMeta, + "default_ttl_ms" | "max_ttl_ms" | "deprecation_message" + > >; const validFormValues: FormValues = { diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx index 5bbbb55fdce56..51954c75d77c7 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.stories.tsx @@ -38,6 +38,26 @@ 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", + }, + { + ...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", + }, + { + ...MockTemplate, + name: "deprecated-template", + display_name: "Deprecated", + description: "Template is incompatible", + }, ], examples: [], }, diff --git a/site/src/pages/TemplatesPage/TemplatesPageView.tsx b/site/src/pages/TemplatesPage/TemplatesPageView.tsx index e06914f470ad7..085def04a896c 100644 --- a/site/src/pages/TemplatesPage/TemplatesPageView.tsx +++ b/site/src/pages/TemplatesPage/TemplatesPageView.tsx @@ -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 }) => { - + {template.deprecated ? ( + + ) : ( + + )} ); diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index ad822e76e273d..d964d330e3f22 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -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 = { From 4cfa94022011b383463dfe0ccecb5189657693ea Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Fri, 17 Nov 2023 14:51:38 -0600 Subject: [PATCH 06/15] Add deprecated notice to template page --- site/src/api/api.ts | 8 +++---- site/src/api/queries/templates.ts | 6 +++++- .../pages/TemplatePage/TemplatePageHeader.tsx | 21 ++++++++++++------- .../pages/WorkspacePage/Workspace.stories.tsx | 11 ++++++++++ site/src/pages/WorkspacePage/Workspace.tsx | 7 +++++++ 5 files changed, 40 insertions(+), 13 deletions(-) diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 13994306e7842..48d2d5c34ae50 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -224,19 +224,19 @@ export const getTemplates = async ( organizationId: string, deprecated?: boolean, ): Promise => { - const params = {} as Record - if(deprecated !== undefined) { + const params = {} as Record; + if (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(deprecated) + params["deprecated"] = String(deprecated); } const response = await axios.get( `/api/v2/organizations/${organizationId}/templates`, { params, - } + }, ); return response.data; }; diff --git a/site/src/api/queries/templates.ts b/site/src/api/queries/templates.ts index 9a18ec5e17a30..092c0d0fed881 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -33,7 +33,11 @@ export const templateByName = ( }; }; -const getTemplatesQueryKey = (orgId: string, deprecated?: boolean) => [orgId, "templates", deprecated]; +const getTemplatesQueryKey = (orgId: string, deprecated?: boolean) => [ + orgId, + "templates", + deprecated, +]; export const templates = (orgId: string, deprecated?: boolean) => { return { diff --git a/site/src/pages/TemplatePage/TemplatePageHeader.tsx b/site/src/pages/TemplatePage/TemplatePageHeader.tsx index 711b251f2be9e..dab4423f074b0 100644 --- a/site/src/pages/TemplatePage/TemplatePageHeader.tsx +++ b/site/src/pages/TemplatePage/TemplatePageHeader.tsx @@ -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 = ({ - + {!template.deprecated && ( + + )} {permissions.canUpdateTemplate && ( = ({ )} + + {template.deprecated && } diff --git a/site/src/pages/WorkspacePage/Workspace.stories.tsx b/site/src/pages/WorkspacePage/Workspace.stories.tsx index 10887f5b5ebdc..bd7fc91db52e5 100644 --- a/site/src/pages/WorkspacePage/Workspace.stories.tsx +++ b/site/src/pages/WorkspacePage/Workspace.stories.tsx @@ -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, diff --git a/site/src/pages/WorkspacePage/Workspace.tsx b/site/src/pages/WorkspacePage/Workspace.tsx index 060ee10c5b8a2..14f18642eaef5 100644 --- a/site/src/pages/WorkspacePage/Workspace.tsx +++ b/site/src/pages/WorkspacePage/Workspace.tsx @@ -328,6 +328,13 @@ export const Workspace: FC> = ({ )} + {template?.deprecated && ( + + Workspace using deprecated template + {template?.deprecation_message} + + )} + {transitionStats !== undefined && ( Date: Fri, 17 Nov 2023 15:15:26 -0600 Subject: [PATCH 07/15] Add ui to deprecate a template --- .../Dashboard/DashboardProvider.tsx | 5 +++ .../TemplateSettingsForm.tsx | 45 ++++++++++++++++++- .../TemplateSettingsPage.tsx | 9 +++- .../TemplateSettingsPageView.tsx | 3 ++ 4 files changed, 59 insertions(+), 3 deletions(-) diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index 4243c8b01effe..4df5be5946c88 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -129,3 +129,8 @@ export const useTemplatePoliciesEnabled = (): boolean => { experiments.includes("template_update_policies") ); }; + +export const useAccessControlEntitled = (): boolean => { + const { entitlements } = useDashboard(); + return entitlements.features.access_control.enabled; +}; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index 80a1b82355985..2a6dd37a6661c 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -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; accessControlEnabled: boolean; + templatePoliciesEnabled: boolean; } export const TemplateSettingsForm: FC = ({ @@ -59,6 +61,7 @@ export const TemplateSettingsForm: FC = ({ isSubmitting, initialTouched, accessControlEnabled, + templatePoliciesEnabled, }) => { const validationSchema = getValidationSchema(); const form: FormikContextType = @@ -73,6 +76,7 @@ export const TemplateSettingsForm: FC = ({ 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 = ({ - {accessControlEnabled && ( + {templatePoliciesEnabled && (