diff --git a/cli/templateedit.go b/cli/templateedit.go index 1c17ec52bcab3..9c46e98427f8d 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 + 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.", 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. diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 2050d5d86a71e..095f16d223f22 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -10057,6 +10057,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 e7bc9fadace50..927c9b32e3986 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -9085,6 +9085,12 @@ "default_ttl_ms": { "type": "integer" }, + "deprecated": { + "type": "boolean" + }, + "deprecation_message": { + "type": "string" + }, "description": { "type": "string" }, diff --git a/coderd/coderdtest/coderdtest.go b/coderd/coderdtest/coderdtest.go index 0410985492780..9d0f499a70d63 100644 --- a/coderd/coderdtest/coderdtest.go +++ b/coderd/coderdtest/coderdtest.go @@ -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 diff --git a/coderd/database/dbauthz/accesscontrol.go b/coderd/database/dbauthz/accesscontrol.go index 92417ff4114ba..0a9194c09a8a0 100644 --- a/coderd/database/dbauthz/accesscontrol.go +++ b/coderd/database/dbauthz/accesscontrol.go @@ -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 } diff --git a/coderd/database/dbmem/dbmem.go b/coderd/database/dbmem/dbmem.go index cfeebdc928b1c..1837c6f2588b5 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 } @@ -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 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/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/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..842b05380258e 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 @@ -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 } diff --git a/coderd/database/queries/templates.sql b/coderd/database/queries/templates.sql index c5bc72d7911d6..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 @@ -174,7 +185,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..227e5934af257 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 @@ -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, } } diff --git a/coderd/templates_test.go b/coderd/templates_test.go index a218119e266e4..f8bb4e29da5cc 100644 --- a/coderd/templates_test.go +++ b/coderd/templates_test.go @@ -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) }) }) } 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..3ebe2359c8600 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"` + 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 { 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 c340e1ab6dae4..c2d31f83c4297 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -4411,6 +4411,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in "created_by_id": "9377d689-01fb-4abf-8450-3368d2c1924f", "created_by_name": "string", "default_ttl_ms": 0, + "deprecated": true, + "deprecation_message": "string", "description": "string", "display_name": "string", "failure_ttl_ms": 0, @@ -4443,6 +4445,8 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | `created_by_id` | string | false | | | | `created_by_name` | string | false | | | | `default_ttl_ms` | integer | false | | | +| `deprecated` | boolean | false | | | +| `deprecation_message` | string | false | | | | `description` | string | false | | | | `display_name` | string | false | | | | `failure_ttl_ms` | integer | false | | Failure ttl ms TimeTilDormantMillis, and TimeTilDormantAutoDeleteMillis are enterprise-only. Their values are used if your license is entitled to use the advanced template scheduling feature. | 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/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..3c141542fdfc7 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,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) }) } diff --git a/site/src/api/api.ts b/site/src/api/api.ts index cb57a149f6987..42a680290c0b1 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -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 => { + const params = {} as Record; + 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( `/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..9fce3909c229c 100644 --- a/site/src/api/queries/templates.ts +++ b/site/src/api/queries/templates.ts @@ -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 }), }; }; diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 31f97085158fd..27a5687a9db36 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/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/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 && (