feat(site): add create template from scratch (#12082)

This commit is contained in:
Bruno Quaresma 2024-02-09 11:42:26 -03:00 committed by GitHub
parent 2b307c7c4e
commit 390217b396
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
18 changed files with 317 additions and 34 deletions

View File

@ -6,7 +6,7 @@ USAGE:
Get started with a templated template.
OPTIONS:
--id aws-devcontainer|aws-linux|aws-windows|azure-linux|do-linux|docker|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|nomad-docker
--id aws-devcontainer|aws-linux|aws-windows|azure-linux|do-linux|docker|gcp-devcontainer|gcp-linux|gcp-vm-container|gcp-windows|kubernetes|nomad-docker|scratch
Specify a given example template by ID.
———

View File

@ -14,8 +14,8 @@ coder templates init [flags] [directory]
### --id
| | |
| ---- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Type | <code>enum[aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|do-linux\|docker\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|nomad-docker]</code> |
| | |
| ---- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Type | <code>enum[aws-devcontainer\|aws-linux\|aws-windows\|azure-linux\|do-linux\|docker\|gcp-devcontainer\|gcp-linux\|gcp-vm-container\|gcp-windows\|kubernetes\|nomad-docker\|scratch]</code> |
Specify a given example template by ID.

View File

@ -156,5 +156,14 @@
"container"
],
"markdown": "\n# Remote Development on Nomad\n\nProvision Nomad Jobs as [Coder workspaces](https://coder.com/docs/v2/latest/workspaces) with this example template. This example shows how to use Nomad service tasks to be used as a development environment using docker and host csi volumes.\n\n\u003c!-- TODO: Add screenshot --\u003e\n\n\u003e **Note**\n\u003e This template is designed to be a starting point! Edit the Terraform to extend the template to support your use case.\n\n## Prerequisites\n\n- [Nomad](https://www.nomadproject.io/downloads)\n- [Docker](https://docs.docker.com/get-docker/)\n\n## Setup\n\n### 1. Start the CSI Host Volume Plugin\n\nThe CSI Host Volume plugin is used to mount host volumes into Nomad tasks. This is useful for development environments where you want to mount persistent volumes into your container workspace.\n\n1. Login to the Nomad server using SSH.\n\n2. Append the following stanza to your Nomad server configuration file and restart the nomad service.\n\n ```hcl\n plugin \"docker\" {\n config {\n allow_privileged = true\n }\n }\n ```\n\n ```shell\n sudo systemctl restart nomad\n ```\n\n3. Create a file `hostpath.nomad` with following content:\n\n ```hcl\n job \"hostpath-csi-plugin\" {\n datacenters = [\"dc1\"]\n type = \"system\"\n\n group \"csi\" {\n task \"plugin\" {\n driver = \"docker\"\n\n config {\n image = \"registry.k8s.io/sig-storage/hostpathplugin:v1.10.0\"\n\n args = [\n \"--drivername=csi-hostpath\",\n \"--v=5\",\n \"--endpoint=${CSI_ENDPOINT}\",\n \"--nodeid=node-${NOMAD_ALLOC_INDEX}\",\n ]\n\n privileged = true\n }\n\n csi_plugin {\n id = \"hostpath\"\n type = \"monolith\"\n mount_dir = \"/csi\"\n }\n\n resources {\n cpu = 256\n memory = 128\n }\n }\n }\n }\n ```\n\n4. Run the job:\n\n ```shell\n nomad job run hostpath.nomad\n ```\n\n### 2. Setup the Nomad Template\n\n1. Create the template by running the following command:\n\n ```shell\n coder template init nomad-docker\n cd nomad-docker\n coder template push\n ```\n\n2. Set up Nomad server address and optional authentication:\n\n3. Create a new workspace and start developing.\n"
},
{
"id": "scratch",
"url": "",
"name": "Scratch",
"description": "A minimal starter template for Coder",
"icon": "/emojis/1f4e6.png",
"tags": [],
"markdown": "\n# A minimal Scaffolding for a Coder Template\n\nUse this starter template as a basis to create your own unique template from scratch.\n"
}
]

View File

@ -35,6 +35,7 @@ var (
//go:embed templates/gcp-windows
//go:embed templates/kubernetes
//go:embed templates/nomad-docker
//go:embed templates/scratch
files embed.FS
exampleBasePath = "https://github.com/coder/coder/tree/main/examples/templates/"

View File

@ -0,0 +1,12 @@
---
display_name: Scratch
description: A minimal starter template for Coder
icon: ../../../site/static/emojis/1f4e6.png
maintainer_github: coder
verified: true
tags: []
---
# A minimal Scaffolding for a Coder Template
Use this starter template as a basis to create your own unique template from scratch.

View File

@ -0,0 +1,62 @@
terraform {
required_providers {
coder = {
source = "coder/coder"
}
}
}
data "coder_provisioner" "me" {}
data "coder_workspace" "me" {}
resource "coder_agent" "main" {
arch = data.coder_provisioner.me.arch
os = data.coder_provisioner.me.os
metadata {
display_name = "CPU Usage"
key = "0_cpu_usage"
script = "coder stat cpu"
interval = 10
timeout = 1
}
metadata {
display_name = "RAM Usage"
key = "1_ram_usage"
script = "coder stat mem"
interval = 10
timeout = 1
}
}
# Use this to set environment variables in your workspace
# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/env
resource "coder_env" "welcome_message" {
agent_id = coder_agent.main.id
name = "WELCOME_MESSAGE"
value = "Welcome to your Coder workspace!"
}
# Adds code-server
# See all available modules at https://registry.coder.com
module "code-server" {
source = "registry.coder.com/modules/code-server/coder"
version = "1.0.2"
agent_id = coder_agent.main.id
}
# Runs a script at workspace start/stop or on a cron schedule
# details: https://registry.terraform.io/providers/coder/coder/latest/docs/resources/script
resource "coder_script" "startup_script" {
agent_id = coder_agent.main.id
display_name = "Startup Script"
script = <<-EOF
#!/bin/sh
set -e
# Run programs at workspace startup
EOF
run_on_start = true
start_blocks_login = true
}

View File

@ -134,7 +134,7 @@ export const createTemplate = async (
const name = randomName();
await page.getByLabel("Name *").fill(name);
await page.getByTestId("form-submit").click();
await expect(page).toHaveURL("/templates/" + name, {
await expect(page).toHaveURL(`/templates/${name}/files`, {
timeout: 30000,
});
return name;

View File

@ -88,7 +88,7 @@ test("Create template from starter template", async () => {
);
await waitFor(() => expect(API.createTemplate).toBeCalledTimes(1));
expect(router.state.location.pathname).toEqual(
`/templates/${MockTemplate.name}`,
`/templates/${MockTemplate.name}/files`,
);
expect(API.createTemplateVersion).toHaveBeenCalledWith(MockOrganization.id, {
example_id: "aws-windows",
@ -138,7 +138,7 @@ test("Create template from duplicating a template", async () => {
);
await waitFor(() => {
expect(router.state.location.pathname).toEqual(
`/templates/${MockTemplate.name}`,
`/templates/${MockTemplate.name}/files`,
);
});
});

View File

@ -6,11 +6,16 @@ import { FullPageHorizontalForm } from "components/FullPageForm/FullPageHorizont
import { DuplicateTemplateView } from "./DuplicateTemplateView";
import { ImportStarterTemplateView } from "./ImportStarterTemplateView";
import { UploadTemplateView } from "./UploadTemplateView";
import { Template } from "api/typesGenerated";
const CreateTemplatePage: FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const onSuccess = (template: Template) => {
navigate(`/templates/${template.name}/files`);
};
const onCancel = () => {
navigate(-1);
};
@ -23,11 +28,11 @@ const CreateTemplatePage: FC = () => {
<FullPageHorizontalForm title="Create Template" onCancel={onCancel}>
{searchParams.has("fromTemplate") ? (
<DuplicateTemplateView />
<DuplicateTemplateView onSuccess={onSuccess} />
) : searchParams.has("exampleId") ? (
<ImportStarterTemplateView />
<ImportStarterTemplateView onSuccess={onSuccess} />
) : (
<UploadTemplateView />
<UploadTemplateView onSuccess={onSuccess} />
)}
</FullPageHorizontalForm>
</>

View File

@ -15,8 +15,15 @@ import { ErrorAlert } from "components/Alert/ErrorAlert";
import { Loader } from "components/Loader/Loader";
import { CreateTemplateForm } from "./CreateTemplateForm";
import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils";
import { Template } from "api/typesGenerated";
export const DuplicateTemplateView: FC = () => {
type DuplicateTemplateViewProps = {
onSuccess: (template: Template) => void;
};
export const DuplicateTemplateView: FC<DuplicateTemplateViewProps> = ({
onSuccess,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
const [searchParams] = useSearchParams();
@ -79,7 +86,7 @@ export const DuplicateTemplateView: FC = () => {
),
template: newTemplate(formData),
});
navigate(`/templates/${template.name}`);
onSuccess(template);
}}
/>
);

View File

@ -18,8 +18,15 @@ import {
getFormPermissions,
newTemplate,
} from "./utils";
import { Template } from "api/typesGenerated";
export const ImportStarterTemplateView: FC = () => {
type ImportStarterTemplateViewProps = {
onSuccess: (template: Template) => void;
};
export const ImportStarterTemplateView: FC<ImportStarterTemplateViewProps> = ({
onSuccess,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
const [searchParams] = useSearchParams();
@ -76,7 +83,7 @@ export const ImportStarterTemplateView: FC = () => {
),
template: newTemplate(formData),
});
navigate(`/templates/${template.name}`);
onSuccess(template);
}}
/>
);

View File

@ -11,8 +11,16 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId";
import { useDashboard } from "modules/dashboard/useDashboard";
import { CreateTemplateForm } from "./CreateTemplateForm";
import { firstVersionFromFile, getFormPermissions, newTemplate } from "./utils";
import { Template } from "api/typesGenerated";
import { FC } from "react";
export const UploadTemplateView = () => {
type UploadTemplateViewProps = {
onSuccess: (template: Template) => void;
};
export const UploadTemplateView: FC<UploadTemplateViewProps> = ({
onSuccess,
}) => {
const navigate = useNavigate();
const organizationId = useOrganizationId();
@ -61,7 +69,7 @@ export const UploadTemplateView = () => {
),
template: newTemplate(formData),
});
navigate(`/templates/${template.name}`);
onSuccess(template);
}}
/>
);

View File

@ -0,0 +1,59 @@
import { render, screen } from "@testing-library/react";
import StarterTemplatesPage from "./StarterTemplatesPage";
import { AppProviders } from "App";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import { RequireAuth } from "contexts/auth/RequireAuth";
import { rest } from "msw";
import {
MockTemplateExample,
MockTemplateExample2,
} from "testHelpers/entities";
import { server } from "testHelpers/server";
test("does not display the scratch template", async () => {
server.use(
rest.get(
"api/v2/organizations/:organizationId/templates/examples",
(req, res, ctx) => {
return res(
ctx.status(200),
ctx.json([
MockTemplateExample,
MockTemplateExample2,
{
...MockTemplateExample,
id: "scratch",
name: "Scratch",
description: "Create a template from scratch",
},
]),
);
},
),
);
render(
<AppProviders>
<RouterProvider
router={createMemoryRouter(
[
{
element: <RequireAuth />,
children: [
{
path: "/starter-templates",
element: <StarterTemplatesPage />,
},
],
},
],
{ initialEntries: ["/starter-templates"] },
)}
/>
</AppProviders>,
);
await screen.findByText(MockTemplateExample.name);
screen.getByText(MockTemplateExample2.name);
expect(screen.queryByText("Scratch")).not.toBeInTheDocument();
});

View File

@ -6,12 +6,14 @@ import { useOrganizationId } from "contexts/auth/useOrganizationId";
import { pageTitle } from "utils/page";
import { getTemplatesByTag } from "utils/starterTemplates";
import { StarterTemplatesPageView } from "./StarterTemplatesPageView";
import { TemplateExample } from "api/typesGenerated";
const StarterTemplatesPage: FC = () => {
const organizationId = useOrganizationId();
const templateExamplesQuery = useQuery(templateExamples(organizationId));
const starterTemplatesByTag = templateExamplesQuery.data
? getTemplatesByTag(templateExamplesQuery.data)
? // Currently, the scratch template should not be displayed on the starter templates page.
getTemplatesByTag(removeScratchExample(templateExamplesQuery.data))
: undefined;
return (
@ -28,4 +30,8 @@ const StarterTemplatesPage: FC = () => {
);
};
const removeScratchExample = (data: TemplateExample[]) => {
return data.filter((example) => example.id !== "scratch");
};
export default StarterTemplatesPage;

View File

@ -0,0 +1,22 @@
import { CreateTemplateButton } from "./CreateTemplateButton";
import type { Meta, StoryObj } from "@storybook/react";
import { userEvent, screen } from "@storybook/test";
const meta: Meta<typeof CreateTemplateButton> = {
title: "pages/TemplatesPage/CreateTemplateButton",
component: CreateTemplateButton,
};
export default meta;
type Story = StoryObj<typeof CreateTemplateButton>;
export const Close: Story = {};
export const Open: Story = {
play: async ({ step }) => {
const user = userEvent.setup();
await step("click on trigger", async () => {
await user.click(screen.getByRole("button"));
});
},
};

View File

@ -0,0 +1,56 @@
import Button from "@mui/material/Button";
import AddIcon from "@mui/icons-material/AddOutlined";
import {
MoreMenu,
MoreMenuContent,
MoreMenuItem,
MoreMenuTrigger,
} from "components/MoreMenu/MoreMenu";
import NoteAddOutlined from "@mui/icons-material/NoteAddOutlined";
import UploadOutlined from "@mui/icons-material/UploadOutlined";
import Inventory2 from "@mui/icons-material/Inventory2";
import { FC } from "react";
type CreateTemplateButtonProps = {
onNavigate: (path: string) => void;
};
export const CreateTemplateButton: FC<CreateTemplateButtonProps> = ({
onNavigate,
}) => {
return (
<MoreMenu>
<MoreMenuTrigger>
<Button startIcon={<AddIcon />} variant="contained">
Create Template
</Button>
</MoreMenuTrigger>
<MoreMenuContent>
<MoreMenuItem
onClick={() => {
onNavigate(`/templates/new?exampleId=scratch`);
}}
>
<NoteAddOutlined />
From scratch
</MoreMenuItem>
<MoreMenuItem
onClick={() => {
onNavigate("/templates/new");
}}
>
<UploadOutlined />
Upload template
</MoreMenuItem>
<MoreMenuItem
onClick={() => {
onNavigate("/starter-templates");
}}
>
<Inventory2 />
Choose a starter template
</MoreMenuItem>
</MoreMenuContent>
</MoreMenu>
);
};

View File

@ -0,0 +1,42 @@
import { render, screen } from "@testing-library/react";
import { AppProviders } from "App";
import { RequireAuth } from "contexts/auth/RequireAuth";
import { RouterProvider, createMemoryRouter } from "react-router-dom";
import TemplatesPage from "./TemplatesPage";
import userEvent from "@testing-library/user-event";
test("create template from scratch", async () => {
const user = userEvent.setup();
const router = createMemoryRouter(
[
{
element: <RequireAuth />,
children: [
{
path: "/templates",
element: <TemplatesPage />,
},
{
path: "/templates/new",
element: <div data-testid="new-template-page" />,
},
],
},
],
{ initialEntries: ["/templates"] },
);
render(
<AppProviders>
<RouterProvider router={router} />
</AppProviders>,
);
const createTemplateButton = await screen.findByRole("button", {
name: "Create Template",
});
await user.click(createTemplateButton);
const fromScratchMenuItem = await screen.findByText("From scratch");
await user.click(fromScratchMenuItem);
await screen.findByTestId("new-template-page");
expect(router.state.location.pathname).toBe("/templates/new");
expect(router.state.location.search).toBe("?exampleId=scratch");
});

View File

@ -6,9 +6,8 @@ import TableCell from "@mui/material/TableCell";
import TableContainer from "@mui/material/TableContainer";
import TableHead from "@mui/material/TableHead";
import TableRow from "@mui/material/TableRow";
import AddIcon from "@mui/icons-material/AddOutlined";
import { type FC } from "react";
import { useNavigate, Link as RouterLink } from "react-router-dom";
import { useNavigate } from "react-router-dom";
import { createDayString } from "utils/createDayString";
import {
formatTemplateBuildTime,
@ -45,6 +44,7 @@ import { docs } from "utils/docs";
import Skeleton from "@mui/material/Skeleton";
import { AvatarDataSkeleton } from "components/AvatarData/AvatarDataSkeleton";
import { DeprecatedBadge } from "components/Badges/Badges";
import { CreateTemplateButton } from "./CreateTemplateButton";
export const Language = {
developerCount: (activeCount: number): string => {
@ -165,26 +165,13 @@ export const TemplatesPageView: FC<TemplatesPageViewProps> = ({
}) => {
const isLoading = !templates;
const isEmpty = templates && templates.length === 0;
const navigate = useNavigate();
return (
<Margins>
<PageHeader
actions={
canCreateTemplates && (
<>
<Button component={RouterLink} to="/starter-templates">
Starter Templates
</Button>
<Button
startIcon={<AddIcon />}
component={RouterLink}
to="new"
variant="contained"
>
Create Template
</Button>
</>
)
canCreateTemplates && <CreateTemplateButton onNavigate={navigate} />
}
>
<PageHeaderTitle>