diff --git a/cli/testdata/coder_list_--output_json.golden b/cli/testdata/coder_list_--output_json.golden index 4d04910796618..fb6bea96e82ba 100644 --- a/cli/testdata/coder_list_--output_json.golden +++ b/cli/testdata/coder_list_--output_json.golden @@ -12,6 +12,7 @@ "template_icon": "", "template_allow_user_cancel_workspace_jobs": false, "template_active_version_id": "[version ID]", + "template_require_active_version": false, "latest_build": { "id": "[workspace build ID]", "created_at": "[timestamp]", diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9d75c5385bb56..9764b1b658d73 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -11125,6 +11125,9 @@ const docTemplate = `{ "template_name": { "type": "string" }, + "template_require_active_version": { + "type": "boolean" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index fc42019342e28..b943027ed40d4 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -10091,6 +10091,9 @@ "template_name": { "type": "string" }, + "template_require_active_version": { + "type": "boolean" + }, "ttl_ms": { "type": "integer" }, diff --git a/coderd/workspaces.go b/coderd/workspaces.go index c495d250287c0..bea87fb2f427a 100644 --- a/coderd/workspaces.go +++ b/coderd/workspaces.go @@ -1338,6 +1338,7 @@ func convertWorkspace( TemplateDisplayName: template.DisplayName, TemplateAllowUserCancelWorkspaceJobs: template.AllowUserCancelWorkspaceJobs, TemplateActiveVersionID: template.ActiveVersionID, + TemplateRequireActiveVersion: template.RequireActiveVersion, Outdated: workspaceBuild.TemplateVersionID.String() != template.ActiveVersionID.String(), Name: workspace.Name, AutostartSchedule: autostartSchedule, diff --git a/codersdk/workspaces.go b/codersdk/workspaces.go index ef7640417a5ca..54f79aa58725d 100644 --- a/codersdk/workspaces.go +++ b/codersdk/workspaces.go @@ -36,6 +36,7 @@ type Workspace struct { TemplateIcon string `json:"template_icon"` TemplateAllowUserCancelWorkspaceJobs bool `json:"template_allow_user_cancel_workspace_jobs"` TemplateActiveVersionID uuid.UUID `json:"template_active_version_id" format:"uuid"` + TemplateRequireActiveVersion bool `json:"template_require_active_version"` LatestBuild WorkspaceBuild `json:"latest_build"` Outdated bool `json:"outdated"` Name string `json:"name"` diff --git a/docs/api/schemas.md b/docs/api/schemas.md index ff9aac1436700..c30c1081f07b8 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -5766,6 +5766,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -5795,6 +5796,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| | `template_icon` | string | false | | | | `template_id` | string | false | | | | `template_name` | string | false | | | +| `template_require_active_version` | boolean | false | | | | `ttl_ms` | integer | false | | | | `updated_at` | string | false | | | @@ -7014,6 +7016,7 @@ If the schedule is empty, the user will be updated to use the default schedule.| "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } diff --git a/docs/api/workspaces.md b/docs/api/workspaces.md index a7dafb266043b..3ade42b54e9c9 100644 --- a/docs/api/workspaces.md +++ b/docs/api/workspaces.md @@ -215,6 +215,7 @@ curl -X POST http://coder-server:8080/api/v2/organizations/{organization}/member "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -423,6 +424,7 @@ curl -X GET http://coder-server:8080/api/v2/users/{user}/workspace/{workspacenam "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -630,6 +632,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -839,6 +842,7 @@ curl -X GET http://coder-server:8080/api/v2/workspaces/{workspace} \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } @@ -1163,6 +1167,7 @@ curl -X PUT http://coder-server:8080/api/v2/workspaces/{workspace}/dormant \ "template_icon": "string", "template_id": "c6d67e98-83ea-49f0-8812-e4abae2b68bc", "template_name": "string", + "template_require_active_version": true, "ttl_ms": 0, "updated_at": "2019-08-24T14:15:22Z" } diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 9209feea44176..2d73f29700787 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1351,6 +1351,7 @@ export interface Workspace { readonly template_icon: string; readonly template_allow_user_cancel_workspace_jobs: boolean; readonly template_active_version_id: string; + readonly template_require_active_version: boolean; readonly latest_build: WorkspaceBuild; readonly outdated: boolean; readonly name: string; diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx index d6afac0fbd504..ea94ef0982ff6 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsForm.tsx @@ -37,6 +37,7 @@ export const getValidationSchema = (): Yup.AnyObjectSchema => ), allow_user_cancel_workspace_jobs: Yup.boolean(), icon: iconValidator, + require_active_version: Yup.boolean(), }); export interface TemplateSettingsForm { @@ -47,6 +48,7 @@ export interface TemplateSettingsForm { error?: unknown; // Helpful to show field errors on Storybook initialTouched?: FormikTouched; + accessControlEnabled: boolean; } export const TemplateSettingsForm: FC = ({ @@ -56,6 +58,7 @@ export const TemplateSettingsForm: FC = ({ error, isSubmitting, initialTouched, + accessControlEnabled, }) => { const validationSchema = getValidationSchema(); const form: FormikContextType = @@ -69,7 +72,7 @@ export const TemplateSettingsForm: FC = ({ template.allow_user_cancel_workspace_jobs, update_workspace_last_used_at: false, update_workspace_dormant_at: false, - require_active_version: false, + require_active_version: template.require_active_version, }, validationSchema, onSubmit, @@ -135,38 +138,72 @@ export const TemplateSettingsForm: FC = ({ title="Operations" description="Regulate actions allowed on workspaces created from this template." > - + {accessControlEnabled && ( + + )} + diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx index aaef6bddaf659..d483fb7ad053f 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPage.tsx @@ -10,6 +10,7 @@ import { useTemplateSettings } from "../TemplateSettingsLayout"; import { TemplateSettingsPageView } from "./TemplateSettingsPageView"; import { templateByNameKey } from "api/queries/templates"; import { useOrganizationId } from "hooks"; +import { useDashboard } from "components/Dashboard/DashboardProvider"; export const TemplateSettingsPage: FC = () => { const { template: templateName } = useParams() as { template: string }; @@ -17,6 +18,11 @@ export const TemplateSettingsPage: FC = () => { const orgId = useOrganizationId(); const { template } = useTemplateSettings(); const queryClient = useQueryClient(); + const { entitlements, experiments } = useDashboard(); + const accessControlEnabled = + entitlements.features["advanced_template_scheduling"].enabled && + experiments.includes("template_update_policies"); + const { mutate: updateTemplate, isLoading: isSubmitting, @@ -51,6 +57,7 @@ export const TemplateSettingsPage: FC = () => { ...templateSettings, }); }} + accessControlEnabled={accessControlEnabled} /> ); diff --git a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx index 2112b25fb979e..5eac1759d5ca2 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateGeneralSettingsPage/TemplateSettingsPageView.tsx @@ -12,6 +12,7 @@ export interface TemplateSettingsPageViewProps { initialTouched?: ComponentProps< typeof TemplateSettingsForm >["initialTouched"]; + accessControlEnabled: boolean; } export const TemplateSettingsPageView: FC = ({ @@ -21,6 +22,7 @@ export const TemplateSettingsPageView: FC = ({ isSubmitting, submitError, initialTouched, + accessControlEnabled, }) => { return ( <> @@ -35,6 +37,7 @@ export const TemplateSettingsPageView: FC = ({ onSubmit={onSubmit} onCancel={onCancel} error={submitError} + accessControlEnabled={accessControlEnabled} /> ); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx index c53cfeab9a957..bbe9afe598fba 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.stories.tsx @@ -79,3 +79,17 @@ export const Updating: Story = { workspace: Mocks.MockOutdatedWorkspace, }, }; + +export const RequireActiveVersionStarted: Story = { + args: { + workspace: Mocks.MockOutdatedRunningWorkspaceRequireActiveVersion, + canChangeVersions: false, + }, +}; + +export const RequireActiveVersionStopped: Story = { + args: { + workspace: Mocks.MockOutdatedStoppedWorkspaceRequireActiveVersion, + canChangeVersions: false, + }, +}; diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx index 04d2744530b90..226396720b593 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx +++ b/site/src/pages/WorkspacePage/WorkspaceActions/WorkspaceActions.tsx @@ -61,7 +61,11 @@ export const WorkspaceActions: FC = ({ canCancel, canAcceptJobs, actions: actionsByStatus, - } = actionsByWorkspaceStatus(workspace, workspace.latest_build.status); + } = actionsByWorkspaceStatus( + workspace, + workspace.latest_build.status, + canChangeVersions, + ); const canBeUpdated = workspace.outdated && canAcceptJobs; const menuTriggerRef = useRef(null); const [isMenuOpen, setIsMenuOpen] = useState(false); diff --git a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts index d6f2704a18f80..dc57d0e4fbd0e 100644 --- a/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts +++ b/site/src/pages/WorkspacePage/WorkspaceActions/constants.ts @@ -33,6 +33,7 @@ interface WorkspaceAbilities { export const actionsByWorkspaceStatus = ( workspace: Workspace, status: WorkspaceStatus, + canChangeVersions: boolean, ): WorkspaceAbilities => { if (workspace.dormant_at) { return { @@ -41,6 +42,26 @@ export const actionsByWorkspaceStatus = ( canAcceptJobs: false, }; } + if ( + workspace.template_require_active_version && + workspace.outdated && + !canChangeVersions + ) { + if (status === "running") { + return { + actions: [ButtonTypesEnum.stop], + canCancel: false, + canAcceptJobs: true, + }; + } + if (status === "stopped") { + return { + actions: [], + canCancel: false, + canAcceptJobs: true, + }; + } + } return statusToActions[status]; }; diff --git a/site/src/testHelpers/entities.ts b/site/src/testHelpers/entities.ts index 7cf0e8c8a6c28..f7ccfb783c68a 100644 --- a/site/src/testHelpers/entities.ts +++ b/site/src/testHelpers/entities.ts @@ -960,6 +960,7 @@ export const MockWorkspace: TypesGen.Workspace = { template_allow_user_cancel_workspace_jobs: MockTemplate.allow_user_cancel_workspace_jobs, template_active_version_id: MockTemplate.active_version_id, + template_require_active_version: MockTemplate.require_active_version, outdated: false, owner_id: MockUser.id, organization_id: MockOrganization.id, @@ -1053,6 +1054,27 @@ export const MockOutdatedWorkspace: TypesGen.Workspace = { outdated: true, }; +export const MockOutdatedRunningWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockWorkspace, + id: "test-outdated-workspace-require-active-version", + outdated: true, + template_require_active_version: true, + latest_build: { + ...MockWorkspaceBuild, + status: "running", + }, + }; + +export const MockOutdatedStoppedWorkspaceRequireActiveVersion: TypesGen.Workspace = + { + ...MockOutdatedRunningWorkspaceRequireActiveVersion, + latest_build: { + ...MockWorkspaceBuild, + status: "stopped", + }, + }; + export const MockPendingWorkspace: TypesGen.Workspace = { ...MockWorkspace, id: "test-pending-workspace",