diff --git a/AGENTS.md b/AGENTS.md index 5c84bca26..5b34b807a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -64,6 +64,7 @@ - Only implement what is necessary to exercise the UI; keep the db seeded via `mock-api/msw/db.ts`. - Store API response objects in the mock tables when possible so state persists across calls. - Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`). +- All UUIDs in `mock-api/` must be valid RFC 4122 (a safety test enforces this). Use `uuidgen` to generate them—do not hand-write UUIDs. # Routing diff --git a/app/api/selectors.ts b/app/api/selectors.ts index f700d6cae..0dd0bc122 100644 --- a/app/api/selectors.ts +++ b/app/api/selectors.ts @@ -32,6 +32,8 @@ export type SystemUpdate = Readonly<{ version: string }> export type SshKey = Readonly<{ sshKey: string }> export type Sled = Readonly<{ sledId?: string }> export type IpPool = Readonly<{ pool?: string }> +export type SubnetPool = Readonly<{ subnetPool?: string }> +export type ExternalSubnet = Readonly> export type FloatingIp = Readonly> export type Id = Readonly<{ id: string }> diff --git a/app/components/AttachEphemeralIpModal.tsx b/app/components/AttachEphemeralIpModal.tsx index 06fa56d7a..fa0af0519 100644 --- a/app/components/AttachEphemeralIpModal.tsx +++ b/app/components/AttachEphemeralIpModal.tsx @@ -22,7 +22,7 @@ import { } from '~/api' import { ListboxField } from '~/components/form/fields/ListboxField' import { HL } from '~/components/HL' -import { toIpPoolItem } from '~/components/IpPoolListboxItem' +import { toPoolItem } from '~/components/PoolListboxItem' import { useInstanceSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Message } from '~/ui/lib/Message' @@ -90,7 +90,7 @@ export const AttachEphemeralIpModal = ({ name="pool" label="Pool" control={form.control} - items={sortPools(compatibleUnicastPools).map(toIpPoolItem)} + items={sortPools(compatibleUnicastPools).map(toPoolItem)} disabled={compatibleUnicastPools.length === 0} placeholder="Select a pool" noItemsPlaceholder="No pools available" diff --git a/app/components/IpPoolListboxItem.tsx b/app/components/PoolListboxItem.tsx similarity index 73% rename from app/components/IpPoolListboxItem.tsx rename to app/components/PoolListboxItem.tsx index 8de4e0bc0..c3041d61c 100644 --- a/app/components/IpPoolListboxItem.tsx +++ b/app/components/PoolListboxItem.tsx @@ -6,14 +6,22 @@ * Copyright Oxide Computer Company */ -import type { SiloIpPool } from '@oxide/api' +import type { IpVersion } from '@oxide/api' import { Badge } from '@oxide/design-system/ui' import { IpVersionBadge } from '~/components/IpVersionBadge' import type { ListboxItem } from '~/ui/lib/Listbox' -/** Format a SiloIpPool for use as a ListboxField item */ -export function toIpPoolItem(p: SiloIpPool): ListboxItem { +/** Common fields of SiloIpPool and SiloSubnetPool used for display */ +type PoolLike = { + name: string + isDefault: boolean + ipVersion: IpVersion + description: string +} + +/** Format a pool for use as a ListboxField item */ +export function toPoolItem(p: PoolLike): ListboxItem { const value = p.name const selectedLabel = p.name const label = ( diff --git a/app/forms/external-subnet-create.tsx b/app/forms/external-subnet-create.tsx new file mode 100644 index 000000000..790adf93f --- /dev/null +++ b/app/forms/external-subnet-create.tsx @@ -0,0 +1,159 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate } from 'react-router' +import { match } from 'ts-pattern' + +import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { NameField } from '~/components/form/fields/NameField' +import { NumberField } from '~/components/form/fields/NumberField' +import { RadioField } from '~/components/form/fields/RadioField' +import { TextField } from '~/components/form/fields/TextField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { toPoolItem } from '~/components/PoolListboxItem' +import { titleCrumb } from '~/hooks/use-crumbs' +import { useProjectSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { ALL_ISH } from '~/util/consts' +import { validateIpNet } from '~/util/ip' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const poolList = q(api.subnetPoolList, { query: { limit: ALL_ISH } }) + +export async function clientLoader() { + await queryClient.prefetchQuery(poolList) + return null +} + +export const handle = titleCrumb('New External Subnet') + +type FormValues = { + name: string + description: string + allocationType: 'auto' | 'explicit' + prefixLen: number + pool: string + subnet: string +} + +const defaultFormValues: Omit = { + name: '', + description: '', + allocationType: 'auto', + prefixLen: 24, + subnet: '', +} + +export default function CreateExternalSubnetSideModalForm() { + const { data: pools } = usePrefetchedQuery(poolList) + + const defaultPool = pools.items.find((p) => p.isDefault) + + const projectSelector = useProjectSelector() + const navigate = useNavigate() + + const createExternalSubnet = useApiMutation(api.externalSubnetCreate, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} created) + navigate(pb.externalSubnets(projectSelector)) + }, + }) + + const form = useForm({ + defaultValues: { ...defaultFormValues, pool: defaultPool?.name ?? '' }, + }) + + const allocationType = form.watch('allocationType') + const selectedPoolName = form.watch('pool') + const selectedPool = pools.items.find((p) => p.name === selectedPoolName) + // In auto allocation, the requested prefix length is matched against pool + // members whose min/max prefix range includes it, then a subnet is carved + // out of the first member with a large enough gap. Reproducing this member + // resolution logic is more or less impossible, so we just enforce a max by + // IP version. + // https://github.com/oxidecomputer/omicron/blob/e7d260a/nexus/db-queries/src/db/queries/external_subnet.rs#L906-L908 + const prefixLenMax = !selectedPool || selectedPool.ipVersion === 'v6' ? 128 : 32 + + return ( + navigate(pb.externalSubnets(projectSelector))} + onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => { + const allocator = match(allocationType) + .with('explicit', () => ({ type: 'explicit' as const, subnet })) + .with('auto', () => ({ + type: 'auto' as const, + prefixLen, + poolSelector: { type: 'explicit' as const, pool }, + })) + .exhaustive() + createExternalSubnet.mutate({ + query: projectSelector, + body: { name, description, allocator }, + }) + }} + loading={createExternalSubnet.isPending} + submitError={createExternalSubnet.error} + > + + + + {allocationType === 'auto' ? ( + <> + + + + ) : ( + + )} + + + ) +} diff --git a/app/forms/external-subnet-edit.tsx b/app/forms/external-subnet-edit.tsx new file mode 100644 index 000000000..0efa2622c --- /dev/null +++ b/app/forms/external-subnet-edit.tsx @@ -0,0 +1,120 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useForm } from 'react-hook-form' +import { useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + q, + qErrorsAllowed, + queryClient, + useApiMutation, + usePrefetchedQuery, +} from '@oxide/api' + +import { DescriptionField } from '~/components/form/fields/DescriptionField' +import { NameField } from '~/components/form/fields/NameField' +import { SideModalForm } from '~/components/form/SideModalForm' +import { HL } from '~/components/HL' +import { titleCrumb } from '~/hooks/use-crumbs' +import { getExternalSubnetSelector, useExternalSubnetSelector } from '~/hooks/use-params' +import { addToast } from '~/stores/toast' +import { InstanceLink } from '~/table/cells/InstanceLinkCell' +import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell' +import { SideModalFormDocs } from '~/ui/lib/ModalLinks' +import { PropertiesTable } from '~/ui/lib/PropertiesTable' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' +import type * as PP from '~/util/path-params' + +const externalSubnetView = ({ project, externalSubnet }: PP.ExternalSubnet) => + q(api.externalSubnetView, { + path: { externalSubnet }, + query: { project }, + }) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const selector = getExternalSubnetSelector(params) + const subnet = await queryClient.fetchQuery(externalSubnetView(selector)) + await Promise.all([ + queryClient.prefetchQuery( + // subnet pool cell uses errors allowed, so we have to do that here to match + qErrorsAllowed( + api.subnetPoolView, + { path: { pool: subnet.subnetPoolId } }, + { + errorsExpected: { + explanation: 'the referenced subnet pool may have been deleted.', + statusCode: 404, + }, + } + ) + ), + subnet.instanceId + ? queryClient.prefetchQuery( + q(api.instanceView, { path: { instance: subnet.instanceId } }) + ) + : null, + ]) + return null +} + +export const handle = titleCrumb('Edit External Subnet') + +export default function EditExternalSubnetSideModalForm() { + const navigate = useNavigate() + + const subnetSelector = useExternalSubnetSelector() + const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project })) + + const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector)) + + const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, { + onSuccess(updated) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {updated.name} updated) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: subnet }) + return ( + { + editExternalSubnet.mutate({ + path: { externalSubnet: subnetSelector.externalSubnet }, + query: { project: subnetSelector.project }, + body: { name, description }, + }) + }} + loading={editExternalSubnet.isPending} + submitError={editExternalSubnet.error} + > + + + + + {subnet.subnet} + + + + + + + + + + + + ) +} diff --git a/app/forms/floating-ip-create.tsx b/app/forms/floating-ip-create.tsx index 27e772d77..b36b218fe 100644 --- a/app/forms/floating-ip-create.tsx +++ b/app/forms/floating-ip-create.tsx @@ -25,7 +25,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField' import { NameField } from '~/components/form/fields/NameField' import { SideModalForm } from '~/components/form/SideModalForm' import { HL } from '~/components/HL' -import { toIpPoolItem } from '~/components/IpPoolListboxItem' +import { toPoolItem } from '~/components/PoolListboxItem' import { titleCrumb } from '~/hooks/use-crumbs' import { useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' @@ -103,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() { name="pool" label="Pool" control={form.control} - items={sortPools(unicastPools).map(toIpPoolItem)} + items={sortPools(unicastPools).map(toPoolItem)} required placeholder="Select a pool" noItemsPlaceholder="No pools available" diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx index 4659af7e2..a662e85b6 100644 --- a/app/forms/instance-create.tsx +++ b/app/forms/instance-create.tsx @@ -63,7 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField' import { Form } from '~/components/form/Form' import { FullPageForm } from '~/components/form/FullPageForm' import { HL } from '~/components/HL' -import { toIpPoolItem } from '~/components/IpPoolListboxItem' +import { toPoolItem } from '~/components/PoolListboxItem' import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' import { addToast } from '~/stores/toast' import { Button } from '~/ui/lib/Button' @@ -335,7 +335,7 @@ function EphemeralIpCheckbox({ (getSelector: (params: AllParams) => T) { // params are present. Only the specified keys end up in the result object, but // we do not error if there are other params present in the query string. +export const useExternalSubnetSelector = () => useSelectedParams(getExternalSubnetSelector) export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector) export const useProjectSelector = () => useSelectedParams(getProjectSelector) export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector) diff --git a/app/layouts/ProjectLayoutBase.tsx b/app/layouts/ProjectLayoutBase.tsx index 407876df1..b73e90581 100644 --- a/app/layouts/ProjectLayoutBase.tsx +++ b/app/layouts/ProjectLayoutBase.tsx @@ -19,6 +19,7 @@ import { Networking16Icon, Snapshots16Icon, Storage16Icon, + Subnet16Icon, } from '@oxide/design-system/icons/react' import { TopBar } from '~/components/TopBar' @@ -68,6 +69,7 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { { value: 'Images', path: pb.projectImages(projectSelector) }, { value: 'VPCs', path: pb.vpcs(projectSelector) }, { value: 'Floating IPs', path: pb.floatingIps(projectSelector) }, + { value: 'External Subnets', path: pb.externalSubnets(projectSelector) }, { value: 'Affinity Groups', path: pb.affinity(projectSelector) }, { value: 'Project Access', path: pb.projectAccess(projectSelector) }, ] @@ -112,6 +114,9 @@ export function ProjectLayoutBase({ overrideContentPane }: ProjectLayoutProps) { Floating IPs + + External Subnets + Affinity Groups diff --git a/app/pages/project/disks/DisksPage.tsx b/app/pages/project/disks/DisksPage.tsx index ad426fafa..7f6a37ae3 100644 --- a/app/pages/project/disks/DisksPage.tsx +++ b/app/pages/project/disks/DisksPage.tsx @@ -164,7 +164,7 @@ export default function DisksPage() { colHelper.accessor( (disk) => ('instance' in disk.state ? disk.state.instance : undefined), { - header: 'Attached to', + header: 'Instance', cell: (info) => ( ), diff --git a/app/pages/project/external-subnets/ExternalSubnetsPage.tsx b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx new file mode 100644 index 000000000..4d0aa1ec4 --- /dev/null +++ b/app/pages/project/external-subnets/ExternalSubnetsPage.tsx @@ -0,0 +1,303 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' +import { createColumnHelper } from '@tanstack/react-table' +import { useCallback, useState } from 'react' +import { useForm } from 'react-hook-form' +import { Outlet, useNavigate, type LoaderFunctionArgs } from 'react-router' + +import { + api, + getListQFn, + q, + queryClient, + useApiMutation, + usePrefetchedQuery, + type ExternalSubnet, + type Instance, +} from '@oxide/api' +import { Subnet16Icon, Subnet24Icon } from '@oxide/design-system/icons/react' + +import { DocsPopover } from '~/components/DocsPopover' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' +import { HL } from '~/components/HL' +import { makeCrumb } from '~/hooks/use-crumbs' +import { getProjectSelector, useProjectSelector } from '~/hooks/use-params' +import { useQuickActions } from '~/hooks/use-quick-actions' +import { confirmAction } from '~/stores/confirm-action' +import { confirmDelete } from '~/stores/confirm-delete' +import { addToast } from '~/stores/toast' +import { InstanceLink } from '~/table/cells/InstanceLinkCell' +import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell' +import { useColsWithActions, type MenuAction } from '~/table/columns/action-col' +import { Columns } from '~/table/columns/common' +import { useQueryTable } from '~/table/QueryTable' +import { CreateLink } from '~/ui/lib/CreateButton' +import { EmptyMessage } from '~/ui/lib/EmptyMessage' +import { Message } from '~/ui/lib/Message' +import { PageHeader, PageTitle } from '~/ui/lib/PageHeader' +import { TableActions } from '~/ui/lib/Table' +import { ALL_ISH } from '~/util/consts' +import { docLinks } from '~/util/links' +import { pb } from '~/util/path-builder' + +const EmptyState = () => ( + } + title="No external subnets" + body="Create an external subnet to see it here" + buttonText="New External Subnet" + buttonTo={pb.externalSubnetsNew(useProjectSelector())} + /> +) + +const subnetList = (project: string) => + getListQFn(api.externalSubnetList, { query: { project } }) +const instanceList = (project: string) => + getListQFn(api.instanceList, { query: { project, limit: ALL_ISH } }) + +export const handle = makeCrumb('External Subnets', (p) => + pb.externalSubnets(getProjectSelector(p)) +) + +export async function clientLoader({ params }: LoaderFunctionArgs) { + const { project } = getProjectSelector(params) + await Promise.all([ + queryClient.fetchQuery(subnetList(project).optionsFn()), + queryClient.fetchQuery(instanceList(project).optionsFn()), + ]) + return null +} + +const colHelper = createColumnHelper() +const staticCols = [ + colHelper.accessor('name', {}), + colHelper.accessor('description', Columns.description), + colHelper.accessor('subnet', { + header: 'Subnet', + cell: (info) => {info.getValue()}, + }), + colHelper.accessor('subnetPoolId', { + header: 'Subnet pool', + cell: (info) => , + }), + colHelper.accessor('instanceId', { + header: 'Instance', + cell: (info) => , + }), +] + +export default function ExternalSubnetsPage() { + const [subnetToAttach, setSubnetToAttach] = useState(null) + const { project } = useProjectSelector() + const { data: instances } = usePrefetchedQuery(instanceList(project).optionsFn()) + const navigate = useNavigate() + + const { mutateAsync: externalSubnetDetach } = useApiMutation(api.externalSubnetDetach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} detached) + }, + }) + const { mutateAsync: deleteExternalSubnet } = useApiMutation(api.externalSubnetDelete, { + onSuccess(_data, variables) { + queryClient.invalidateEndpoint('externalSubnetList') + // prettier-ignore + addToast(<>External subnet {variables.path.externalSubnet} deleted) + }, + }) + + const makeActions = useCallback( + (subnet: ExternalSubnet): MenuAction[] => { + const instanceName = subnet.instanceId + ? instances.items.find((i) => i.id === subnet.instanceId)?.name + : undefined + const fromInstance = instanceName ? ( + <> + {' '} + from instance {instanceName} + + ) : null + + const isAttached = !!subnet.instanceId + const attachOrDetachAction = isAttached + ? { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + externalSubnetDetach({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + modalTitle: 'Detach External Subnet', + modalContent: ( +

+ Are you sure you want to detach external subnet {subnet.name} + {fromInstance}? +

+ ), + errorTitle: 'Error detaching external subnet', + }), + } + : { + label: 'Attach', + onActivate() { + setSubnetToAttach(subnet) + }, + } + return [ + { + label: 'Edit', + onActivate: () => { + // Seed cache so edit form can show data immediately + const { queryKey } = q(api.externalSubnetView, { + path: { externalSubnet: subnet.name }, + query: { project }, + }) + queryClient.setQueryData(queryKey, subnet) + navigate(pb.externalSubnetEdit({ project, externalSubnet: subnet.name })) + }, + }, + attachOrDetachAction, + { + label: 'Delete', + disabled: isAttached + ? 'This external subnet must be detached from the instance before it can be deleted' + : false, + onActivate: confirmDelete({ + doDelete: () => + deleteExternalSubnet({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + label: subnet.name, + }), + }, + ] + }, + [deleteExternalSubnet, externalSubnetDetach, navigate, project, instances] + ) + + const columns = useColsWithActions(staticCols, makeActions) + const { table } = useQueryTable({ + query: subnetList(project), + columns, + emptyState: , + }) + + const { data: allSubnets } = useQuery( + q(api.externalSubnetList, { query: { project, limit: ALL_ISH } }) + ) + + useQuickActions( + () => [ + { + value: 'New external subnet', + navGroup: 'Actions', + action: pb.externalSubnetsNew({ project }), + }, + ...(allSubnets?.items || []).map((s) => ({ + value: s.name, + action: pb.externalSubnetEdit({ project, externalSubnet: s.name }), + navGroup: 'Edit external subnet', + })), + ], + [project, allSubnets] + ) + + return ( + <> + + }>External Subnets + } + summary="External subnets provide a range of IP addresses from a subnet pool that can be attached to instances." + links={[docLinks.externalSubnets]} + /> + + + New External Subnet + + {table} + + {subnetToAttach && ( + setSubnetToAttach(null)} + /> + )} + + ) +} + +const AttachExternalSubnetModal = ({ + subnet, + instances, + project, + onDismiss, +}: { + subnet: ExternalSubnet + instances: Array + project: string + onDismiss: () => void +}) => { + const externalSubnetAttach = useApiMutation(api.externalSubnetAttach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: { instanceId: '' } }) + + return ( + { + externalSubnetAttach.mutate({ + path: { externalSubnet: subnet.name }, + query: { project }, + body: { instance: instanceId }, + }) + }} + submitLabel="Attach" + submitError={externalSubnetAttach.error} + loading={externalSubnetAttach.isPending} + onDismiss={onDismiss} + > + + The selected instance will be reachable at subnet {subnet.subnet} + + } + /> + ({ value: i.id, label: i.name }))} + label="Instance" + required + placeholder="Select an instance" + /> + + ) +} diff --git a/app/pages/project/instances/NetworkingTab.tsx b/app/pages/project/instances/NetworkingTab.tsx index 877c79ac9..960bd29aa 100644 --- a/app/pages/project/instances/NetworkingTab.tsx +++ b/app/pages/project/instances/NetworkingTab.tsx @@ -8,6 +8,7 @@ import { useQuery } from '@tanstack/react-query' import { createColumnHelper, getCoreRowModel, useReactTable } from '@tanstack/react-table' import { useCallback, useMemo, useState } from 'react' +import { useForm } from 'react-hook-form' import { type LoaderFunctionArgs } from 'react-router' import { match } from 'ts-pattern' @@ -21,6 +22,7 @@ import { useApiMutation, usePrefetchedQuery, type ExternalIp, + type ExternalSubnet, type InstanceNetworkInterface, type InstanceState, type IpVersion, @@ -31,6 +33,8 @@ import { Badge } from '@oxide/design-system/ui' import { AttachEphemeralIpModal } from '~/components/AttachEphemeralIpModal' import { AttachFloatingIpModal } from '~/components/AttachFloatingIpModal' import { orderIps } from '~/components/ExternalIps' +import { ListboxField } from '~/components/form/fields/ListboxField' +import { ModalForm } from '~/components/form/ModalForm' import { HL } from '~/components/HL' import { IpVersionBadge } from '~/components/IpVersionBadge' import { ListPlusCell } from '~/components/ListPlusCell' @@ -113,6 +117,13 @@ const PrivateIpCell = ({ ipVersion, ip }: { ipVersion: IpVersion; ip: string }) ) +const subnetColHelper = createColumnHelper() +const staticSubnetCols = [ + subnetColHelper.accessor('name', {}), + subnetColHelper.accessor('subnet', { header: 'Subnet' }), + subnetColHelper.accessor('description', Columns.description), +] + export async function clientLoader({ params }: LoaderFunctionArgs) { const { project, instance } = getInstanceSelector(params) await Promise.all([ @@ -123,6 +134,15 @@ export async function clientLoader({ params }: LoaderFunctionArgs) { }) ), queryClient.fetchQuery(q(api.floatingIpList, { query: { project, limit: ALL_ISH } })), + queryClient.fetchQuery( + q(api.externalSubnetList, { query: { project, limit: ALL_ISH } }) + ), + queryClient.fetchQuery( + q(api.instanceExternalSubnetList, { + path: { instance }, + query: { project }, + }) + ), // dupe of page-level fetch but that's fine, RQ dedupes queryClient.fetchQuery( q(api.instanceExternalIpList, { path: { instance }, query: { project } }) @@ -302,12 +322,25 @@ export default function NetworkingTab() { const [editing, setEditing] = useState(null) const [attachEphemeralModalOpen, setAttachEphemeralModalOpen] = useState(false) const [attachFloatingModalOpen, setAttachFloatingModalOpen] = useState(false) + const [attachSubnetModalOpen, setAttachSubnetModalOpen] = useState(false) // Fetch the floating IPs to show in the "Attach floating IP" modal const { data: ips } = usePrefetchedQuery( q(api.floatingIpList, { query: { project, limit: ALL_ISH } }) ) + // Fetch external subnets for this project and this instance + const { data: allSubnets } = usePrefetchedQuery( + q(api.externalSubnetList, { query: { project, limit: ALL_ISH } }) + ) + const { data: instanceSubnets } = usePrefetchedQuery( + q(api.instanceExternalSubnetList, { + path: { instance: instanceName }, + query: { project }, + }) + ) + const availableSubnets = allSubnets.items.filter((s) => !s.instanceId) + const nics = usePrefetchedQuery( q(api.instanceNetworkInterfaceList, { query: { ...instanceSelector, limit: ALL_ISH }, @@ -463,6 +496,48 @@ export default function NetworkingTab() { }, }) + const { mutateAsync: externalSubnetDetach } = useApiMutation(api.externalSubnetDetach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} detached) + }, + }) + + const makeSubnetActions = useCallback( + (subnet: ExternalSubnet): MenuAction[] => [ + { + label: 'Detach', + onActivate: () => + confirmAction({ + actionType: 'danger', + doAction: () => + externalSubnetDetach({ + path: { externalSubnet: subnet.name }, + query: { project }, + }), + modalTitle: 'Detach External Subnet', + modalContent: ( +

+ Are you sure you want to detach external subnet {subnet.name} from{' '} + {instanceName}? +

+ ), + errorTitle: 'Error detaching external subnet', + }), + }, + ], + [externalSubnetDetach, instanceName, project] + ) + + const subnetTableCols = useColsWithActions(staticSubnetCols, makeSubnetActions) + const subnetTableInstance = useReactTable({ + columns: subnetTableCols, + data: instanceSubnets.items, + getCoreRowModel: getCoreRowModel(), + }) + const makeIpActions = useCallback( (externalIp: ExternalIp): MenuAction[] => { const copyAction = { @@ -558,6 +633,9 @@ export default function NetworkingTab() { ? 'No available floating IPs' : null + const subnetDisabledReason = + availableSubnets.length === 0 ? 'No available external subnets' : null + return (
@@ -664,6 +742,100 @@ export default function NetworkingTab() { setEditing(null)} /> )} + + + + + + + + {instanceSubnets.items.length > 0 ? ( + + ) : ( + + } + title="No external subnets" + body="Attach an external subnet to see it here" + /> + + )} + + + {attachSubnetModalOpen && ( + setAttachSubnetModalOpen(false)} + /> + )} + ) } + +const AttachExternalSubnetModal = ({ + subnets, + instanceId, + project, + onDismiss, +}: { + subnets: ExternalSubnet[] + instanceId: string + project: string + onDismiss: () => void +}) => { + const externalSubnetAttach = useApiMutation(api.externalSubnetAttach, { + onSuccess(subnet) { + queryClient.invalidateEndpoint('externalSubnetList') + queryClient.invalidateEndpoint('instanceExternalSubnetList') + // prettier-ignore + addToast(<>External subnet {subnet.name} attached) + onDismiss() + }, + }) + + const form = useForm({ defaultValues: { subnetName: '' } }) + + return ( + { + externalSubnetAttach.mutate({ + path: { externalSubnet: subnetName }, + query: { project }, + body: { instance: instanceId }, + }) + }} + submitLabel="Attach" + submitError={externalSubnetAttach.error} + loading={externalSubnetAttach.isPending} + onDismiss={onDismiss} + > + ({ + value: s.name, + label: `${s.name} (${s.subnet})`, + }))} + label="External subnet" + required + placeholder="Select an external subnet" + /> + + ) +} diff --git a/app/routes.tsx b/app/routes.tsx index 03da82fa4..8795c29f5 100644 --- a/app/routes.tsx +++ b/app/routes.tsx @@ -483,6 +483,21 @@ export const routes = createRoutesFromElements( + + import('./pages/project/external-subnets/ExternalSubnetsPage').then(convert) + } + > + + import('./forms/external-subnet-create').then(convert)} + /> + import('./forms/external-subnet-edit').then(convert)} + /> + import('./pages/project/floating-ips/FloatingIpsPage').then(convert) diff --git a/app/table/cells/SubnetPoolCell.tsx b/app/table/cells/SubnetPoolCell.tsx new file mode 100644 index 000000000..bd53882fb --- /dev/null +++ b/app/table/cells/SubnetPoolCell.tsx @@ -0,0 +1,38 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ +import { useQuery } from '@tanstack/react-query' + +import { api, qErrorsAllowed } from '~/api' +import { Tooltip } from '~/ui/lib/Tooltip' + +import { EmptyCell, SkeletonCell } from './EmptyCell' + +export const SubnetPoolCell = ({ subnetPoolId }: { subnetPoolId: string }) => { + const { data: result } = useQuery( + qErrorsAllowed( + api.subnetPoolView, + { path: { pool: subnetPoolId } }, + { + errorsExpected: { + explanation: 'the referenced subnet pool may have been deleted.', + statusCode: 404, + }, + } + ) + ) + if (!result) return + // Defensive: the error case should never happen in practice. It should not be + // possible for a resource to reference a pool without that pool existing. + if (result.type === 'error') return + const pool = result.data + return ( + + {pool.name} + + ) +} diff --git a/app/util/__snapshots__/path-builder.spec.ts.snap b/app/util/__snapshots__/path-builder.spec.ts.snap index a2c90e443..62c7e050b 100644 --- a/app/util/__snapshots__/path-builder.spec.ts.snap +++ b/app/util/__snapshots__/path-builder.spec.ts.snap @@ -129,6 +129,48 @@ exports[`breadcrumbs 2`] = ` "path": "/projects/p/disks", }, ], + "externalSubnetEdit (/projects/p/external-subnets/es/edit)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], + "externalSubnets (/projects/p/external-subnets)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], + "externalSubnetsNew (/projects/p/external-subnets-new)": [ + { + "label": "Projects", + "path": "/projects", + }, + { + "label": "p", + "path": "/projects/p/instances", + }, + { + "label": "External Subnets", + "path": "/projects/p/external-subnets", + }, + ], "fleetAccess (/system/access)": [ { "label": "Fleet Access", diff --git a/app/util/links.ts b/app/util/links.ts index b1aad7cd0..b919ed509 100644 --- a/app/util/links.ts +++ b/app/util/links.ts @@ -44,6 +44,10 @@ export const docLinks = { href: 'https://docs.oxide.computer/guides/working-with-api-and-sdk#_device_token_setup', linkText: 'Access Tokens', }, + externalSubnets: { + href: 'https://docs.oxide.computer/guides/operator/ip-pool-management#_using_subnet_pools', + linkText: 'Subnet Pools', + }, disks: { href: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots', linkText: 'Disks and Snapshots', diff --git a/app/util/path-builder.spec.ts b/app/util/path-builder.spec.ts index b55e30828..c3538b66b 100644 --- a/app/util/path-builder.spec.ts +++ b/app/util/path-builder.spec.ts @@ -18,6 +18,7 @@ import { pb } from './path-builder' const params = { affinityGroup: 'ag', antiAffinityGroup: 'aag', + externalSubnet: 'es', floatingIp: 'f', gateway: 'g', project: 'p', @@ -52,6 +53,9 @@ test('path builder', () => { "diskInventory": "/system/inventory/disks", "disks": "/projects/p/disks", "disksNew": "/projects/p/disks-new", + "externalSubnetEdit": "/projects/p/external-subnets/es/edit", + "externalSubnets": "/projects/p/external-subnets", + "externalSubnetsNew": "/projects/p/external-subnets-new", "fleetAccess": "/system/access", "floatingIpEdit": "/projects/p/floating-ips/f/edit", "floatingIps": "/projects/p/floating-ips", diff --git a/app/util/path-builder.ts b/app/util/path-builder.ts index b7470e845..07c587743 100644 --- a/app/util/path-builder.ts +++ b/app/util/path-builder.ts @@ -93,6 +93,11 @@ export const pb = { `${pb.vpcInternetGateways(params)}/${params.gateway}`, // vpcInternetGatewaysNew: (params: Vpc) => `${vpcBase(params)}/internet-gateways-new`, // + externalSubnets: (params: PP.Project) => `${projectBase(params)}/external-subnets`, + externalSubnetsNew: (params: PP.Project) => `${projectBase(params)}/external-subnets-new`, + externalSubnetEdit: (params: PP.ExternalSubnet) => + `${pb.externalSubnets(params)}/${params.externalSubnet}/edit`, + floatingIps: (params: PP.Project) => `${projectBase(params)}/floating-ips`, floatingIpsNew: (params: PP.Project) => `${projectBase(params)}/floating-ips-new`, floatingIpEdit: (params: PP.FloatingIp) => diff --git a/app/util/path-params.ts b/app/util/path-params.ts index ee4549a86..ed103c65d 100644 --- a/app/util/path-params.ts +++ b/app/util/path-params.ts @@ -19,6 +19,7 @@ export type Image = Required export type Snapshot = Required export type SiloImage = Required export type IpPool = Required +export type ExternalSubnet = Required export type FloatingIp = Required export type FirewallRule = Required export type VpcRouter = Required diff --git a/mock-api/external-subnet.ts b/mock-api/external-subnet.ts new file mode 100644 index 000000000..ea519a200 --- /dev/null +++ b/mock-api/external-subnet.ts @@ -0,0 +1,55 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { ExternalSubnet } from '@oxide/api' + +import { instance } from './instance' +import type { Json } from './json-type' +import { project } from './project' +import { subnetPool1, subnetPoolMember1 } from './subnet-pool' + +export const externalSubnet1: Json = { + id: 'f3a0768c-5334-486c-a99b-1d564bb910b7', + name: 'web-subnet', + description: 'Subnet for web services', + project_id: project.id, + instance_id: undefined, + subnet: '10.128.1.0/24', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnet2: Json = { + id: '4f395c54-2dca-4c7f-a52e-a1bb86e7acbd', + name: 'db-subnet', + description: 'Subnet for database tier', + project_id: project.id, + instance_id: instance.id, + subnet: '10.128.2.0/24', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnet3: Json = { + id: '8b406f20-fe14-40e8-8d21-3ebc0efd15b6', + name: 'staging-subnet', + description: 'Staging environment subnet', + project_id: project.id, + instance_id: undefined, + subnet: '10.128.3.0/28', + subnet_pool_id: subnetPool1.id, + subnet_pool_member_id: subnetPoolMember1.id, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const externalSubnets = [externalSubnet1, externalSubnet2, externalSubnet3] diff --git a/mock-api/index.ts b/mock-api/index.ts index b63cbf565..3620d30c2 100644 --- a/mock-api/index.ts +++ b/mock-api/index.ts @@ -9,6 +9,7 @@ export * from './affinity-group' export * from './disk' export * from './external-ip' +export * from './external-subnet' export * from './floating-ip' export * from './image' export * from './instance' @@ -22,6 +23,7 @@ export * from './role-assignment' export * from './silo' export * from './sled' export * from './snapshot' +export * from './subnet-pool' export * from './sshKeys' export * from './switch' export * from './system-update' diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index e8679f897..f7f378165 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -220,6 +220,24 @@ export const lookup = { return disk }, + externalSubnet({ + externalSubnet: id, + ...projectSelector + }: Sel.ExternalSubnet): Json { + if (!id) throw notFoundErr('no external subnet specified') + + if (isUuid(id)) { + ensureNoParentSelectors('external subnet', projectSelector) + return lookupById(db.externalSubnets, id) + } + + const project = lookup.project(projectSelector) + const externalSubnet = db.externalSubnets.find( + (s) => s.project_id === project.id && s.name === id + ) + if (!externalSubnet) throw notFoundErr(`external subnet '${id}'`) + return externalSubnet + }, floatingIp({ floatingIp: id, ...projectSelector }: Sel.FloatingIp): Json { if (!id) throw notFoundErr('no floating IP specified') @@ -384,6 +402,18 @@ export const lookup = { if (!image) throw notFoundErr(`image '${id}'`) return image }, + subnetPool({ subnetPool: id }: Sel.SubnetPool): Json { + if (!id) throw notFoundErr('no subnet pool specified') + if (isUuid(id)) return lookupById(db.subnetPools, id) + const pool = db.subnetPools.find((p) => p.name === id) + if (!pool) throw notFoundErr(`subnet pool '${id}'`) + return pool + }, + defaultSubnetPool(): Json { + const pool = db.subnetPools.find((p) => p.is_default) + if (!pool) throw notFoundErr('no default subnet pool configured') + return pool + }, ipPool({ pool: id }: Sel.IpPool): Json { if (!id) throw notFoundErr('no pool specified') @@ -537,6 +567,9 @@ const initDb = { deviceTokens: [...mock.deviceTokens], disks: [...mock.disks], diskBulkImportState: new Map(), + externalSubnets: [...mock.externalSubnets], + subnetPools: [...mock.subnetPools], + subnetPoolMembers: [...mock.subnetPoolMembers], floatingIps: [...mock.floatingIps], userGroups: [...mock.userGroups], /** Join table for `users` and `userGroups` */ diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index cc564ee2f..212f3567d 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -284,6 +284,94 @@ export const handlers = makeHandlers({ return 204 }, + externalSubnetList({ query }) { + const project = lookup.project(query) + const subnets = db.externalSubnets.filter((s) => s.project_id === project.id) + return paginated(query, subnets) + }, + externalSubnetCreate({ body, query }) { + const project = lookup.project(query) + errIfExists(db.externalSubnets, { name: body.name, project_id: project.id }) + + const { pool, subnet } = match(body.allocator) + .with({ type: 'explicit' }, (a) => ({ + // Real API infers pool from the subnet; mock just uses default + pool: lookup.defaultSubnetPool(), + subnet: a.subnet, + })) + .with({ type: 'auto' }, (a) => { + const pool = match(a.pool_selector) + .with({ type: 'explicit' }, (ps) => lookup.subnetPool({ subnetPool: ps.pool })) + .with({ type: 'auto' }, () => lookup.defaultSubnetPool()) + .with(undefined, () => lookup.defaultSubnetPool()) + .exhaustive() + const idx = db.externalSubnets.length + 1 + return { pool, subnet: `10.128.${idx}.0/${a.prefix_len}` } + }) + .exhaustive() + + // Use first matching member; real API picks based on CIDR availability + const member = db.subnetPoolMembers.find((m) => m.subnet_pool_id === pool.id) + if (!member) throw notFoundErr(`subnet pool member for pool '${pool.id}'`) + + const newSubnet: Json = { + id: uuid(), + project_id: project.id, + instance_id: undefined, + name: body.name, + description: body.description, + subnet, + subnet_pool_id: pool.id, + subnet_pool_member_id: member.id, + ...getTimestamps(), + } + db.externalSubnets.push(newSubnet) + return json(newSubnet, { status: 201 }) + }, + externalSubnetView: ({ path, query }) => lookup.externalSubnet({ ...path, ...query }), + externalSubnetUpdate: ({ path, query, body }) => { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + if (body.name) { + if (body.name !== externalSubnet.name) { + errIfExists(db.externalSubnets, { + name: body.name, + project_id: externalSubnet.project_id, + }) + } + externalSubnet.name = body.name + } + updateDesc(externalSubnet, body) + return externalSubnet + }, + externalSubnetDelete({ path, query }) { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + if (externalSubnet.instance_id) { + throw invalidRequest( + 'external subnet cannot be deleted while attached to an instance' + ) + } + db.externalSubnets = db.externalSubnets.filter((s) => s.id !== externalSubnet.id) + return 204 + }, + externalSubnetAttach({ path: { externalSubnet }, query: { project }, body }) { + const dbSubnet = lookup.externalSubnet({ externalSubnet, project }) + if (dbSubnet.instance_id) { + throw invalidRequest( + 'external subnet cannot be attached to one instance while still attached to another' + ) + } + const dbInstance = lookup.instance({ + instance: body.instance, + project: isUuid(body.instance) ? undefined : project, + }) + dbSubnet.instance_id = dbInstance.id + return dbSubnet + }, + externalSubnetDetach({ path, query }) { + const externalSubnet = lookup.externalSubnet({ ...path, ...query }) + externalSubnet.instance_id = undefined + return externalSubnet + }, floatingIpCreate({ body, query }) { const project = lookup.project(query) errIfExists(db.floatingIps, { name: body.name, project_id: project.id }) @@ -962,6 +1050,11 @@ export const handlers = makeHandlers({ // endpoint is not paginated. or rather, it's fake paginated return { items: [...ephemeralIps, ...snatIps, ...floatingIps] } }, + instanceExternalSubnetList({ path, query }) { + const instance = lookup.instance({ ...path, ...query }) + const items = db.externalSubnets.filter((s) => s.instance_id === instance.id) + return { items, next_page: null } + }, instanceNetworkInterfaceList({ query }) { const instance = lookup.instance(query) const nics = db.networkInterfaces.filter((n) => n.instance_id === instance.id) @@ -2229,16 +2322,10 @@ export const handlers = makeHandlers({ certificateDelete: NotImplemented, certificateList: NotImplemented, certificateView: NotImplemented, - subnetPoolList: NotImplemented, - subnetPoolView: NotImplemented, - externalSubnetList: NotImplemented, - externalSubnetCreate: NotImplemented, - externalSubnetView: NotImplemented, - externalSubnetUpdate: NotImplemented, - externalSubnetDelete: NotImplemented, - externalSubnetAttach: NotImplemented, - externalSubnetDetach: NotImplemented, - instanceExternalSubnetList: NotImplemented, + subnetPoolList({ query }) { + return paginated(query, db.subnetPools) + }, + subnetPoolView: ({ path }) => lookup.subnetPool({ subnetPool: path.pool }), instanceMulticastGroupJoin: NotImplemented, instanceMulticastGroupLeave: NotImplemented, instanceMulticastGroupList: NotImplemented, diff --git a/mock-api/subnet-pool.ts b/mock-api/subnet-pool.ts new file mode 100644 index 000000000..98803a779 --- /dev/null +++ b/mock-api/subnet-pool.ts @@ -0,0 +1,77 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import type { SiloSubnetPool, SubnetPoolMember } from '@oxide/api' + +import type { Json } from './json-type' + +// Minimal subnet pool seed data for external subnet allocation. +// There's no UI for subnet pools themselves yet, but external subnets +// reference them. + +export const subnetPool1: Json = { + id: '41e54fcd-c45b-43ed-90fb-4b9faf24e167', + name: 'default-v4-subnet-pool', + description: 'Default IPv4 subnet pool', + ip_version: 'v4', + is_default: true, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const subnetPool2: Json = { + id: '9283ee19-7277-4946-918a-6748de31386c', + name: 'secondary-v4-subnet-pool', + description: 'Secondary IPv4 subnet pool', + ip_version: 'v4', + is_default: false, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const subnetPool3: Json = { + id: 'c7bdc035-dfe2-40d0-9794-76f78b4898c2', + name: 'ipv6-subnet-pool', + description: 'IPv6 subnet pool', + ip_version: 'v6', + is_default: false, + time_created: new Date().toISOString(), + time_modified: new Date().toISOString(), +} + +export const subnetPools = [subnetPool1, subnetPool2, subnetPool3] + +export const subnetPoolMember1: Json = { + id: '0466eafd-2922-4360-a0ee-e4c99b370c04', + subnet_pool_id: subnetPool1.id, + // Range that external subnets are allocated from + subnet: '10.128.0.0/16', + min_prefix_length: 20, + max_prefix_length: 28, + time_created: new Date().toISOString(), +} + +export const subnetPoolMember2: Json = { + id: '814310a1-d3fb-4a4a-9dd4-9300727ba3e6', + subnet_pool_id: subnetPool2.id, + subnet: '172.20.0.0/16', + min_prefix_length: 20, + max_prefix_length: 28, + time_created: new Date().toISOString(), +} + +export const subnetPoolMember3: Json = { + id: 'b60cb98d-0f52-4367-834a-9ca37edce66d', + subnet_pool_id: subnetPool3.id, + subnet: 'fd00:1000::/32', + min_prefix_length: 48, + max_prefix_length: 64, + time_created: new Date().toISOString(), +} + +export const subnetPoolMembers = [subnetPoolMember1, subnetPoolMember2, subnetPoolMember3] diff --git a/test/e2e/disks.e2e.ts b/test/e2e/disks.e2e.ts index 4353845b3..30a19fae6 100644 --- a/test/e2e/disks.e2e.ts +++ b/test/e2e/disks.e2e.ts @@ -56,13 +56,13 @@ test('List disks and snapshot', async ({ page }) => { // check one attached and one not attached await expectRowVisible(table, { - 'Attached to': 'db1', + Instance: 'db1', name: 'disk-1', size: '2 GiB', state: 'attached', }) await expectRowVisible(table, { - 'Attached to': '—', + Instance: '—', name: 'disk-3', size: '6 GiB', state: 'detached', diff --git a/test/e2e/external-subnets.e2e.ts b/test/e2e/external-subnets.e2e.ts new file mode 100644 index 000000000..3aa1133d6 --- /dev/null +++ b/test/e2e/external-subnets.e2e.ts @@ -0,0 +1,408 @@ +/* + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, you can obtain one at https://mozilla.org/MPL/2.0/. + * + * Copyright Oxide Computer Company + */ + +import { + clickRowAction, + closeToast, + expect, + expectRowVisible, + expectToast, + selectOption, + test, +} from './utils' + +const externalSubnetsPage = '/projects/mock-project/external-subnets' + +test('can navigate to external subnets via sidebar', async ({ page }) => { + await page.goto('/projects/mock-project/instances') + await page.getByRole('link', { name: 'External Subnets' }).click() + await expect(page).toHaveURL(externalSubnetsPage) + await expect(page.getByRole('heading', { name: 'External Subnets' })).toBeVisible() +}) + +test('displays seeded external subnets in table', async ({ page }) => { + await page.goto(externalSubnetsPage) + + const table = page.getByRole('table') + await expectRowVisible(table, { + name: 'web-subnet', + description: 'Subnet for web services', + Subnet: '10.128.1.0/24', + }) + await expectRowVisible(table, { + name: 'db-subnet', + description: 'Subnet for database tier', + Subnet: '10.128.2.0/24', + Instance: 'db1', + }) + await expectRowVisible(table, { + name: 'staging-subnet', + description: 'Staging environment subnet', + Subnet: '10.128.3.0/28', + }) +}) + +test('can create an external subnet with auto allocation', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'New External Subnet' }).click() + + await expect(page.getByRole('heading', { name: /Create external subnet/ })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Description' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Create external subnet' })).toBeVisible() + + // Auto should be selected by default + await expect(page.getByRole('radio', { name: 'Auto' })).toBeChecked() + + await page.getByRole('textbox', { name: 'Name' }).fill('my-new-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('A test subnet') + + await page.getByRole('button', { name: 'Create external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet my-new-subnet created') + + await expectRowVisible(page.getByRole('table'), { + name: 'my-new-subnet', + description: 'A test subnet', + }) +}) + +test('can create an external subnet with non-default pool', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'New External Subnet' }).click() + + await page.getByRole('textbox', { name: 'Name' }).fill('alt-pool-subnet') + + // Default pool should be preselected + await expect(page.getByRole('button', { name: 'Subnet pool' })).toContainText( + 'default-v4-subnet-pool' + ) + + // Switch to the secondary pool + await selectOption( + page, + 'Subnet pool', + page.getByRole('option', { name: 'secondary-v4-subnet-pool' }) + ) + + await page.getByRole('button', { name: 'Create external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet alt-pool-subnet created') + + await expectRowVisible(page.getByRole('table'), { + name: 'alt-pool-subnet', + }) +}) + +test('can create an external subnet with explicit CIDR', async ({ page }) => { + await page.goto(externalSubnetsPage) + await page.getByRole('link', { name: 'New External Subnet' }).click() + + await page.getByRole('textbox', { name: 'Name' }).fill('explicit-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('Explicit CIDR subnet') + + // Switch to explicit mode + await page.getByRole('radio', { name: 'Explicit' }).click() + + // Prefix length and pool fields should be hidden, subnet CIDR should appear + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeHidden() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeVisible() + + await page.getByRole('textbox', { name: 'Subnet CIDR' }).fill('10.128.5.0/24') + + await page.getByRole('button', { name: 'Create external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet explicit-subnet created') + + await expectRowVisible(page.getByRole('table'), { + name: 'explicit-subnet', + Subnet: '10.128.5.0/24', + }) +}) + +test('can update an external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + await clickRowAction(page, 'web-subnet', 'Edit') + + await expect(page.getByRole('heading', { name: /Edit external subnet/ })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Name' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Description' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Update external subnet' })).toBeVisible() + + // Read-only properties should be visible in the side modal + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('10.128.1.0/24')).toBeVisible() + await expect(dialog.getByText('default-v4-subnet-pool')).toBeVisible() + + await page.getByRole('textbox', { name: 'Name' }).fill('renamed-subnet') + await page.getByRole('textbox', { name: 'Description' }).fill('Updated description') + await page.getByRole('button', { name: 'Update external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet renamed-subnet updated') + + await expectRowVisible(page.getByRole('table'), { + name: 'renamed-subnet', + description: 'Updated description', + }) +}) + +test('can update just the description', async ({ page }) => { + await page.goto(`${externalSubnetsPage}/web-subnet/edit`) + + await page.getByRole('textbox', { name: 'Description' }).fill('New description only') + await page.getByRole('button', { name: 'Update external subnet' }).click() + + await expect(page).toHaveURL(externalSubnetsPage) + await expectToast(page, 'External subnet web-subnet updated') + + await expectRowVisible(page.getByRole('table'), { + name: 'web-subnet', + description: 'New description only', + }) +}) + +test('can delete an unattached external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + + await clickRowAction(page, 'web-subnet', 'Delete') + await expect(page.getByText('Are you sure you want to delete web-subnet?')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, 'External subnet web-subnet deleted') + + await expect(page.getByRole('cell', { name: 'web-subnet' })).toBeHidden() +}) + +test('cannot delete an attached external subnet', async ({ page }) => { + await page.goto(externalSubnetsPage) + + // db-subnet is attached to db1, so delete should be disabled + const actionsButton = page + .getByRole('row', { name: 'db-subnet' }) + .getByRole('button', { name: 'Row actions' }) + await actionsButton.click() + + const deleteButton = page.getByRole('menuitem', { name: 'Delete' }) + await expect(deleteButton).toBeDisabled() + await deleteButton.hover() + await expect(page.getByText('must be detached')).toBeVisible() +}) + +test('can detach and reattach an external subnet from the list page', async ({ page }) => { + await page.goto(externalSubnetsPage) + + // db-subnet is attached to db1 + await expectRowVisible(page.getByRole('table'), { + name: 'db-subnet', + Instance: 'db1', + }) + + // Detach it + await clickRowAction(page, 'db-subnet', 'Detach') + await expect(page.getByText('Are you sure you want to detach')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + await expectToast(page, 'External subnet db-subnet detached') + + // After detaching, the instance cell should no longer show db1 + await expect(page.getByRole('dialog')).toBeHidden() + + // Now reattach it via the Attach action + await clickRowAction(page, 'db-subnet', 'Attach') + await expect(page.getByRole('heading', { name: 'Attach external subnet' })).toBeVisible() + await page.getByLabel('Instance').click() + await page.getByRole('option', { name: 'db1' }).click() + await page.getByRole('button', { name: 'Attach' }).click() + + await expect(page.getByRole('dialog')).toBeHidden() + await expectToast(page, 'External subnet db-subnet attached') + + await expectRowVisible(page.getByRole('table'), { + name: 'db-subnet', + Instance: 'db1', + }) +}) + +test('Instance networking tab — shows attached external subnets', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { + name: 'db-subnet', + Subnet: '10.128.2.0/24', + }) + + // Unattached subnets should not appear + await expect(subnetTable.getByRole('cell', { name: 'web-subnet' })).toBeHidden() + await expect(subnetTable.getByRole('cell', { name: 'staging-subnet' })).toBeHidden() +}) + +test('Instance networking tab — detach external subnet', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { name: 'db-subnet' }) + + await clickRowAction(page, 'db-subnet', 'Detach') + await expect(page.getByText('Are you sure you want to detach')).toBeVisible() + await page.getByRole('button', { name: 'Confirm' }).click() + + await expectToast(page, 'External subnet db-subnet detached') + + await expect(subnetTable.getByRole('cell', { name: 'db-subnet' })).toBeHidden() +}) + +test('Instance networking tab — attach external subnet', async ({ page }) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + const attachButton = page.getByRole('button', { name: 'Attach external subnet' }) + await expect(attachButton).toBeEnabled() + + await attachButton.click() + await expect(page.getByRole('heading', { name: 'Attach external subnet' })).toBeVisible() + + // Select web-subnet (unattached) + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /web-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + + await expect(page.getByRole('dialog')).toBeHidden() + await expectToast(page, 'External subnet web-subnet attached') + + const subnetTable = page.getByRole('table', { name: 'External Subnets' }) + await expectRowVisible(subnetTable, { + name: 'web-subnet', + Subnet: '10.128.1.0/24', + }) +}) + +test('Instance networking tab — attach button disabled when no available subnets', async ({ + page, +}) => { + await page.goto('/projects/mock-project/instances/db1/networking') + + // Attach both unattached subnets to exhaust the pool + const attachButton = page.getByRole('button', { name: 'Attach external subnet' }) + + // Attach web-subnet + await attachButton.click() + const dialog = page.getByRole('dialog') + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /web-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(dialog).toBeHidden() + await closeToast(page) + + // Attach staging-subnet + await attachButton.click() + await dialog.getByRole('button', { name: 'External subnet', exact: true }).click() + await page.getByRole('option', { name: /staging-subnet/ }).click() + await dialog.getByRole('button', { name: 'Attach' }).click() + await expect(dialog).toBeHidden() + await closeToast(page) + + // Now all subnets are attached, so the button should be disabled + await expect(attachButton).toBeDisabled() +}) + +test('edit form shows resolved subnet pool and instance names', async ({ page }) => { + // db-subnet is attached to db1 + await page.goto(`${externalSubnetsPage}/db-subnet/edit`) + + await expect(page.getByRole('heading', { name: /Edit external subnet/i })).toBeVisible() + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('10.128.2.0/24')).toBeVisible() + await expect(dialog.getByText('default-v4-subnet-pool')).toBeVisible() + await expect(dialog.getByRole('link', { name: 'db1' })).toBeVisible() + + await expect(page.getByRole('textbox', { name: 'Name' })).toHaveValue('db-subnet') +}) + +test('edit form works via direct URL for unattached subnet', async ({ page }) => { + await page.goto(`${externalSubnetsPage}/web-subnet/edit`) + + const dialog = page.getByRole('dialog') + await expect(dialog.getByText('10.128.1.0/24')).toBeVisible() + await expect(dialog.getByText('default-v4-subnet-pool')).toBeVisible() + + await expect(page.getByRole('textbox', { name: 'Name' })).toHaveValue('web-subnet') +}) + +test('create form validates explicit subnet CIDR', async ({ page }) => { + await page.goto(`${externalSubnetsPage}-new`) + + await page.getByRole('textbox', { name: 'Name' }).fill('bad-cidr-subnet') + await page.getByRole('radio', { name: 'Explicit' }).click() + + const dialog = page.getByRole('dialog') + const subnetField = page.getByRole('textbox', { name: 'Subnet CIDR' }) + const submitButton = page.getByRole('button', { name: 'Create external subnet' }) + const cidrError = dialog.getByText( + 'Must contain an IP address and a width, separated by a /' + ) + + // Invalid CIDR shows error on submit + await subnetField.fill('not-a-cidr') + await submitButton.click() + await expect(cidrError).toBeVisible() + + // Valid CIDR clears the error and submits successfully + await subnetField.fill('10.128.6.0/24') + await submitButton.click() + await expect(cidrError).toBeHidden() + await expectToast(page, 'External subnet bad-cidr-subnet created') +}) + +test('create form prefix length max changes with pool IP version', async ({ page }) => { + await page.goto(`${externalSubnetsPage}-new`) + + await expect(page.getByText('Max is 32 for IPv4 pools, 128 for IPv6.')).toBeVisible() + + const prefixLen = page.getByRole('textbox', { name: 'Prefix length' }) + const v6Pool = page.getByRole('option', { name: 'ipv6-subnet-pool' }) + const v4Pool = page.getByRole('option', { name: 'default-v4-subnet-pool' }) + + // With v4 pool selected, typing 64 should be clamped to 32 + await prefixLen.fill('64') + await prefixLen.blur() + await expect(prefixLen).toHaveValue('32') + + // Switch to v6 pool — 64 should now be accepted + await selectOption(page, 'Subnet pool', v6Pool) + await prefixLen.fill('64') + await prefixLen.blur() + await expect(prefixLen).toHaveValue('64') + + // Switch back to v4 — value should clamp back to 32 + await selectOption(page, 'Subnet pool', v4Pool) + await expect(prefixLen).toHaveValue('32') +}) + +test('create form toggles between auto and explicit fields', async ({ page }) => { + await page.goto(`${externalSubnetsPage}-new`) + + // Auto mode: prefix length visible, subnet CIDR hidden + await expect(page.getByRole('radio', { name: 'Auto' })).toBeChecked() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeHidden() + + // Switch to explicit + await page.getByRole('radio', { name: 'Explicit' }).click() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeHidden() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeVisible() + + // Switch back to auto + await page.getByRole('radio', { name: 'Auto' }).click() + await expect(page.getByRole('textbox', { name: 'Prefix length' })).toBeVisible() + await expect(page.getByRole('textbox', { name: 'Subnet CIDR' })).toBeHidden() +})