diff --git a/cli/templatecreate.go b/cli/templatecreate.go index 13dd2de64e7f1..da0793121d949 100644 --- a/cli/templatecreate.go +++ b/cli/templatecreate.go @@ -50,6 +50,18 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { isTemplateSchedulingOptionsSet := failureTTL != 0 || inactivityTTL != 0 || maxTTL != 0 if isTemplateSchedulingOptionsSet || requireActiveVersion { + if failureTTL != 0 || inactivityTTL != 0 { + // This call can be removed when workspace_actions is no longer experimental + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + } + } + entitlements, err := client.Entitlements(inv.Context()) if cerr, ok := codersdk.AsError(err); ok && cerr.StatusCode() == http.StatusNotFound { return xerrors.Errorf("your deployment appears to be an AGPL deployment, so you cannot set enterprise-only flags") @@ -59,7 +71,7 @@ func (r *RootCmd) templateCreate() *clibase.Cmd { if isTemplateSchedulingOptionsSet { if !entitlements.Features[codersdk.FeatureAdvancedTemplateScheduling].Enabled { - return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl or --inactivityTTL") + return xerrors.Errorf("your license is not entitled to use advanced template scheduling, so you cannot set --failure-ttl, --inactivity-ttl, or --max-ttl") } } diff --git a/cli/templateedit.go b/cli/templateedit.go index 5bd1cd236f7df..1c17ec52bcab3 100644 --- a/cli/templateedit.go +++ b/cli/templateedit.go @@ -43,6 +43,18 @@ func (r *RootCmd) templateEdit() *clibase.Cmd { ), Short: "Edit the metadata of a template by name.", Handler: func(inv *clibase.Invocation) error { + // This clause can be removed when workspace_actions is no longer experimental + if failureTTL != 0 || inactivityTTL != 0 { + experiments, exErr := client.Experiments(inv.Context()) + if exErr != nil { + return xerrors.Errorf("get experiments: %w", exErr) + } + + if !experiments.Enabled(codersdk.ExperimentWorkspaceActions) { + return xerrors.Errorf("--failure-ttl and --inactivity-ttl are experimental features. Use the workspace_actions CODER_EXPERIMENTS flag to set these configuration values.") + } + } + unsetAutostopRequirementDaysOfWeek := len(autostopRequirementDaysOfWeek) == 1 && autostopRequirementDaysOfWeek[0] == "none" requiresScheduling := (len(autostopRequirementDaysOfWeek) > 0 && !unsetAutostopRequirementDaysOfWeek) || autostopRequirementWeeks > 0 || diff --git a/coderd/apidoc/docs.go b/coderd/apidoc/docs.go index 9764b1b658d73..76daf4c798e2f 100644 --- a/coderd/apidoc/docs.go +++ b/coderd/apidoc/docs.go @@ -8537,6 +8537,7 @@ const docTemplate = `{ "type": "string", "enum": [ "moons", + "workspace_actions", "tailnet_pg_coordinator", "single_tailnet", "template_autostop_requirement", @@ -8546,6 +8547,7 @@ const docTemplate = `{ ], "x-enum-varnames": [ "ExperimentMoons", + "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", diff --git a/coderd/apidoc/swagger.json b/coderd/apidoc/swagger.json index b943027ed40d4..a6908efc1e61e 100644 --- a/coderd/apidoc/swagger.json +++ b/coderd/apidoc/swagger.json @@ -7649,6 +7649,7 @@ "type": "string", "enum": [ "moons", + "workspace_actions", "tailnet_pg_coordinator", "single_tailnet", "template_autostop_requirement", @@ -7658,6 +7659,7 @@ ], "x-enum-varnames": [ "ExperimentMoons", + "ExperimentWorkspaceActions", "ExperimentTailnetPGCoordinator", "ExperimentSingleTailnet", "ExperimentTemplateAutostopRequirement", diff --git a/codersdk/deployment.go b/codersdk/deployment.go index 7ad2cf527ca48..c53ba8d055194 100644 --- a/codersdk/deployment.go +++ b/codersdk/deployment.go @@ -1973,6 +1973,9 @@ const ( // feature is not yet complete in functionality. ExperimentMoons Experiment = "moons" + // https://github.com/coder/coder/milestone/19 + ExperimentWorkspaceActions Experiment = "workspace_actions" + // ExperimentTailnetPGCoordinator enables the PGCoord in favor of the pubsub- // only Coordinator ExperimentTailnetPGCoordinator Experiment = "tailnet_pg_coordinator" diff --git a/docs/api/schemas.md b/docs/api/schemas.md index c30c1081f07b8..61e227b2b3f4d 100644 --- a/docs/api/schemas.md +++ b/docs/api/schemas.md @@ -2845,6 +2845,7 @@ AuthorizationObject can represent a "set" of objects, such as: all workspaces in | Value | | ------------------------------- | | `moons` | +| `workspace_actions` | | `tailnet_pg_coordinator` | | `single_tailnet` | | `template_autostop_requirement` | diff --git a/site/src/api/typesGenerated.ts b/site/src/api/typesGenerated.ts index 2d73f29700787..975e2b8897197 100644 --- a/site/src/api/typesGenerated.ts +++ b/site/src/api/typesGenerated.ts @@ -1708,7 +1708,8 @@ export type Experiment = | "single_tailnet" | "tailnet_pg_coordinator" | "template_autostop_requirement" - | "template_update_policies"; + | "template_update_policies" + | "workspace_actions"; export const Experiments: Experiment[] = [ "dashboard_theme", "deployment_health_page", @@ -1717,6 +1718,7 @@ export const Experiments: Experiment[] = [ "tailnet_pg_coordinator", "template_autostop_requirement", "template_update_policies", + "workspace_actions", ]; // From codersdk/deployment.go diff --git a/site/src/components/Dashboard/DashboardProvider.tsx b/site/src/components/Dashboard/DashboardProvider.tsx index a0bd176178a1a..7e06b4a656620 100644 --- a/site/src/components/Dashboard/DashboardProvider.tsx +++ b/site/src/components/Dashboard/DashboardProvider.tsx @@ -112,3 +112,13 @@ export const useDashboard = (): DashboardProviderValue => { return context; }; + +export const useIsWorkspaceActionsEnabled = (): boolean => { + const { entitlements, experiments } = useDashboard(); + const allowAdvancedScheduling = + entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); + return allowWorkspaceActions && allowAdvancedScheduling; +}; diff --git a/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx b/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx index b8749cdde9cad..8dc7a289d21f9 100644 --- a/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx +++ b/site/src/components/WorkspaceDeletion/DormantDeletionStat.tsx @@ -14,11 +14,20 @@ interface DormantDeletionStatProps { export const DormantDeletionStat: FC = ({ workspace, }) => { - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); - if (!displayDormantDeletion(workspace, allowAdvancedScheduling)) { + if ( + !displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ) + ) { return null; } diff --git a/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx b/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx index 5e7ed87962954..0d1b8692674fa 100644 --- a/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx +++ b/site/src/components/WorkspaceDeletion/DormantDeletionText.tsx @@ -9,11 +9,20 @@ export const DormantDeletionText = ({ }: { workspace: Workspace; }): JSX.Element | null => { - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; + // This check can be removed when https://github.com/coder/coder/milestone/19 + // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); - if (!displayDormantDeletion(workspace, allowAdvancedScheduling)) { + if ( + !displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ) + ) { return null; } return Impending deletion; diff --git a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx index 9315ecc9638b6..66bfc989ae89a 100644 --- a/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx +++ b/site/src/components/WorkspaceDeletion/DormantWorkspaceBanner.tsx @@ -1,5 +1,5 @@ import { Workspace } from "api/typesGenerated"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Alert } from "components/Alert/Alert"; import { formatDistanceToNow } from "date-fns"; import Link from "@mui/material/Link"; @@ -21,9 +21,7 @@ export const DormantWorkspaceBanner = ({ shouldRedisplayBanner: boolean; count?: Count; }): JSX.Element | null => { - const { entitlements } = useDashboard(); - const schedulingEnabled = - entitlements.features["advanced_template_scheduling"].enabled; + const experimentEnabled = useIsWorkspaceActionsEnabled(); if (!workspaces) { return null; @@ -39,7 +37,7 @@ export const DormantWorkspaceBanner = ({ if ( // Only show this if the experiment is included. - !schedulingEnabled || + !experimentEnabled || !hasDormantWorkspaces || // Banners should be redisplayed after dismissal when additional workspaces are newly scheduled for deletion !shouldRedisplayBanner diff --git a/site/src/components/WorkspaceDeletion/utils.test.ts b/site/src/components/WorkspaceDeletion/utils.test.ts index 87d32de1f832e..caca6c5661993 100644 --- a/site/src/components/WorkspaceDeletion/utils.test.ts +++ b/site/src/components/WorkspaceDeletion/utils.test.ts @@ -4,39 +4,53 @@ import { displayDormantDeletion } from "./utils"; describe("displayDormantDeletion", () => { const today = new Date(); - it.each<[string, boolean, boolean]>([ + it.each<[string, boolean, boolean, boolean]>([ [ new Date(new Date().setDate(today.getDate() + 15)).toISOString(), true, + true, false, ], // today + 15 days out [ new Date(new Date().setDate(today.getDate() + 14)).toISOString(), true, true, + true, ], // today + 14 [ new Date(new Date().setDate(today.getDate() + 13)).toISOString(), true, true, + true, ], // today + 13 [ new Date(new Date().setDate(today.getDate() + 1)).toISOString(), true, true, + true, ], // today + 1 - [new Date().toISOString(), true, true], // today + 0 - [new Date().toISOString(), false, false], // Advanced Scheduling off + [new Date().toISOString(), true, true, true], // today + 0 + [new Date().toISOString(), false, true, false], // Advanced Scheduling off + [new Date().toISOString(), true, false, false], // Workspace Actions off ])( - `deleting_at=%p, allowAdvancedScheduling=%p, shouldDisplay=%p`, - (deleting_at, allowAdvancedScheduling, shouldDisplay) => { + `deleting_at=%p, allowAdvancedScheduling=%p, AllowWorkspaceActions=%p, shouldDisplay=%p`, + ( + deleting_at, + allowAdvancedScheduling, + allowWorkspaceActions, + shouldDisplay, + ) => { const workspace: TypesGen.Workspace = { ...Mocks.MockWorkspace, deleting_at, }; - expect(displayDormantDeletion(workspace, allowAdvancedScheduling)).toBe( - shouldDisplay, - ); + expect( + displayDormantDeletion( + workspace, + allowAdvancedScheduling, + allowWorkspaceActions, + ), + ).toBe(shouldDisplay); }, ); }); diff --git a/site/src/components/WorkspaceDeletion/utils.ts b/site/src/components/WorkspaceDeletion/utils.ts index 1265647878a82..14ac74f4a00bd 100644 --- a/site/src/components/WorkspaceDeletion/utils.ts +++ b/site/src/components/WorkspaceDeletion/utils.ts @@ -14,9 +14,14 @@ const IMPENDING_DELETION_DISPLAY_THRESHOLD = 14; // 14 days export const displayDormantDeletion = ( workspace: Workspace, allowAdvancedScheduling: boolean, + allowWorkspaceActions: boolean, ) => { const today = new Date(); - if (!workspace.deleting_at || !allowAdvancedScheduling) { + if ( + !workspace.deleting_at || + !allowAdvancedScheduling || + !allowWorkspaceActions + ) { return false; } return ( diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx index 88cfcc394bdf7..912cc070ccb79 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateScheduleForm.tsx @@ -54,6 +54,7 @@ export interface TemplateScheduleForm { isSubmitting: boolean; error?: unknown; allowAdvancedScheduling: boolean; + allowWorkspaceActions: boolean; allowAutostopRequirement: boolean; // Helpful to show field errors on Storybook initialTouched?: FormikTouched; @@ -65,6 +66,7 @@ export const TemplateScheduleForm: FC = ({ onCancel, error, allowAdvancedScheduling, + allowWorkspaceActions, allowAutostopRequirement, isSubmitting, initialTouched, @@ -491,7 +493,7 @@ export const TemplateScheduleForm: FC = ({ - {allowAdvancedScheduling && ( + {allowAdvancedScheduling && allowWorkspaceActions && ( <> { jest .spyOn(API, "getEntitlements") .mockResolvedValue(MockEntitlementsWithScheduling); + + // remove when https://github.com/coder/coder/milestone/19 is completed. + jest.spyOn(API, "getExperiments").mockResolvedValue(["workspace_actions"]); }); it("Calls the API when user fills in and submits a form", async () => { diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx index 4792e8a86a1b1..ce8db54909ed9 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePage.tsx @@ -18,11 +18,12 @@ const TemplateSchedulePage: FC = () => { const queryClient = useQueryClient(); const orgId = useOrganizationId(); const { template } = useTemplateSettings(); - const { entitlements } = useDashboard(); + const { entitlements, experiments } = useDashboard(); const allowAdvancedScheduling = entitlements.features["advanced_template_scheduling"].enabled; // This check can be removed when https://github.com/coder/coder/milestone/19 // is merged up + const allowWorkspaceActions = experiments.includes("workspace_actions"); const allowAutostopRequirement = entitlements.features["template_autostop_requirement"].enabled; const { clearLocal } = useLocalStorage(); @@ -53,6 +54,7 @@ const TemplateSchedulePage: FC = () => { ; const defaultArgs = { allowAdvancedScheduling: true, + allowWorkspaceActions: true, template: MockTemplate, onSubmit: action("onSubmit"), onCancel: action("cancel"), diff --git a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx index 3e32ae0c20bf3..c75dab222ad78 100644 --- a/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx +++ b/site/src/pages/TemplateSettingsPage/TemplateSchedulePage/TemplateSchedulePageView.tsx @@ -13,6 +13,7 @@ export interface TemplateSchedulePageViewProps { typeof TemplateScheduleForm >["initialTouched"]; allowAdvancedScheduling: boolean; + allowWorkspaceActions: boolean; allowAutostopRequirement: boolean; } @@ -22,6 +23,7 @@ export const TemplateSchedulePageView: FC = ({ onSubmit, isSubmitting, allowAdvancedScheduling, + allowWorkspaceActions, allowAutostopRequirement, submitError, initialTouched, @@ -34,6 +36,7 @@ export const TemplateSchedulePageView: FC = ({ { query: filterProps.filter.query, }); - const { entitlements } = useDashboard(); - const schedulingEnabled = - entitlements.features["advanced_template_scheduling"].enabled; - + const experimentEnabled = useIsWorkspaceActionsEnabled(); // If workspace actions are enabled we need to fetch the dormant // workspaces as well. This lets us determine whether we should // show a banner to the user indicating that some of their workspaces // are at risk of being deleted. useEffect(() => { - if (schedulingEnabled) { - const includesDormant = filterProps.filter.query.includes("is-dormant"); + if (experimentEnabled) { + const includesDormant = filterProps.filter.query.includes("dormant_at"); const dormantQuery = includesDormant ? filterProps.filter.query : filterProps.filter.query + " is-dormant:true"; @@ -89,11 +89,12 @@ const WorkspacesPage: FC = () => { // like dormant workspaces don't exist. setDormantWorkspaces([]); } - }, [schedulingEnabled, data, filterProps.filter.query]); + }, [experimentEnabled, data, filterProps.filter.query]); const updateWorkspace = useWorkspaceUpdate(queryKey); const [checkedWorkspaces, setCheckedWorkspaces] = useState([]); const [isDeletingAll, setIsDeletingAll] = useState(false); const [urlSearchParams] = searchParamsResult; + const { entitlements } = useDashboard(); const canCheckWorkspaces = entitlements.features["workspace_batch_actions"].enabled; diff --git a/site/src/pages/WorkspacesPage/filter/filter.tsx b/site/src/pages/WorkspacesPage/filter/filter.tsx index 57d7260fbd4b3..53d83008cc308 100644 --- a/site/src/pages/WorkspacesPage/filter/filter.tsx +++ b/site/src/pages/WorkspacesPage/filter/filter.tsx @@ -1,7 +1,6 @@ import { FC } from "react"; import Box from "@mui/material/Box"; -import { useDashboard } from "components/Dashboard/DashboardProvider"; - +import { useIsWorkspaceActionsEnabled } from "components/Dashboard/DashboardProvider"; import { Avatar, AvatarProps } from "components/Avatar/Avatar"; import { Palette, PaletteColor } from "@mui/material/styles"; import { TemplateFilterMenu, StatusFilterMenu } from "./menus"; @@ -76,10 +75,8 @@ export const WorkspacesFilter = ({ error, menus, }: WorkspaceFilterProps) => { - const { entitlements } = useDashboard(); - const actionsEnabled = - entitlements.features["advanced_template_scheduling"].enabled; - const presets = actionsEnabled ? PRESET_FILTERS : PRESETS_WITH_DORMANT; + const actionsEnabled = useIsWorkspaceActionsEnabled(); + const presets = actionsEnabled ? PRESETS_WITH_DORMANT : PRESET_FILTERS; return (