feat: add inactivity cleanup and failure cleanup configuration fields to Template Schedule Form (#7402)

* added workspace actions entitlement

* added workspace actions experiment

* added new route for template enterprise meta

* removing new route; repurposing old

* add new fields to get endpoints

* removed workspace actions experiment

* added logic to enterprise template store

* added new form fields

* feature flagged new fields

* fix validation

* fixed submit btn

* fix tests

* changed ttl defaults

* added FE tests

* added BE tests

* fixed lint

* adjusted comment language

* fixing unstaged changes check

* fix test

* Update coderd/database/migrations/000122_add_template_cleanup_ttls.down.sql

Co-authored-by: Dean Sheather <dean@deansheather.com>

* Update coderd/database/migrations/000122_add_template_cleanup_ttls.up.sql

Co-authored-by: Dean Sheather <dean@deansheather.com>

---------

Co-authored-by: Dean Sheather <dean@deansheather.com>
This commit is contained in:
Kira Pilot
2023-05-05 08:19:26 -07:00
committed by GitHub
co-authored by Dean Sheather
parent 3632ac8c01
commit 5ffa6dae50
33 changed files with 578 additions and 59 deletions
+6 -2
View File
@@ -194,6 +194,8 @@ export interface CreateTemplateRequest {
readonly allow_user_cancel_workspace_jobs?: boolean
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
readonly failure_ttl_ms?: number
readonly inactivity_ttl_ms?: number
}
// From codersdk/templateversions.go
@@ -841,6 +843,8 @@ export interface Template {
readonly allow_user_autostart: boolean
readonly allow_user_autostop: boolean
readonly allow_user_cancel_workspace_jobs: boolean
readonly failure_ttl_ms: number
readonly inactivity_ttl_ms: number
}
// From codersdk/templates.go
@@ -1010,6 +1014,8 @@ export interface UpdateTemplateMeta {
readonly allow_user_autostart?: boolean
readonly allow_user_autostop?: boolean
readonly allow_user_cancel_workspace_jobs?: boolean
readonly failure_ttl_ms?: number
readonly inactivity_ttl_ms?: number
}
// From codersdk/users.go
@@ -1376,7 +1382,6 @@ export type FeatureName =
| "scim"
| "template_rbac"
| "user_limit"
| "workspace_actions"
| "workspace_proxy"
export const FeatureNames: FeatureName[] = [
"advanced_template_scheduling",
@@ -1389,7 +1394,6 @@ export const FeatureNames: FeatureName[] = [
"scim",
"template_rbac",
"user_limit",
"workspace_actions",
"workspace_proxy",
]
@@ -19,6 +19,12 @@
"maxTTLHelperText_zero": "Workspaces may run indefinitely.",
"maxTTLHelperText_one": "Workspaces must stop within 1 hour of starting.",
"maxTTLHelperText_other": "Workspaces must stop within {{count}} hours of starting.",
"failureTTLHelperText_zero": "Coder will not automatically stop failed workspaces",
"failureTTLHelperText_one": "Coder will automatically stop failed workspaces after {{count}} day.",
"failureTTLHelperText_other": "Coder will automatically stop failed workspaces after {{count}} days.",
"inactivityTTLHelperText_zero": "Coder will not automatically delete inactive workspaces",
"inactivityTTLHelperText_one": "Coder will automatically delete inactive workspaces after {{count}} day.",
"inactivityTTLHelperText_other": "Coder will automatically delete inactive workspaces after {{count}} days.",
"allowUserCancelWorkspaceJobsLabel": "Allow users to cancel in-progress workspace jobs.",
"allowUserCancelWorkspaceJobsNotice": "Depending on your template, canceling builds may leave workspaces in an unhealthy state. This option isn't recommended for most use cases.",
"allowUsersCancelHelperText": "If checked, users may be able to corrupt their workspace.",
@@ -1,17 +1,24 @@
import TextField from "@material-ui/core/TextField"
import { Template, UpdateTemplateMeta } from "api/typesGenerated"
import { FormikTouched, useFormik } from "formik"
import { FC } from "react"
import { FC, ChangeEvent } from "react"
import { getFormHelpers } from "utils/formUtils"
import * as Yup from "yup"
import i18next from "i18next"
import { useTranslation } from "react-i18next"
import { Maybe } from "components/Conditionals/Maybe"
import { FormSection, HorizontalForm, FormFooter } from "components/Form/Form"
import {
FormSection,
HorizontalForm,
FormFooter,
FormFields,
} from "components/Form/Form"
import { Stack } from "components/Stack/Stack"
import { makeStyles } from "@material-ui/core/styles"
import Link from "@material-ui/core/Link"
import Checkbox from "@material-ui/core/Checkbox"
import FormControlLabel from "@material-ui/core/FormControlLabel"
import Switch from "@material-ui/core/Switch"
const TTLHelperText = ({
ttl,
@@ -32,6 +39,14 @@ const TTLHelperText = ({
const MAX_TTL_DAYS = 7
const MS_HOUR_CONVERSION = 3600000
const MS_DAY_CONVERSION = 86400000
const FAILURE_CLEANUP_DEFAULT = 7
const INACTIVITY_CLEANUP_DEFAULT = 180
export interface TemplateScheduleFormValues extends UpdateTemplateMeta {
failure_cleanup_enabled: boolean
inactivity_cleanup_enabled: boolean
}
export const getValidationSchema = (): Yup.AnyObjectSchema =>
Yup.object({
@@ -49,6 +64,36 @@ export const getValidationSchema = (): Yup.AnyObjectSchema =>
24 * MAX_TTL_DAYS /* 7 days in hours */,
i18next.t("maxTTLMaxError", { ns: "templateSettingsPage" }),
),
failure_ttl_ms: Yup.number()
.integer()
.min(0, "Failure cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Failure cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.failure_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
inactivity_ttl_ms: Yup.number()
.integer()
.min(0, "Inactivity cleanup days must not be less than 0.")
.test(
"positive-if-enabled",
"Inactivity cleanup days must be greater than zero when enabled.",
function (value) {
const parent = this.parent as TemplateScheduleFormValues
if (parent.inactivity_cleanup_enabled) {
return Boolean(value)
} else {
return true
}
},
),
allow_user_autostart: Yup.boolean(),
allow_user_autostop: Yup.boolean(),
})
@@ -60,6 +105,7 @@ export interface TemplateScheduleForm {
isSubmitting: boolean
error?: unknown
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
// Helpful to show field errors on Storybook
initialTouched?: FormikTouched<UpdateTemplateMeta>
}
@@ -70,22 +116,34 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
onCancel,
error,
allowAdvancedScheduling,
allowWorkspaceActions,
isSubmitting,
initialTouched,
}) => {
const { t: commonT } = useTranslation("common")
const validationSchema = getValidationSchema()
const form = useFormik<UpdateTemplateMeta>({
const form = useFormik<TemplateScheduleFormValues>({
initialValues: {
// on display, convert from ms => hours
default_ttl_ms: template.default_ttl_ms / MS_HOUR_CONVERSION,
// the API ignores this value, but to avoid tripping up validation set
// the API ignores these values, but to avoid tripping up validation set
// it to zero if the user can't set the field.
max_ttl_ms: allowAdvancedScheduling
? template.max_ttl_ms / MS_HOUR_CONVERSION
: 0,
failure_ttl_ms: allowAdvancedScheduling
? template.failure_ttl_ms / MS_DAY_CONVERSION
: 0,
inactivity_ttl_ms: allowAdvancedScheduling
? template.inactivity_ttl_ms / MS_DAY_CONVERSION
: 0,
allow_user_autostart: template.allow_user_autostart,
allow_user_autostop: template.allow_user_autostop,
failure_cleanup_enabled:
allowAdvancedScheduling && Boolean(template.failure_ttl_ms),
inactivity_cleanup_enabled:
allowAdvancedScheduling && Boolean(template.inactivity_ttl_ms),
},
validationSchema,
onSubmit: (formData) => {
@@ -97,16 +155,64 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
max_ttl_ms: formData.max_ttl_ms
? formData.max_ttl_ms * MS_HOUR_CONVERSION
: undefined,
failure_ttl_ms: formData.failure_ttl_ms
? formData.failure_ttl_ms * MS_DAY_CONVERSION
: undefined,
inactivity_ttl_ms: formData.inactivity_ttl_ms
? formData.inactivity_ttl_ms * MS_DAY_CONVERSION
: undefined,
allow_user_autostart: formData.allow_user_autostart,
allow_user_autostop: formData.allow_user_autostop,
})
},
initialTouched,
})
const getFieldHelpers = getFormHelpers<UpdateTemplateMeta>(form, error)
const getFieldHelpers = getFormHelpers<TemplateScheduleFormValues>(
form,
error,
)
const { t } = useTranslation("templateSettingsPage")
const styles = useStyles()
const handleToggleFailureCleanup = async (e: ChangeEvent) => {
form.handleChange(e)
if (!form.values.failure_cleanup_enabled) {
// fill failure_ttl_ms with defaults
await form.setValues({
...form.values,
failure_cleanup_enabled: true,
failure_ttl_ms: FAILURE_CLEANUP_DEFAULT,
})
} else {
// clear failure_ttl_ms
await form.setValues({
...form.values,
failure_cleanup_enabled: false,
failure_ttl_ms: 0,
})
}
}
const handleToggleInactivityCleanup = async (e: ChangeEvent) => {
form.handleChange(e)
if (!form.values.inactivity_cleanup_enabled) {
// fill inactivity_ttl_ms with defaults
await form.setValues({
...form.values,
inactivity_cleanup_enabled: true,
inactivity_ttl_ms: INACTIVITY_CLEANUP_DEFAULT,
})
} else {
// clear inactivity_ttl_ms
await form.setValues({
...form.values,
inactivity_cleanup_enabled: false,
inactivity_ttl_ms: 0,
})
}
}
return (
<HorizontalForm
onSubmit={form.handleSubmit}
@@ -215,8 +321,85 @@ export const TemplateScheduleForm: FC<TemplateScheduleForm> = ({
</Stack>
</Stack>
</FormSection>
<FormFooter onCancel={onCancel} isLoading={isSubmitting} />
{allowAdvancedScheduling && allowWorkspaceActions && (
<>
<FormSection
title="Failure Cleanup"
description="When enabled, Coder will automatically stop workspaces that are in a failed state after a specified number of days."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="failureCleanupEnabled"
checked={form.values.failure_cleanup_enabled}
onChange={handleToggleFailureCleanup}
color="primary"
/>
}
label="Enable Failure Cleanup"
/>
<TextField
{...getFieldHelpers(
"failure_ttl_ms",
<TTLHelperText
translationName="failureTTLHelperText"
ttl={form.values.failure_ttl_ms}
/>,
)}
disabled={isSubmitting || !form.values.failure_cleanup_enabled}
fullWidth
inputProps={{ min: 0, step: 1 }}
label="Time until cleanup (days)"
variant="outlined"
type="number"
aria-label="Failure Cleanup"
/>
</FormFields>
</FormSection>
<FormSection
title="Inactivity Cleanup"
description="When enabled, Coder will automatically delete workspaces that are in an inactive state after a specified number of days."
>
<FormFields>
<FormControlLabel
control={
<Switch
name="inactivityCleanupEnabled"
checked={form.values.inactivity_cleanup_enabled}
onChange={handleToggleInactivityCleanup}
color="primary"
/>
}
label="Enable Inactivity Cleanup"
/>
<TextField
{...getFieldHelpers(
"inactivity_ttl_ms",
<TTLHelperText
translationName="inactivityTTLHelperText"
ttl={form.values.inactivity_ttl_ms}
/>,
)}
disabled={
isSubmitting || !form.values.inactivity_cleanup_enabled
}
fullWidth
inputProps={{ min: 0, step: 1 }}
label="Time until cleanup (days)"
variant="outlined"
type="number"
aria-label="Inactivity Cleanup"
/>
</FormFields>
</FormSection>
</>
)}
<FormFooter
onCancel={onCancel}
isLoading={isSubmitting}
submitDisabled={!form.isValid || !form.dirty}
/>
</HorizontalForm>
)
}
@@ -6,11 +6,11 @@ import { Language as FooterFormLanguage } from "components/FormFooter/FormFooter
import {
MockEntitlementsWithScheduling,
MockTemplate,
} from "../../../testHelpers/entities"
} from "testHelpers/entities"
import {
renderWithTemplateSettingsLayout,
waitForLoaderToBeRemoved,
} from "../../../testHelpers/renderHelpers"
} from "testHelpers/renderHelpers"
import { getValidationSchema } from "./TemplateScheduleForm"
import TemplateSchedulePage from "./TemplateSchedulePage"
import i18next from "i18next"
@@ -20,6 +20,8 @@ const { t } = i18next
const validFormValues = {
default_ttl_ms: 1,
max_ttl_ms: 2,
failure_ttl_ms: 7,
inactivity_ttl_ms: 180,
}
const renderTemplateSchedulePage = async () => {
@@ -33,9 +35,13 @@ const renderTemplateSchedulePage = async () => {
const fillAndSubmitForm = async ({
default_ttl_ms,
max_ttl_ms,
failure_ttl_ms,
inactivity_ttl_ms,
}: {
default_ttl_ms: number
max_ttl_ms: number
failure_ttl_ms: number
inactivity_ttl_ms: number
}) => {
const user = userEvent.setup()
const defaultTtlLabel = t("defaultTtlLabel", { ns: "templateSettingsPage" })
@@ -48,6 +54,16 @@ const fillAndSubmitForm = async ({
await user.clear(maxTtlField)
await user.type(maxTtlField, max_ttl_ms.toString())
const failureTtlField = screen.getByRole("checkbox", {
name: /Failure Cleanup/i,
})
await user.type(failureTtlField, failure_ttl_ms.toString())
const inactivityTtlField = screen.getByRole("checkbox", {
name: /Inactivity Cleanup/i,
})
await user.type(inactivityTtlField, inactivity_ttl_ms.toString())
const submitButton = await screen.findByText(
FooterFormLanguage.defaultSubmitLabel,
)
@@ -59,6 +75,9 @@ describe("TemplateSchedulePage", () => {
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("succeeds", async () => {
@@ -71,7 +90,7 @@ describe("TemplateSchedulePage", () => {
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
})
test("ttl is converted to and from hours", async () => {
test("default and max ttl is converted to and from hours", async () => {
await renderTemplateSchedulePage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
@@ -92,7 +111,28 @@ describe("TemplateSchedulePage", () => {
)
})
it("allows a ttl of 7 days", () => {
test("failure and inactivity ttl converted to and from days", async () => {
await renderTemplateSchedulePage()
jest.spyOn(API, "updateTemplateMeta").mockResolvedValueOnce({
...MockTemplate,
...validFormValues,
})
await fillAndSubmitForm(validFormValues)
await waitFor(() => expect(API.updateTemplateMeta).toBeCalledTimes(1))
await waitFor(() =>
expect(API.updateTemplateMeta).toBeCalledWith(
"test-template",
expect.objectContaining({
failure_ttl_ms: validFormValues.failure_ttl_ms * 86400000,
inactivity_ttl_ms: validFormValues.inactivity_ttl_ms * 86400000,
}),
),
)
})
it("allows a default ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7,
@@ -101,7 +141,7 @@ describe("TemplateSchedulePage", () => {
expect(validate).not.toThrowError()
})
it("allows ttl of 0", () => {
it("allows default ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 0,
@@ -110,7 +150,7 @@ describe("TemplateSchedulePage", () => {
expect(validate).not.toThrowError()
})
it("disallows a ttl of 7 days + 1 hour", () => {
it("disallows a default ttl of 7 days + 1 hour", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
default_ttl_ms: 24 * 7 + 1,
@@ -120,4 +160,62 @@ describe("TemplateSchedulePage", () => {
t("defaultTTLMaxError", { ns: "templateSettingsPage" }),
)
})
it("allows a failure ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
failure_ttl_ms: 86400000 * 7,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("allows failure ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
failure_ttl_ms: 0,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("disallows a negative failure ttl", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
failure_ttl_ms: -1,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
"Failure cleanup days must not be less than 0.",
)
})
it("allows an inactivity ttl of 7 days", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
inactivity_ttl_ms: 86400000 * 7,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("allows an inactivity ttl of 0", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
inactivity_ttl_ms: 0,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).not.toThrowError()
})
it("disallows a negative inactivity ttl", () => {
const values: UpdateTemplateMeta = {
...validFormValues,
inactivity_ttl_ms: -1,
}
const validate = () => getValidationSchema().validateSync(values)
expect(validate).toThrowError(
"Inactivity cleanup days must not be less than 0.",
)
})
})
@@ -14,9 +14,13 @@ const TemplateSchedulePage: FC = () => {
const { template: templateName } = useParams() as { template: string }
const navigate = useNavigate()
const { template } = useTemplateSettingsContext()
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 {
mutate: updateTemplate,
isLoading: isSubmitting,
@@ -37,6 +41,7 @@ const TemplateSchedulePage: FC = () => {
</Helmet>
<TemplateSchedulePageView
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
isSubmitting={isSubmitting}
template={template}
submitError={submitError}
@@ -11,6 +11,7 @@ export default {
component: TemplateSchedulePageView,
args: {
allowAdvancedScheduling: true,
allowWorkspaceActions: true,
template: MockTemplate,
onSubmit: action("onSubmit"),
onCancel: action("cancel"),
@@ -12,6 +12,7 @@ export interface TemplateSchedulePageViewProps {
submitError?: unknown
initialTouched?: ComponentProps<typeof TemplateScheduleForm>["initialTouched"]
allowAdvancedScheduling: boolean
allowWorkspaceActions: boolean
}
export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
@@ -20,6 +21,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
onSubmit,
isSubmitting,
allowAdvancedScheduling,
allowWorkspaceActions,
submitError,
initialTouched,
}) => {
@@ -33,6 +35,7 @@ export const TemplateSchedulePageView: FC<TemplateSchedulePageViewProps> = ({
<TemplateScheduleForm
allowAdvancedScheduling={allowAdvancedScheduling}
allowWorkspaceActions={allowWorkspaceActions}
initialTouched={initialTouched}
isSubmitting={isSubmitting}
template={template}
+3 -1
View File
@@ -341,6 +341,8 @@ export const MockTemplate: TypesGen.Template = {
created_by_name: "test_creator",
icon: "/icon/code.svg",
allow_user_cancel_workspace_jobs: true,
failure_ttl_ms: 0,
inactivity_ttl_ms: 0,
allow_user_autostart: false,
allow_user_autostop: false,
}
@@ -1340,7 +1342,7 @@ export const MockEntitlementsWithScheduling: TypesGen.Entitlements = {
}),
}
export const MockExperiments: TypesGen.Experiment[] = []
export const MockExperiments: TypesGen.Experiment[] = ["workspace_actions"]
export const MockAuditLog: TypesGen.AuditLog = {
id: "fbd2116a-8961-4954-87ae-e4575bd29ce0",