diff --git a/app/forms/project-create.tsx b/app/forms/project-create.tsx index 0f865b6a7f..70f5c63cd7 100644 --- a/app/forms/project-create.tsx +++ b/app/forms/project-create.tsx @@ -7,9 +7,18 @@ */ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' +import type { RequiredDeep } from 'type-fest' -import { api, q, queryClient, useApiMutation, type ProjectCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type ProjectCreate, + type ProjectCreateDefaults, +} from '@oxide/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' @@ -17,12 +26,18 @@ import { HL } from '~/components/HL' import { titleCrumb } from '~/hooks/use-crumbs' import { addToast } from '~/stores/toast' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { TipIcon } from '~/ui/lib/TipIcon' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' -const defaultValues: ProjectCreate = { +type ProjectCreateFormValues = Omit & { + createDefaultVpc: boolean +} + +const defaultValues: ProjectCreateFormValues = { name: '', description: '', + createDefaultVpc: true, } export const handle = titleCrumb('New project') @@ -53,14 +68,32 @@ export default function ProjectCreateSideModalForm() { formType="create" resourceName="project" onDismiss={onDismiss} - onSubmit={({ name, description }) => { - createProject.mutate({ body: { name, description } }) + onSubmit={({ name, description, createDefaultVpc }) => { + createProject.mutate({ + body: { + name, + description, + // RequiredDeep will make tsc fail if an API bump adds a new + // default resource, so we can decide how to handle it + defaults: createDefaultVpc + ? ({ + vpc: { type: 'explicit', defaults: { subnet: {} } }, + } satisfies RequiredDeep) + : {}, + }, + }) }} loading={createProject.isPending || createProject.isSuccess} submitError={createProject.error} > + + + Create default VPC and subnet + + An instance can only attach to a VPC with a subnet. + ) diff --git a/app/forms/vpc-create.tsx b/app/forms/vpc-create.tsx index 06fcf43af9..fd1552928a 100644 --- a/app/forms/vpc-create.tsx +++ b/app/forms/vpc-create.tsx @@ -7,9 +7,18 @@ */ import { useForm } from 'react-hook-form' import { useNavigate } from 'react-router' +import type { RequiredDeep } from 'type-fest' -import { api, q, queryClient, useApiMutation, type VpcCreate } from '@oxide/api' +import { + api, + q, + queryClient, + useApiMutation, + type VpcCreate, + type VpcCreateDefaults, +} from '@oxide/api' +import { CheckboxField } from '~/components/form/fields/CheckboxField' import { DescriptionField } from '~/components/form/fields/DescriptionField' import { NameField } from '~/components/form/fields/NameField' import { TextField } from '~/components/form/fields/TextField' @@ -19,6 +28,7 @@ import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { TipIcon } from '~/ui/lib/TipIcon' import { validateVpcIpv6Prefix } from '~/util/ip' import { docLinks } from '~/util/links' import { pb } from '~/util/path-builder' @@ -28,8 +38,7 @@ type VpcCreateFormValues = { description: string dnsName: string ipv6Prefix: string - // defaults will go here once we support it in the UI. for now, omitting means - // all defaults + createDefaultSubnet: boolean } const defaultValues: VpcCreateFormValues = { @@ -37,6 +46,7 @@ const defaultValues: VpcCreateFormValues = { description: '', dnsName: '', ipv6Prefix: '', + createDefaultSubnet: true, } export const handle = titleCrumb('New VPC') @@ -67,9 +77,16 @@ export default function CreateVpcSideModalForm() { form={form} formType="create" resourceName="VPC" - onSubmit={({ ipv6Prefix, ...rest }) => { - // omitting `defaults` means create all defaults, i.e., the original behavior - const body: VpcCreate = { ...rest, ipv6Prefix: ipv6Prefix.trim() || undefined } + onSubmit={({ ipv6Prefix, createDefaultSubnet, ...rest }) => { + const body: VpcCreate = { + ...rest, + ipv6Prefix: ipv6Prefix.trim() || undefined, + // RequiredDeep will make tsc fail if an API bump adds a new + // default resource, so we can decide how to handle it + defaults: createDefaultSubnet + ? ({ subnet: {} } satisfies RequiredDeep) + : {}, + } createVpc.mutate({ query: projectSelector, body }) }} onDismiss={() => navigate(pb.vpcs(projectSelector))} @@ -90,6 +107,17 @@ export default function CreateVpcSideModalForm() { return validateVpcIpv6Prefix(prefix) }} /> + + + Create default subnet + + +

+ An instance can only attach to a VPC with a subnet. +
A subnet can be added later. +

+
+
) diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 69a3889a93..07e6c91bb1 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -47,7 +47,6 @@ import { defaultSilo, toIdp } from '../silo' import { SUPPORT_BUNDLE_SIZE } from '../support-bundle' import { timeseriesSchemas } from '../timeseries-schema' import { getTimestamps } from '../util' -import { defaultFirewallRules } from '../vpc' import { resendableAlerts, retryPendingDeliveries, validateSubscription } from './alert' import { db, @@ -60,6 +59,7 @@ import { } from './db' import { alreadyExistsErr, + createVpc, currentUser, errIfExists, errIfInvalidDiskSize, @@ -117,14 +117,25 @@ export const handlers = makeHandlers({ const user = currentUser(cookies) errIfExists(db.projects, { name: body.name, silo_id: user.silo_id }, 'project') + const { defaults, ...rest } = body const newProject = { id: uuid(), - ...body, + ...rest, ...getTimestamps(), silo_id: user.silo_id, } db.projects.push(newProject) + // omitting `defaults` creates all of them. otherwise only the ones listed + if (!defaults || defaults.vpc) { + createVpc(newProject.id, { + name: 'default', + dns_name: 'default', + description: 'Default VPC', + defaults: defaults?.vpc?.type === 'explicit' ? defaults.vpc.defaults : undefined, + }) + } + return json(R.omit(newProject, ['silo_id']), { status: 201 }) }, projectView: ({ path }) => { @@ -1582,32 +1593,7 @@ export const handlers = makeHandlers({ requireRole(cookies, 'project', project.id, 'collaborator') errIfExists(db.vpcs, { name: body.name, project_id: project.id }) - const newVpc: Json = { - id: uuid(), - project_id: project.id, - system_router_id: uuid(), - ...body, - // API is supposed to generate one if none provided. close enough - ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', - ...getTimestamps(), - } - db.vpcs.push(newVpc) - - // Also create a default subnet - const newSubnet: Json = { - id: uuid(), - name: 'default', - vpc_id: newVpc.id, - ipv6_block: 'fd2d:4569:88b1::/64', - description: '', - ipv4_block: '', - ...getTimestamps(), - } - db.vpcSubnets.push(newSubnet) - - // populate default firewall rules - db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) - + const newVpc = createVpc(project.id, body) return json(newVpc, { status: 201 }) }, vpcView: ({ path, query }) => lookup.vpc({ ...path, ...query }), diff --git a/mock-api/msw/util.ts b/mock-api/msw/util.ts index 5dd54c56d8..2058af01b9 100644 --- a/mock-api/msw/util.ts +++ b/mock-api/msw/util.ts @@ -11,6 +11,7 @@ import { differenceInSeconds, subHours } from 'date-fns' import { IPv4, IPv6 } from 'ip-num/IPNumber.js' import * as R from 'remeda' import { match } from 'ts-pattern' +import { v4 as uuid } from 'uuid' import { FLEET_ID, @@ -31,6 +32,9 @@ import { type SystemMetricQueryParams, type TimeseriesQuery, type User, + type Vpc, + type VpcCreate, + type VpcSubnet, } from '@oxide/api' import { json, type Json } from '~/api/__generated__/msw-handlers' @@ -55,6 +59,8 @@ import { getMockValues, getHistoPoints, } from '../oxql-metrics' +import { getTimestamps } from '../util' +import { defaultFirewallRules } from '../vpc' import { db, lookupById } from './db' interface PaginateOptions { @@ -763,3 +769,37 @@ export function randomHex(length: number) { .map(() => hexChars.charAt(Math.floor(Math.random() * hexChars.length))) .join('') } + +/** Shared by `vpcCreate` and `projectCreate`, which can create a default VPC */ +export function createVpc(projectId: string, body: Json) { + const { defaults, ...rest } = body + const newVpc: Json = { + id: uuid(), + project_id: projectId, + system_router_id: uuid(), + ...rest, + // API is supposed to generate one if none provided. close enough + ipv6_prefix: body.ipv6_prefix || 'fd2d:4569:88b2::/64', + ...getTimestamps(), + } + db.vpcs.push(newVpc) + + // omitting `defaults` creates all of them. otherwise only the ones listed + if (!defaults || defaults.subnet) { + const newSubnet: Json = { + id: uuid(), + name: 'default', + vpc_id: newVpc.id, + ipv6_block: 'fd2d:4569:88b1::/64', + description: '', + ipv4_block: '', + ...getTimestamps(), + } + db.vpcSubnets.push(newSubnet) + } + + // populate default firewall rules, which are always created + db.vpcFirewallRules.push(...defaultFirewallRules(newVpc.id)) + + return newVpc +} diff --git a/test/e2e/project-create.e2e.ts b/test/e2e/project-create.e2e.ts index db469b1c00..a945d13b28 100644 --- a/test/e2e/project-create.e2e.ts +++ b/test/e2e/project-create.e2e.ts @@ -29,6 +29,29 @@ test.describe('Project create', () => { await expect(page).toHaveURL('/projects/mock-project-2/instances') }) + test('creates default VPC by default', async ({ page }) => { + await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') + await expect( + page.getByRole('checkbox', { name: 'Create default VPC and subnet' }) + ).toBeChecked() + await page.getByRole('button', { name: 'Create project' }).click() + await expect(page).toHaveURL('/projects/mock-project-2/instances') + + // client-side nav to keep the mock db state + await page.getByRole('link', { name: 'VPCs' }).click() + await expect(page.getByRole('cell', { name: 'default' }).first()).toBeVisible() + }) + + test('can skip creating the default VPC', async ({ page }) => { + await page.getByRole('textbox', { name: 'Name' }).fill('mock-project-2') + await page.getByRole('checkbox', { name: 'Create default VPC and subnet' }).uncheck() + await page.getByRole('button', { name: 'Create project' }).click() + await expect(page).toHaveURL('/projects/mock-project-2/instances') + + await page.getByRole('link', { name: 'VPCs' }).click() + await expect(page.getByText('No VPCs')).toBeVisible() + }) + test('clicking the scrim dismisses the side modal', async ({ page }) => { const dialog = page.getByRole('dialog', { name: /Create project/ }) await expect(dialog).toBeVisible() diff --git a/test/e2e/vpcs.e2e.ts b/test/e2e/vpcs.e2e.ts index 36d42894ee..c4e81052ca 100644 --- a/test/e2e/vpcs.e2e.ts +++ b/test/e2e/vpcs.e2e.ts @@ -129,6 +129,38 @@ test('whitespace-only IPv6 prefix is omitted on VPC create', async ({ page }) => await expect(page.getByRole('heading', { name: 'vpc-generated-v6' })).toBeVisible() }) +test('creates default subnet on VPC create by default', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs-new') + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + await dialog.getByRole('textbox', { name: 'Name', exact: true }).fill('vpc-with-subnet') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-with-subnet') + await expect( + dialog.getByRole('checkbox', { name: 'Create default subnet' }) + ).toBeChecked() + await dialog.getByRole('button', { name: 'Create VPC' }).click() + + await expect(page.getByRole('heading', { name: 'vpc-with-subnet' })).toBeVisible() + await page.getByRole('tab', { name: 'VPC Subnets' }).click() + await expectRowVisible(page.getByRole('table'), { name: 'default' }) +}) + +test('can skip creating the default subnet', async ({ page }) => { + await page.goto('/projects/mock-project/vpcs-new') + + const dialog = page.getByRole('dialog', { name: 'Create VPC' }) + await dialog + .getByRole('textbox', { name: 'Name', exact: true }) + .fill('vpc-without-subnet') + await dialog.getByRole('textbox', { name: 'DNS name' }).fill('vpc-without-subnet') + await dialog.getByRole('checkbox', { name: 'Create default subnet' }).uncheck() + await dialog.getByRole('button', { name: 'Create VPC' }).click() + + await expect(page.getByRole('heading', { name: 'vpc-without-subnet' })).toBeVisible() + await page.getByRole('tab', { name: 'VPC Subnets' }).click() + await expect(page.getByText('No VPC subnets')).toBeVisible() +}) + test('can create and delete subnet', async ({ page }) => { await page.goto('/projects/mock-project/vpcs/default') await page.getByRole('tab', { name: 'VPC Subnets' }).click()