diff --git a/app/components/AccordionItem.tsx b/app/components/AccordionItem.tsx
index 8bf2365120..227d8583ae 100644
--- a/app/components/AccordionItem.tsx
+++ b/app/components/AccordionItem.tsx
@@ -37,7 +37,7 @@ export const AccordionItem = ({ children, isOpen, label, value }: AccordionItemP
{children}
diff --git a/app/forms/instance-create.tsx b/app/forms/instance-create.tsx
index 6d53acc033..9e74ab96da 100644
--- a/app/forms/instance-create.tsx
+++ b/app/forms/instance-create.tsx
@@ -7,7 +7,7 @@
*/
import * as Accordion from '@radix-ui/react-accordion'
import { useEffect, useMemo, useState } from 'react'
-import { useWatch, type Control } from 'react-hook-form'
+import { useController, useWatch, type Control } from 'react-hook-form'
import { useNavigate, type LoaderFunctionArgs } from 'react-router-dom'
import type { SetRequired } from 'type-fest'
@@ -50,12 +50,16 @@ import { Form } from '~/components/form/Form'
import { FullPageForm } from '~/components/form/FullPageForm'
import { getProjectSelector, useForm, useProjectSelector } from '~/hooks'
import { addToast } from '~/stores/toast'
+import { Badge } from '~/ui/lib/Badge'
+import { Checkbox } from '~/ui/lib/Checkbox'
import { FormDivider } from '~/ui/lib/Divider'
import { EmptyMessage } from '~/ui/lib/EmptyMessage'
+import { Listbox } from '~/ui/lib/Listbox'
import { Message } from '~/ui/lib/Message'
import { RadioCard } from '~/ui/lib/Radio'
import { Tabs } from '~/ui/lib/Tabs'
import { TextInputHint } from '~/ui/lib/TextInput'
+import { TipIcon } from '~/ui/lib/TipIcon'
import { readBlobAsBase64 } from '~/util/file'
import { links } from '~/util/links'
import { nearest10 } from '~/util/math'
@@ -130,6 +134,7 @@ const baseDefaultValues: InstanceCreateInput = {
start: true,
userData: null,
+ externalIps: [{ type: 'ephemeral' }],
}
const DISK_FETCH_LIMIT = 1000
@@ -144,6 +149,7 @@ CreateInstanceForm.loader = async ({ params }: LoaderFunctionArgs) => {
query: { project, limit: DISK_FETCH_LIMIT },
}),
apiQueryClient.prefetchQuery('currentUserSshKeyList', {}),
+ apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: 1000 } }),
])
return null
}
@@ -187,6 +193,15 @@ export function CreateInstanceForm() {
const { data: sshKeys } = usePrefetchedApiQuery('currentUserSshKeyList', {})
const allKeys = useMemo(() => sshKeys.items.map((key) => key.id), [sshKeys])
+ // projectIpPoolList fetches the pools linked to the current silo
+ const { data: siloPools } = usePrefetchedApiQuery('projectIpPoolList', {
+ query: { limit: 1000 },
+ })
+ const defaultPool = useMemo(
+ () => (siloPools ? siloPools.items.find((p) => p.isDefault)?.name : undefined),
+ [siloPools]
+ )
+
const defaultSource =
siloImages.length > 0 ? 'siloImage' : projectImages.length > 0 ? 'projectImage' : 'disk'
@@ -198,6 +213,7 @@ export function CreateInstanceForm() {
diskSource: disks?.[0]?.value || '',
sshPublicKeys: allKeys,
bootDiskSize: nearest10(defaultImage?.size / GiB),
+ externalIps: [{ type: 'ephemeral', pool: defaultPool }],
}
const form = useForm({ defaultValues })
@@ -283,7 +299,7 @@ export function CreateInstanceForm() {
memory: instance.memory * GiB,
ncpus: instance.ncpus,
disks: [bootDisk, ...values.disks],
- externalIps: [{ type: 'ephemeral' }],
+ externalIps: values.externalIps,
start: values.start,
networkInterfaces: values.networkInterfaces,
sshPublicKeys: values.sshPublicKeys,
@@ -514,7 +530,11 @@ export function CreateInstanceForm() {
Advanced
-
+
Create instance
navigate(pb.instances({ project }))} />
@@ -526,14 +546,21 @@ export function CreateInstanceForm() {
const AdvancedAccordion = ({
control,
isSubmitting,
+ siloPools,
}: {
control: Control
isSubmitting: boolean
+ siloPools: Array<{ name: string; isDefault: boolean }>
}) => {
// we track this state manually for the sole reason that we need to be able to
// tell, inside AccordionItem, when an accordion is opened so we can scroll its
// contents into view
const [openItems, setOpenItems] = useState([])
+ const externalIps = useController({ control, name: 'externalIps' })
+ const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral')
+ const assignEphemeralIp = !!ephemeralIp
+ const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined
+ const defaultPool = siloPools.find((pool) => pool.isDefault)?.name
return (
-
+
+
+
+
+
+
+ Ephemeral IP{' '}
+
+ Ephemeral IPs are allocated when the instance is created and deallocated when
+ it is deleted
+
+
+
+ {
+ const newExternalIps = assignEphemeralIp
+ ? externalIps.field.value?.filter((ip) => ip.type !== 'ephemeral')
+ : [
+ ...(externalIps.field.value || []),
+ { type: 'ephemeral', pool: selectedPool || defaultPool },
+ ]
+ externalIps.field.onChange(newExternalIps)
+ }}
+ />
+
+
+ {assignEphemeralIp && (
+
pool.name === selectedPool)?.name}`}
+ items={
+ siloPools.map((pool) => ({
+ label: (
+
+ {pool.name}
+ {pool.isDefault && default}
+
+ ),
+ value: pool.name,
+ })) || []
+ }
+ disabled={!assignEphemeralIp || isSubmitting}
+ required
+ onChange={(value) => {
+ const newExternalIps = externalIps.field.value?.map((ip) =>
+ ip.type === 'ephemeral' ? { ...ip, pool: value } : ip
+ )
+ externalIps.field.onChange(newExternalIps)
+ }}
+ />
+ )}
+
= {
value: Value
-} & (
- | { label: string; labelString?: never }
- // labelString is required when `label` is a `ReactElement` because we
- // need need a one-line string to display in the button when the item is
- // selected.
- | { label: ReactNode; labelString: string }
-)
+} & { label?: string | ReactNode; labelString?: string }
export interface ListboxProps {
// null is allowed as a default empty value, but onChange will never be called with null
@@ -44,9 +38,9 @@ export interface ListboxProps {
disabled?: boolean
hasError?: boolean
name?: string
- label?: string
+ label?: React.ReactNode
tooltipText?: string
- description?: string | React.ReactNode
+ description?: React.ReactNode
required?: boolean
isLoading?: boolean
}
diff --git a/app/util/links.ts b/app/util/links.ts
index 9fa5cabbae..b9b5a4298a 100644
--- a/app/util/links.ts
+++ b/app/util/links.ts
@@ -10,6 +10,8 @@ export const links: Record = {
cloudInitFormat: 'https://cloudinit.readthedocs.io/en/latest/explanation/format.html',
cloudInitExamples: 'https://cloudinit.readthedocs.io/en/latest/reference/examples.html',
disksDocs: 'https://docs.oxide.computer/guides/managing-disks-and-snapshots',
+ externalAddresses:
+ 'https://docs.oxide.computer/guides/operator/ip-pool-management#_external_address_categorization',
firewallRulesDocs:
'https://docs.oxide.computer/guides/configuring-guest-networking#_firewall_rules',
floatingIpsDocs: 'https://docs.oxide.computer/guides/managing-floating-ips',
diff --git a/test/e2e/instance-create.e2e.ts b/test/e2e/instance-create.e2e.ts
index 13b82d208b..d8b8ed006d 100644
--- a/test/e2e/instance-create.e2e.ts
+++ b/test/e2e/instance-create.e2e.ts
@@ -51,6 +51,27 @@ test('can create an instance', async ({ page }) => {
await page.getByRole('button', { name: 'Networking' }).click()
await page.getByRole('button', { name: 'Configuration' }).click()
+ const assignEphemeralIpCheckbox = page.getByRole('checkbox', {
+ name: 'Allocate and attach an ephemeral IP address',
+ })
+ const assignEphemeralIpButton = page.getByRole('button', {
+ name: 'IP pool for ephemeral IP',
+ })
+
+ // verify that the ip pool selector is visible and default is selected
+ await expect(assignEphemeralIpCheckbox).toBeChecked()
+ await assignEphemeralIpButton.click()
+ await expect(page.getByRole('option', { name: 'ip-pool-1' })).toBeEnabled()
+
+ // unchecking the box should disable the selector
+ await assignEphemeralIpCheckbox.uncheck()
+ await expect(assignEphemeralIpButton).toBeHidden()
+
+ // re-checking the box should re-enable the selector, and other options should be selectable
+ await assignEphemeralIpCheckbox.check()
+ await assignEphemeralIpButton.click()
+ await page.getByRole('option', { name: 'ip-pool-2' }).click()
+
// should be visible in accordion
await expectVisible(page, [
'role=radiogroup[name="Network interface"]',
@@ -293,3 +314,15 @@ test('maintains selected values even when changing tabs', async ({ page }) => {
// so this checks to make sure that the arch-based image — with ID `bd6aa051…` — was used
await expectVisible(page, [`text=${instanceName}-bd6aa051`])
})
+
+test('does not attach an ephemeral IP when the checkbox is unchecked', async ({ page }) => {
+ await page.goto('/projects/mock-project/instances-new')
+ await page.getByRole('textbox', { name: 'Name', exact: true }).fill('no-ephemeral-ip')
+ await page.getByRole('button', { name: 'Networking' }).click()
+ await page
+ .getByRole('checkbox', { name: 'Allocate and attach an ephemeral IP address' })
+ .uncheck()
+ await page.getByRole('button', { name: 'Create instance' }).click()
+ await expect(page).toHaveURL('/projects/mock-project/instances/no-ephemeral-ip/storage')
+ await expect(page.getByText('External IPs—')).toBeVisible()
+})