Skip to content

Commit c0d92b9

Browse files
authored
External subnets (#3039)
Closes #3030 Closes #3031 Operator subnet pools UI is in #3146. <img width="1341" height="875" alt="image" src="https://github.com/user-attachments/assets/4bd0ed83-53f2-4476-99a2-78a28726c9e2" /> <img width="906" height="648" alt="image" src="https://github.com/user-attachments/assets/6b6cf51f-fc57-4e20-94ab-9cfea2f9d91b" /> <img width="506" height="615" alt="image" src="https://github.com/user-attachments/assets/8a5bf4e7-536b-4f69-b35b-6c90a78696e4" />
1 parent 34bbf64 commit c0d92b9

27 files changed

+1565
-22
lines changed

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
- Only implement what is necessary to exercise the UI; keep the db seeded via `mock-api/msw/db.ts`.
6565
- Store API response objects in the mock tables when possible so state persists across calls.
6666
- Enforce role checks with `requireFleetViewer`/`requireFleetCollab`/`requireFleetAdmin`, and return realistic errors (e.g. downgrade guard in `systemUpdateStatus`).
67+
- 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.
6768

6869
# Routing
6970

app/api/selectors.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ export type SystemUpdate = Readonly<{ version: string }>
3232
export type SshKey = Readonly<{ sshKey: string }>
3333
export type Sled = Readonly<{ sledId?: string }>
3434
export type IpPool = Readonly<{ pool?: string }>
35+
export type SubnetPool = Readonly<{ subnetPool?: string }>
36+
export type ExternalSubnet = Readonly<Merge<Project, { externalSubnet?: string }>>
3537
export type FloatingIp = Readonly<Merge<Project, { floatingIp?: string }>>
3638

3739
export type Id = Readonly<{ id: string }>

app/components/AttachEphemeralIpModal.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
} from '~/api'
2323
import { ListboxField } from '~/components/form/fields/ListboxField'
2424
import { HL } from '~/components/HL'
25-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
25+
import { toPoolItem } from '~/components/PoolListboxItem'
2626
import { useInstanceSelector } from '~/hooks/use-params'
2727
import { addToast } from '~/stores/toast'
2828
import { Message } from '~/ui/lib/Message'
@@ -90,7 +90,7 @@ export const AttachEphemeralIpModal = ({
9090
name="pool"
9191
label="Pool"
9292
control={form.control}
93-
items={sortPools(compatibleUnicastPools).map(toIpPoolItem)}
93+
items={sortPools(compatibleUnicastPools).map(toPoolItem)}
9494
disabled={compatibleUnicastPools.length === 0}
9595
placeholder="Select a pool"
9696
noItemsPlaceholder="No pools available"
Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,22 @@
66
* Copyright Oxide Computer Company
77
*/
88

9-
import type { SiloIpPool } from '@oxide/api'
9+
import type { IpVersion } from '@oxide/api'
1010
import { Badge } from '@oxide/design-system/ui'
1111

1212
import { IpVersionBadge } from '~/components/IpVersionBadge'
1313
import type { ListboxItem } from '~/ui/lib/Listbox'
1414

15-
/** Format a SiloIpPool for use as a ListboxField item */
16-
export function toIpPoolItem(p: SiloIpPool): ListboxItem {
15+
/** Common fields of SiloIpPool and SiloSubnetPool used for display */
16+
type PoolLike = {
17+
name: string
18+
isDefault: boolean
19+
ipVersion: IpVersion
20+
description: string
21+
}
22+
23+
/** Format a pool for use as a ListboxField item */
24+
export function toPoolItem(p: PoolLike): ListboxItem {
1725
const value = p.name
1826
const selectedLabel = p.name
1927
const label = (
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useForm } from 'react-hook-form'
9+
import { useNavigate } from 'react-router'
10+
import { match } from 'ts-pattern'
11+
12+
import { api, q, queryClient, useApiMutation, usePrefetchedQuery } from '@oxide/api'
13+
14+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
15+
import { ListboxField } from '~/components/form/fields/ListboxField'
16+
import { NameField } from '~/components/form/fields/NameField'
17+
import { NumberField } from '~/components/form/fields/NumberField'
18+
import { RadioField } from '~/components/form/fields/RadioField'
19+
import { TextField } from '~/components/form/fields/TextField'
20+
import { SideModalForm } from '~/components/form/SideModalForm'
21+
import { HL } from '~/components/HL'
22+
import { toPoolItem } from '~/components/PoolListboxItem'
23+
import { titleCrumb } from '~/hooks/use-crumbs'
24+
import { useProjectSelector } from '~/hooks/use-params'
25+
import { addToast } from '~/stores/toast'
26+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
27+
import { ALL_ISH } from '~/util/consts'
28+
import { validateIpNet } from '~/util/ip'
29+
import { docLinks } from '~/util/links'
30+
import { pb } from '~/util/path-builder'
31+
32+
const poolList = q(api.subnetPoolList, { query: { limit: ALL_ISH } })
33+
34+
export async function clientLoader() {
35+
await queryClient.prefetchQuery(poolList)
36+
return null
37+
}
38+
39+
export const handle = titleCrumb('New External Subnet')
40+
41+
type FormValues = {
42+
name: string
43+
description: string
44+
allocationType: 'auto' | 'explicit'
45+
prefixLen: number
46+
pool: string
47+
subnet: string
48+
}
49+
50+
const defaultFormValues: Omit<FormValues, 'pool'> = {
51+
name: '',
52+
description: '',
53+
allocationType: 'auto',
54+
prefixLen: 24,
55+
subnet: '',
56+
}
57+
58+
export default function CreateExternalSubnetSideModalForm() {
59+
const { data: pools } = usePrefetchedQuery(poolList)
60+
61+
const defaultPool = pools.items.find((p) => p.isDefault)
62+
63+
const projectSelector = useProjectSelector()
64+
const navigate = useNavigate()
65+
66+
const createExternalSubnet = useApiMutation(api.externalSubnetCreate, {
67+
onSuccess(subnet) {
68+
queryClient.invalidateEndpoint('externalSubnetList')
69+
// prettier-ignore
70+
addToast(<>External subnet <HL>{subnet.name}</HL> created</>)
71+
navigate(pb.externalSubnets(projectSelector))
72+
},
73+
})
74+
75+
const form = useForm({
76+
defaultValues: { ...defaultFormValues, pool: defaultPool?.name ?? '' },
77+
})
78+
79+
const allocationType = form.watch('allocationType')
80+
const selectedPoolName = form.watch('pool')
81+
const selectedPool = pools.items.find((p) => p.name === selectedPoolName)
82+
// In auto allocation, the requested prefix length is matched against pool
83+
// members whose min/max prefix range includes it, then a subnet is carved
84+
// out of the first member with a large enough gap. Reproducing this member
85+
// resolution logic is more or less impossible, so we just enforce a max by
86+
// IP version.
87+
// https://github.com/oxidecomputer/omicron/blob/e7d260a/nexus/db-queries/src/db/queries/external_subnet.rs#L906-L908
88+
const prefixLenMax = !selectedPool || selectedPool.ipVersion === 'v6' ? 128 : 32
89+
90+
return (
91+
<SideModalForm
92+
form={form}
93+
formType="create"
94+
resourceName="external subnet"
95+
onDismiss={() => navigate(pb.externalSubnets(projectSelector))}
96+
onSubmit={({ name, description, allocationType, prefixLen, pool, subnet }) => {
97+
const allocator = match(allocationType)
98+
.with('explicit', () => ({ type: 'explicit' as const, subnet }))
99+
.with('auto', () => ({
100+
type: 'auto' as const,
101+
prefixLen,
102+
poolSelector: { type: 'explicit' as const, pool },
103+
}))
104+
.exhaustive()
105+
createExternalSubnet.mutate({
106+
query: projectSelector,
107+
body: { name, description, allocator },
108+
})
109+
}}
110+
loading={createExternalSubnet.isPending}
111+
submitError={createExternalSubnet.error}
112+
>
113+
<NameField name="name" control={form.control} />
114+
<DescriptionField name="description" control={form.control} />
115+
<RadioField
116+
name="allocationType"
117+
label="Allocation method"
118+
control={form.control}
119+
items={[
120+
{ value: 'auto', label: 'Auto' },
121+
{ value: 'explicit', label: 'Explicit' },
122+
]}
123+
/>
124+
{allocationType === 'auto' ? (
125+
<>
126+
<ListboxField
127+
name="pool"
128+
label="Subnet pool"
129+
control={form.control}
130+
placeholder="Select a pool"
131+
noItemsPlaceholder="No pools linked to silo"
132+
items={pools.items.map(toPoolItem)}
133+
required
134+
description="Subnet pool to allocate from"
135+
/>
136+
<NumberField
137+
name="prefixLen"
138+
label="Prefix length"
139+
required
140+
control={form.control}
141+
min={1}
142+
max={prefixLenMax}
143+
description="Prefix length for the allocated subnet (e.g., 24 for a /24). Max is 32 for IPv4 pools, 128 for IPv6."
144+
/>
145+
</>
146+
) : (
147+
<TextField
148+
name="subnet"
149+
label="Subnet CIDR"
150+
required
151+
control={form.control}
152+
validate={validateIpNet}
153+
description="The subnet to reserve, e.g., 10.128.1.0/24"
154+
/>
155+
)}
156+
<SideModalFormDocs docs={[docLinks.externalSubnets]} />
157+
</SideModalForm>
158+
)
159+
}

app/forms/external-subnet-edit.tsx

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
/*
2+
* This Source Code Form is subject to the terms of the Mozilla Public
3+
* License, v. 2.0. If a copy of the MPL was not distributed with this
4+
* file, you can obtain one at https://mozilla.org/MPL/2.0/.
5+
*
6+
* Copyright Oxide Computer Company
7+
*/
8+
import { useForm } from 'react-hook-form'
9+
import { useNavigate, type LoaderFunctionArgs } from 'react-router'
10+
11+
import {
12+
api,
13+
q,
14+
qErrorsAllowed,
15+
queryClient,
16+
useApiMutation,
17+
usePrefetchedQuery,
18+
} from '@oxide/api'
19+
20+
import { DescriptionField } from '~/components/form/fields/DescriptionField'
21+
import { NameField } from '~/components/form/fields/NameField'
22+
import { SideModalForm } from '~/components/form/SideModalForm'
23+
import { HL } from '~/components/HL'
24+
import { titleCrumb } from '~/hooks/use-crumbs'
25+
import { getExternalSubnetSelector, useExternalSubnetSelector } from '~/hooks/use-params'
26+
import { addToast } from '~/stores/toast'
27+
import { InstanceLink } from '~/table/cells/InstanceLinkCell'
28+
import { SubnetPoolCell } from '~/table/cells/SubnetPoolCell'
29+
import { SideModalFormDocs } from '~/ui/lib/ModalLinks'
30+
import { PropertiesTable } from '~/ui/lib/PropertiesTable'
31+
import { docLinks } from '~/util/links'
32+
import { pb } from '~/util/path-builder'
33+
import type * as PP from '~/util/path-params'
34+
35+
const externalSubnetView = ({ project, externalSubnet }: PP.ExternalSubnet) =>
36+
q(api.externalSubnetView, {
37+
path: { externalSubnet },
38+
query: { project },
39+
})
40+
41+
export async function clientLoader({ params }: LoaderFunctionArgs) {
42+
const selector = getExternalSubnetSelector(params)
43+
const subnet = await queryClient.fetchQuery(externalSubnetView(selector))
44+
await Promise.all([
45+
queryClient.prefetchQuery(
46+
// subnet pool cell uses errors allowed, so we have to do that here to match
47+
qErrorsAllowed(
48+
api.subnetPoolView,
49+
{ path: { pool: subnet.subnetPoolId } },
50+
{
51+
errorsExpected: {
52+
explanation: 'the referenced subnet pool may have been deleted.',
53+
statusCode: 404,
54+
},
55+
}
56+
)
57+
),
58+
subnet.instanceId
59+
? queryClient.prefetchQuery(
60+
q(api.instanceView, { path: { instance: subnet.instanceId } })
61+
)
62+
: null,
63+
])
64+
return null
65+
}
66+
67+
export const handle = titleCrumb('Edit External Subnet')
68+
69+
export default function EditExternalSubnetSideModalForm() {
70+
const navigate = useNavigate()
71+
72+
const subnetSelector = useExternalSubnetSelector()
73+
const onDismiss = () => navigate(pb.externalSubnets({ project: subnetSelector.project }))
74+
75+
const { data: subnet } = usePrefetchedQuery(externalSubnetView(subnetSelector))
76+
77+
const editExternalSubnet = useApiMutation(api.externalSubnetUpdate, {
78+
onSuccess(updated) {
79+
queryClient.invalidateEndpoint('externalSubnetList')
80+
// prettier-ignore
81+
addToast(<>External subnet <HL>{updated.name}</HL> updated</>)
82+
onDismiss()
83+
},
84+
})
85+
86+
const form = useForm({ defaultValues: subnet })
87+
return (
88+
<SideModalForm
89+
form={form}
90+
formType="edit"
91+
resourceName="external subnet"
92+
onDismiss={onDismiss}
93+
onSubmit={({ name, description }) => {
94+
editExternalSubnet.mutate({
95+
path: { externalSubnet: subnetSelector.externalSubnet },
96+
query: { project: subnetSelector.project },
97+
body: { name, description },
98+
})
99+
}}
100+
loading={editExternalSubnet.isPending}
101+
submitError={editExternalSubnet.error}
102+
>
103+
<PropertiesTable>
104+
<PropertiesTable.IdRow id={subnet.id} />
105+
<PropertiesTable.DateRow label="Created" date={subnet.timeCreated} />
106+
<PropertiesTable.DateRow label="Updated" date={subnet.timeModified} />
107+
<PropertiesTable.Row label="Subnet">{subnet.subnet}</PropertiesTable.Row>
108+
<PropertiesTable.Row label="Subnet Pool">
109+
<SubnetPoolCell subnetPoolId={subnet.subnetPoolId} />
110+
</PropertiesTable.Row>
111+
<PropertiesTable.Row label="Instance">
112+
<InstanceLink instanceId={subnet.instanceId} tab="networking" />
113+
</PropertiesTable.Row>
114+
</PropertiesTable>
115+
<NameField name="name" control={form.control} />
116+
<DescriptionField name="description" control={form.control} />
117+
<SideModalFormDocs docs={[docLinks.externalSubnets]} />
118+
</SideModalForm>
119+
)
120+
}

app/forms/floating-ip-create.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import { ListboxField } from '~/components/form/fields/ListboxField'
2525
import { NameField } from '~/components/form/fields/NameField'
2626
import { SideModalForm } from '~/components/form/SideModalForm'
2727
import { HL } from '~/components/HL'
28-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
28+
import { toPoolItem } from '~/components/PoolListboxItem'
2929
import { titleCrumb } from '~/hooks/use-crumbs'
3030
import { useProjectSelector } from '~/hooks/use-params'
3131
import { addToast } from '~/stores/toast'
@@ -103,7 +103,7 @@ export default function CreateFloatingIpSideModalForm() {
103103
name="pool"
104104
label="Pool"
105105
control={form.control}
106-
items={sortPools(unicastPools).map(toIpPoolItem)}
106+
items={sortPools(unicastPools).map(toPoolItem)}
107107
required
108108
placeholder="Select a pool"
109109
noItemsPlaceholder="No pools available"

app/forms/instance-create.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ import { SshKeysField } from '~/components/form/fields/SshKeysField'
6363
import { Form } from '~/components/form/Form'
6464
import { FullPageForm } from '~/components/form/FullPageForm'
6565
import { HL } from '~/components/HL'
66-
import { toIpPoolItem } from '~/components/IpPoolListboxItem'
66+
import { toPoolItem } from '~/components/PoolListboxItem'
6767
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
6868
import { addToast } from '~/stores/toast'
6969
import { Button } from '~/ui/lib/Button'
@@ -335,7 +335,7 @@ function EphemeralIpCheckbox({
335335
<ListboxField
336336
name={poolFieldName}
337337
control={control}
338-
items={pools.map(toIpPoolItem)}
338+
items={pools.map(toPoolItem)}
339339
disabled={isSubmitting}
340340
required={checked}
341341
hideOptionalTag

app/hooks/use-params.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ export const requireParams =
3333
}
3434

3535
export const getProjectSelector = requireParams('project')
36+
export const getExternalSubnetSelector = requireParams('project', 'externalSubnet')
3637
export const getFloatingIpSelector = requireParams('project', 'floatingIp')
3738
export const getInstanceSelector = requireParams('project', 'instance')
3839
export const getVpcSelector = requireParams('project', 'vpc')
@@ -79,6 +80,7 @@ function useSelectedParams<T>(getSelector: (params: AllParams) => T) {
7980
// params are present. Only the specified keys end up in the result object, but
8081
// we do not error if there are other params present in the query string.
8182

83+
export const useExternalSubnetSelector = () => useSelectedParams(getExternalSubnetSelector)
8284
export const useFloatingIpSelector = () => useSelectedParams(getFloatingIpSelector)
8385
export const useProjectSelector = () => useSelectedParams(getProjectSelector)
8486
export const useProjectImageSelector = () => useSelectedParams(getProjectImageSelector)

0 commit comments

Comments
 (0)