diff --git a/docs/admin/provisioners.md b/docs/admin/provisioners.md index 422aa9b29d94c..ef94004106805 100644 --- a/docs/admin/provisioners.md +++ b/docs/admin/provisioners.md @@ -67,6 +67,13 @@ There are two exceptions: **Organization-scoped Provisioners** can pick up build jobs created by any user. These provisioners always have the implicit tags `scope=organization owner=""`. +```shell +coder provisionerd start --org +``` + +If you omit the `--org` argument, the provisioner will be assigned to the +default organization. + ```shell coder provisionerd start ``` diff --git a/site/src/api/api.ts b/site/src/api/api.ts index 3b1a60f2ca134..8dcef31bf676e 100644 --- a/site/src/api/api.ts +++ b/site/src/api/api.ts @@ -627,6 +627,18 @@ class ApiMethods { return response.data; }; + /** + * @param organization Can be the organization's ID or name + */ + getProvisionerDaemonsByOrganization = async ( + organization: string, + ): Promise => { + const response = await this.axios.get( + `/api/v2/organizations/${organization}/provisionerdaemons`, + ); + return response.data; + }; + getTemplate = async (templateId: string): Promise => { const response = await this.axios.get( `/api/v2/templates/${templateId}`, diff --git a/site/src/api/queries/organizations.ts b/site/src/api/queries/organizations.ts index 98c3c9a61e66a..11d97fedcff01 100644 --- a/site/src/api/queries/organizations.ts +++ b/site/src/api/queries/organizations.ts @@ -107,3 +107,16 @@ export const organizations = () => { queryFn: () => API.getOrganizations(), }; }; + +export const getProvisionerDaemonsKey = (organization: string) => [ + "organization", + organization, + "provisionerDaemons", +]; + +export const provisionerDaemons = (organization: string) => { + return { + queryKey: getProvisionerDaemonsKey(organization), + queryFn: () => API.getProvisionerDaemonsByOrganization(organization), + }; +}; diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx index 6f62439ae3d45..b6da0e8f127a1 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.stories.tsx @@ -1,6 +1,13 @@ import { action } from "@storybook/addon-actions"; import type { Meta, StoryObj } from "@storybook/react"; +import { screen, userEvent } from "@storybook/test"; import { + getProvisionerDaemonsKey, + organizationsKey, +} from "api/queries/organizations"; +import { + MockDefaultOrganization, + MockOrganization2, MockTemplate, MockTemplateExample, MockTemplateVersionVariable1, @@ -54,6 +61,31 @@ export const StarterTemplateWithOrgPicker: Story = { }, }; +export const StarterTemplateWithProvisionerWarning: Story = { + parameters: { + queries: [ + { + key: organizationsKey, + data: [MockDefaultOrganization, MockOrganization2], + }, + { + key: getProvisionerDaemonsKey(MockOrganization2.id), + data: [], + }, + ], + }, + args: { + ...StarterTemplate.args, + showOrganizationPicker: true, + }, + play: async () => { + const organizationPicker = screen.getByPlaceholderText("Organization name"); + await userEvent.click(organizationPicker); + const org2 = await screen.findByText(MockOrganization2.display_name); + await userEvent.click(org2); + }, +}; + export const DuplicateTemplateWithVariables: Story = { args: { copiedTemplate: MockTemplate, diff --git a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx index d359530bd6f06..3364d99bf4745 100644 --- a/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx +++ b/site/src/pages/CreateTemplatePage/CreateTemplateForm.tsx @@ -1,10 +1,13 @@ +import Link from "@mui/material/Link"; import TextField from "@mui/material/TextField"; import { useFormik } from "formik"; import camelCase from "lodash/camelCase"; import capitalize from "lodash/capitalize"; import { useState, type FC } from "react"; +import { useQuery } from "react-query"; import { useSearchParams } from "react-router-dom"; import * as Yup from "yup"; +import { provisionerDaemons } from "api/queries/organizations"; import type { Organization, ProvisionerJobLog, @@ -14,6 +17,7 @@ import type { TemplateVersionVariable, VariableValue, } from "api/typesGenerated"; +import { Alert } from "components/Alert/Alert"; import { HorizontalForm, FormSection, @@ -23,6 +27,7 @@ import { import { IconField } from "components/IconField/IconField"; import { OrganizationAutocomplete } from "components/OrganizationAutocomplete/OrganizationAutocomplete"; import { SelectedTemplate } from "pages/CreateWorkspacePage/SelectedTemplate"; +import { docs } from "utils/docs"; import { nameValidator, getFormHelpers, @@ -210,6 +215,24 @@ export const CreateTemplateForm: FC = (props) => { }); const getFieldHelpers = getFormHelpers(form, error); + const provisionerDaemonsQuery = useQuery( + selectedOrg + ? { + ...provisionerDaemons(selectedOrg.id), + enabled: showOrganizationPicker, + select: (provisioners) => provisioners.length < 1, + } + : { enabled: false }, + ); + + // TODO: Ideally, we would have a backend endpoint that could notify the + // frontend that a provisioner has been connected, so that we could hide + // this warning. In the meantime, **do not use this variable to disable + // form submission**!! A user could easily see this warning, connect a + // provisioner, and then not refresh the page. Even if they submit without + // a provisioner, it'll just sit in the job queue until they connect one. + const showProvisionerWarning = provisionerDaemonsQuery.data; + return ( {/* General info */} @@ -232,17 +255,20 @@ export const CreateTemplateForm: FC = (props) => { )} {showOrganizationPicker && ( - { - setSelectedOrg(newValue); - void form.setFieldValue("organization", newValue?.name || ""); - }} - size="medium" - /> + <> + {showProvisionerWarning && } + { + setSelectedOrg(newValue); + void form.setFieldValue("organization", newValue?.name || ""); + }} + size="medium" + /> + )} {"copiedTemplate" in props && ( @@ -369,3 +395,15 @@ const fillNameAndDisplayWithFilename = async ( form.setFieldValue("display_name", capitalize(name)), ]); }; + +const ProvisionerWarning: FC = () => { + return ( + + This organization does not have any provisioners. Before you create a + template, you'll need to configure a provisioner.{" "} + + See our documentation. + + + ); +};