From 128c27fab827ee94b2456831c7715d96963179e7 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 19 Oct 2023 00:45:03 +0000 Subject: [PATCH 1/3] feat: add frontend support for mandating active template version --- coderd/apidoc/docs.go | 3 + coderd/apidoc/swagger.json | 3 + coderd/workspaces.go | 1 + codersdk/workspaces.go | 1 + docs/api/schemas.md | 3 + docs/api/workspaces.md | 5 + scripts/develop.sh | 2 +- site/src/api/typesGenerated.ts | 1 + .../TemplateSettingsForm.tsx | 97 +++++++++++++------ .../TemplateSettingsPage.tsx | 7 ++ .../TemplateSettingsPageView.tsx | 3 + .../WorkspaceActions.stories.tsx | 14 +++ .../WorkspaceActions/WorkspaceActions.tsx | 6 +- .../WorkspaceActions/constants.ts | 21 ++++ site/src/testHelpers/entities.ts | 22 +++++ 15 files changed, 157 insertions(+), 32 deletions(-) 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/scripts/develop.sh b/scripts/develop.sh index 39f81c2951bc4..d8981fe8de7d2 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -136,7 +136,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --experiments="template_update_policies" --dangerous-allow-cors-requests=true "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script 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", From 7595f2b135032b0d094725c163d165eafce6d1f6 Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 19 Oct 2023 22:29:42 +0000 Subject: [PATCH 2/3] golden --- cli/testdata/coder_list_--output_json.golden | 1 + 1 file changed, 1 insertion(+) 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]", From 833f2d834f6f69b5d6ed8ad1ac5db2668ef9a47f Mon Sep 17 00:00:00 2001 From: Jon Ayers Date: Thu, 19 Oct 2023 22:49:49 +0000 Subject: [PATCH 3/3] remove develop script modification --- scripts/develop.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/develop.sh b/scripts/develop.sh index d8981fe8de7d2..39f81c2951bc4 100755 --- a/scripts/develop.sh +++ b/scripts/develop.sh @@ -136,7 +136,7 @@ fatal() { trap 'fatal "Script encountered an error"' ERR cdroot - start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --experiments="template_update_policies" --dangerous-allow-cors-requests=true "$@" + start_cmd API "" "${CODER_DEV_SHIM}" server --http-address 0.0.0.0:3000 --swagger-enable --access-url "${CODER_DEV_ACCESS_URL}" --dangerous-allow-cors-requests=true "$@" echo '== Waiting for Coder to become ready' # Start the timeout in the background so interrupting this script