Skip to content

Add instance to anti-affinity group in create form #2788

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
167 changes: 164 additions & 3 deletions app/forms/instance-create.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
useApiMutation,
useApiQueryClient,
usePrefetchedApiQuery,
type AntiAffinityGroup,
type ExternalIpCreate,
type FloatingIp,
type Image,
Expand All @@ -29,6 +30,7 @@ import {
type SiloIpPool,
} from '@oxide/api'
import {
Affinity16Icon,
Images16Icon,
Instances16Icon,
Instances24Icon,
Expand Down Expand Up @@ -60,6 +62,7 @@ import { FullPageForm } from '~/components/form/FullPageForm'
import { HL } from '~/components/HL'
import { getProjectSelector, useProjectSelector } from '~/hooks/use-params'
import { addToast } from '~/stores/toast'
import { Badge } from '~/ui/lib/Badge'
import { Button } from '~/ui/lib/Button'
import { Checkbox } from '~/ui/lib/Checkbox'
import { toComboboxItems } from '~/ui/lib/Combobox'
Expand Down Expand Up @@ -155,6 +158,7 @@ const baseDefaultValues: InstanceCreateInput = {

userData: null,
externalIps: [{ type: 'ephemeral' }],
antiAffinityGroups: [],
}

export async function clientLoader({ params }: LoaderFunctionArgs) {
Expand All @@ -169,6 +173,9 @@ export async function clientLoader({ params }: LoaderFunctionArgs) {
apiQueryClient.prefetchQuery('currentUserSshKeyList', {}),
apiQueryClient.prefetchQuery('projectIpPoolList', { query: { limit: ALL_ISH } }),
apiQueryClient.prefetchQuery('floatingIpList', { query: { project, limit: ALL_ISH } }),
apiQueryClient.prefetchQuery('antiAffinityGroupList', {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Number of groups is likely to be low. Curious if there is a threshold where its better to move this stuff after the page is loaded.

query: { project, limit: ALL_ISH },
}),
])
return null
}
Expand Down Expand Up @@ -343,6 +350,7 @@ export default function CreateInstanceForm() {
networkInterfaces: values.networkInterfaces,
sshPublicKeys: values.sshPublicKeys,
userData,
antiAffinityGroups: values.antiAffinityGroups,
},
})
}}
Expand Down Expand Up @@ -645,7 +653,13 @@ const AdvancedAccordion = ({
const [openItems, setOpenItems] = useState<string[]>([])
const [floatingIpModalOpen, setFloatingIpModalOpen] = useState(false)
const [selectedFloatingIp, setSelectedFloatingIp] = useState<FloatingIp | undefined>()
const [antiAffinityGroupModalOpen, setAntiAffinityGroupModalOpen] = useState(false)
const [selectedAntiAffinityGroup, setSelectedAntiAffinityGroup] = useState<
AntiAffinityGroup | undefined
>()

const externalIps = useController({ control, name: 'externalIps' })
const antiAffinityGroups = useController({ control, name: 'antiAffinityGroups' })
const ephemeralIp = externalIps.field.value?.find((ip) => ip.type === 'ephemeral')
const assignEphemeralIp = !!ephemeralIp
const selectedPool = ephemeralIp && 'pool' in ephemeralIp ? ephemeralIp.pool : undefined
Expand All @@ -658,6 +672,9 @@ const AdvancedAccordion = ({
const { data: floatingIpList } = usePrefetchedApiQuery('floatingIpList', {
query: { project, limit: ALL_ISH },
})
const { data: antiAffinityGroupList } = usePrefetchedApiQuery('antiAffinityGroupList', {
query: { project, limit: ALL_ISH },
})

// Filter out the IPs that are already attached to an instance
const attachableFloatingIps = useMemo(
Expand All @@ -673,6 +690,17 @@ const AdvancedAccordion = ({
.map((ip) => attachableFloatingIps.find((fip) => fip.name === ip.floatingIp))
.filter((ip) => !!ip)

const attachedAntiAffinityGroupNames = antiAffinityGroups.field.value || []

const attachedAntiAffinityGroupData = attachedAntiAffinityGroupNames
.map((name) => antiAffinityGroupList.items.find((group) => group.name === name))
.filter((group) => !!group)

// Available anti-affinity groups with those already attached removed
const availableAntiAffinityGroups = antiAffinityGroupList.items.filter(
(group) => !attachedAntiAffinityGroupNames.includes(group.name)
)

const closeFloatingIpModal = () => {
setFloatingIpModalOpen(false)
setSelectedFloatingIp(undefined)
Expand All @@ -696,6 +724,27 @@ const AdvancedAccordion = ({
)
}

const closeAntiAffinityGroupModal = () => {
setAntiAffinityGroupModalOpen(false)
setSelectedAntiAffinityGroup(undefined)
}

const attachAntiAffinityGroup = () => {
if (selectedAntiAffinityGroup) {
antiAffinityGroups.field.onChange([
...(antiAffinityGroups.field.value || []),
selectedAntiAffinityGroup.name,
])
}
closeAntiAffinityGroupModal()
}

const detachAntiAffinityGroup = (name: string) => {
antiAffinityGroups.field.onChange(
antiAffinityGroups.field.value?.filter((groupName) => groupName !== name)
)
}

const isFloatingIpAttached = attachedFloatingIps.some((ip) => ip.floatingIp !== '')

const selectedFloatingIpMessage = (
Expand Down Expand Up @@ -774,12 +823,12 @@ const AdvancedAccordion = ({
)}
</div>

<div className="flex flex-1 flex-col gap-4">
<div className="flex flex-1 flex-col gap-2">
<h2 className="flex items-center text-sans-md">
Floating IPs{' '}
<TipIcon className="ml-1.5">
Floating IPs exist independently of instances and can be attached to and
detached from them as needed.
detached from them as needed
</TipIcon>
</h2>
{isFloatingIpAttached && (
Expand Down Expand Up @@ -821,7 +870,6 @@ const AdvancedAccordion = ({
<div>
<Button
size="sm"
className="shrink-0"
disabled={availableFloatingIps.length === 0}
disabledReason="No floating IPs available"
onClick={() => setFloatingIpModalOpen(true)}
Expand Down Expand Up @@ -882,6 +930,119 @@ const AdvancedAccordion = ({
control={control}
disabled={isSubmitting}
/>
<div className="flex flex-1 flex-col gap-2">
<h2 className="flex items-center text-sans-md">
Anti-affinity groups
<TipIcon className="ml-1.5">
Instances in an anti-affinity group will be placed on different sleds when
they start
</TipIcon>
</h2>
{attachedAntiAffinityGroupNames.length > 0 && (
<MiniTable.Table>
<MiniTable.Header>
<MiniTable.HeadCell>Name</MiniTable.HeadCell>
<MiniTable.HeadCell>Policy</MiniTable.HeadCell>
{/* For remove button */}
<MiniTable.HeadCell className="w-12" />
</MiniTable.Header>
<MiniTable.Body>
{attachedAntiAffinityGroupData.map((item, index) => (
<MiniTable.Row
tabIndex={0}
aria-rowindex={index + 1}
aria-label={`Name: ${item.name}, Policy: ${item.policy}`}
key={item.name}
>
<MiniTable.Cell>{item.name}</MiniTable.Cell>
<MiniTable.Cell>
<Badge variant="solid">{item.policy}</Badge>
</MiniTable.Cell>
<MiniTable.RemoveCell
onClick={() => detachAntiAffinityGroup(item.name)}
label={`remove anti-affinity group ${item.name}`}
/>
</MiniTable.Row>
))}
</MiniTable.Body>
</MiniTable.Table>
)}
{antiAffinityGroupList.items.length === 0 ? (
<div className="flex max-w-lg items-center justify-center rounded-lg border p-6 border-default">
<EmptyMessage
icon={<Affinity16Icon />}
title="No anti-affinity groups found"
body="Create an anti-affinity group to see it here"
/>
</div>
) : (
<div>
<Button
size="sm"
disabled={availableAntiAffinityGroups.length === 0}
disabledReason="No anti-affinity groups available"
onClick={() => setAntiAffinityGroupModalOpen(true)}
>
Add to group
</Button>
</div>
)}

<Modal
isOpen={antiAffinityGroupModalOpen}
onDismiss={closeAntiAffinityGroupModal}
title="Add instance to group"
>
<Modal.Body>
<Modal.Section>
<Message
variant="info"
content="Instances in an anti-affinity group will be placed on different sleds when they start. The policy attribute determines whether instances can still start when a unique sled is not available."
/>
<form>
<Listbox
name="antiAffinityGroup"
items={availableAntiAffinityGroups.map((group) => ({
value: group.name,
label: (
<div>
<div>{group.name}</div>
<div className="flex gap-0.5 text-secondary selected:text-accent-secondary">
<div>{group.policy}</div>
{group.description && (
<>
<Slash />
<div className="grow overflow-hidden overflow-ellipsis whitespace-pre text-left">
{group.description}
</div>
</>
)}
</div>
</div>
),
selectedLabel: group.name,
}))}
label="Group"
onChange={(name) => {
setSelectedAntiAffinityGroup(
availableAntiAffinityGroups.find((group) => group.name === name)
)
}}
required
placeholder="Select a group"
selected={selectedAntiAffinityGroup?.name || ''}
/>
</form>
</Modal.Section>
</Modal.Body>
<Modal.Footer
actionText="Add"
disabled={!selectedAntiAffinityGroup}
onAction={attachAntiAffinityGroup}
onDismiss={closeAntiAffinityGroupModal}
></Modal.Footer>
</Modal>
</div>
</AccordionItem>
</Accordion.Root>
)
Expand Down
33 changes: 33 additions & 0 deletions mock-api/msw/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -591,6 +591,39 @@ export const handlers = makeHandlers({
newInstance.run_state = 'running'
}, 4000)

// Add instance to specified anti-affinity groups
if (body.anti_affinity_groups && body.anti_affinity_groups.length > 0) {
for (const groupName of body.anti_affinity_groups) {
try {
const antiAffinityGroup = lookup.antiAffinityGroup({
project: project.id,
antiAffinityGroup: groupName,
})

// Check if instance is already in the group
const alreadyThere = db.antiAffinityGroupMemberLists.some(
(i) =>
i.anti_affinity_group_id === antiAffinityGroup.id &&
i.anti_affinity_group_member.id === instanceId
)

if (!alreadyThere) {
db.antiAffinityGroupMemberLists.push({
anti_affinity_group_id: antiAffinityGroup.id,
anti_affinity_group_member: {
id: instanceId,
type: 'instance',
},
...getTimestamps(),
})
}
} catch (_e) {
// Silently ignore if group not found - API will handle validation
console.warn(`Anti-affinity group ${groupName} not found, skipping`)
}
}
}

db.instances.push(newInstance)

return json(newInstance, { status: 201 })
Expand Down
37 changes: 37 additions & 0 deletions test/e2e/instance-create.e2e.ts
Original file line number Diff line number Diff line change
Expand Up @@ -612,3 +612,40 @@ test('create instance with additional disks', async ({ page }) => {
await expectRowVisible(otherDisksTable, { Disk: 'new-disk-1', size: '5 GiB' })
await expectRowVisible(otherDisksTable, { Disk: 'disk-3', size: '6 GiB' })
})

test('can add anti-affinity group when creating an instance', async ({ page }) => {
const instanceName = 'anti-affinity-instance'
await page.goto('/projects/mock-project/instances-new')
await page.getByRole('textbox', { name: 'Name', exact: true }).fill(instanceName)
await selectAProjectImage(page, 'image-1')

// Open the Configuration accordion to expose anti-affinity group controls
await page.getByRole('button', { name: 'Configuration' }).click()

await page.getByRole('button', { name: 'Add to group' }).click()

const dialog = page.getByRole('dialog')
await expect(dialog.getByText('Add instance to group')).toBeVisible()

const groupListbox = dialog.getByRole('button', { name: 'Group' })
await groupListbox.click()
await page.getByRole('option', { name: 'romulus-remus' }).click()

await dialog.getByRole('button', { name: 'Add', exact: true }).click()

// Verify the group appears in the mini-table
const antiAffinityTable = page.getByRole('table')
await expectRowVisible(antiAffinityTable, { Name: 'romulus-remus', Policy: 'fail' })

await page.getByRole('button', { name: 'Create instance' }).click()

await expect(page).toHaveURL(`/projects/mock-project/instances/${instanceName}/storage`)
await expectVisible(page, [`h1:has-text("${instanceName}")`])
await page.getByRole('tab', { name: 'Settings' }).click()

const ipsTable = page.getByRole('table', { name: 'Anti-affinity groups' })
await expectRowVisible(ipsTable, {
name: 'romulus-remus',
Policy: 'fail',
})
})
Loading