diff --git a/.github/workflows/lintBuildTest.yml b/.github/workflows/lintBuildTest.yml index 5a41229fb0..85f4878714 100644 --- a/.github/workflows/lintBuildTest.yml +++ b/.github/workflows/lintBuildTest.yml @@ -28,7 +28,7 @@ jobs: run: yarn lint - name: Test - run: yarn test:run + run: yarn test run - name: Build run: yarn build diff --git a/.github/workflows/packer.yaml b/.github/workflows/packer.yaml index 5d1f20455d..9b18599aa2 100644 --- a/.github/workflows/packer.yaml +++ b/.github/workflows/packer.yaml @@ -75,7 +75,7 @@ jobs: CLOUDFLARE_TOKEN: ${{secrets.CLOUDFLARE_TOKEN}} SSL_CERT: ${{secrets.SSL_CERT}} SSL_KEY: ${{secrets.SSL_KEY}} - API_VERSION: c4e76cb01fa791c4dc9722072800c8969397aa03 + API_VERSION: f615ee43803902fc8ed37a7e66a39677f885dbdb # get the image information from gcloud - name: Get image information diff --git a/README.md b/README.md index c14a110f85..d0ec896823 100644 --- a/README.md +++ b/README.md @@ -118,7 +118,7 @@ Using the script is strongly recommended, but if you really don't want to, make | Command | Description | | --------------- | ---------------------------------------------------------------------------------- | -| `yarn test:run` | Vitest tests | +| `yarn test run` | Vitest tests | | `yarn test` | Vitest tests in watch mode | | `yarn lint` | ESLint | | `yarn tsc` | Check types | diff --git a/app/pages/__tests__/InstanceCreatePage.spec.tsx b/app/pages/__tests__/InstanceCreatePage.spec.tsx index 3212a33d35..4c78cef0d1 100644 --- a/app/pages/__tests__/InstanceCreatePage.spec.tsx +++ b/app/pages/__tests__/InstanceCreatePage.spec.tsx @@ -3,7 +3,7 @@ import { override, renderAppAt, screen, - userEvent, + typeByRole, waitFor, } from 'app/test-utils' import { org, project, instance } from '@oxide/api-mocks' @@ -30,8 +30,7 @@ describe('InstanceCreatePage', () => { it('shows specific message for known server error code', async () => { renderAppAt(formUrl) - const name = screen.getByRole('textbox', { name: 'Choose a name' }) - userEvent.type(name, instance.name) // already exists in db + typeByRole('textbox', 'Choose a name', instance.name) // already exists in db fireEvent.click(submitButton()) @@ -59,10 +58,9 @@ describe('InstanceCreatePage', () => { const instancesPage = `/orgs/${org.name}/projects/${project.name}/instances` expect(window.location.pathname).not.toEqual(instancesPage) - const name = screen.getByRole('textbox', { name: 'Choose a name' }) - userEvent.type(name, 'new-instance') - userEvent.click(screen.getByLabelText(/6 CPUs/)) - userEvent.click(submitButton()) + typeByRole('textbox', 'Choose a name', 'new-instance') + fireEvent.click(screen.getByLabelText(/6 CPUs/)) + fireEvent.click(submitButton()) // nav to instances list await waitFor(() => expect(window.location.pathname).toEqual(instancesPage)) diff --git a/app/pages/project/instances/actions.tsx b/app/pages/project/instances/actions.tsx index 23e6c7dd36..2661ec04be 100644 --- a/app/pages/project/instances/actions.tsx +++ b/app/pages/project/instances/actions.tsx @@ -1,8 +1,4 @@ -import type { - ApiListMethods, - Instance, - ProjectInstancesGetParams, -} from '@oxide/api' +import type { Instance, ProjectInstancesGetParams } from '@oxide/api' import { useApiMutation, useApiQueryClient } from '@oxide/api' import type { MakeActions } from '@oxide/table' import { Success16Icon } from '@oxide/ui' @@ -28,7 +24,7 @@ const instanceCan: Record boolean> = { export const useInstanceActions = ( params: ProjectInstancesGetParams -): MakeActions => { +): MakeActions => { const addToast = useToast() const queryClient = useApiQueryClient() const refetch = () => diff --git a/app/pages/project/networking/VpcPage/VpcPage.spec.ts b/app/pages/project/networking/VpcPage/VpcPage.spec.ts index 012eb6d9bc..f40324d198 100644 --- a/app/pages/project/networking/VpcPage/VpcPage.spec.ts +++ b/app/pages/project/networking/VpcPage/VpcPage.spec.ts @@ -1,50 +1,209 @@ import { - fireEvent, + clickByRole, renderAppAt, screen, - userEvent, + typeByRole, waitForElementToBeRemoved, + userEvent, + getByRole, } from 'app/test-utils' +import { defaultFirewallRules } from '@oxide/api-mocks' describe('VpcPage', () => { - describe('subnets tab', () => { - it('creating a subnet works', async () => { + describe('subnet', () => { + it('create works', async () => { renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc') screen.getByText('Subnets') // wait for subnet to show up in the table - await screen.findByRole('cell', { name: 'mock-subnet' }) - // second one is not there though + await screen.findByText('mock-subnet') + // the one we'll be adding is not there expect(screen.queryByRole('cell', { name: 'mock-subnet-2' })).toBeNull() // modal is not already open - expect(screen.queryByRole('dialog', { name: 'Create subnet' })).toBeNull() + expect(screen.queryByTestId('create-vpc-subnet-modal')).toBeNull() // click button to open modal - fireEvent.click(screen.getByRole('button', { name: 'New subnet' })) + await clickByRole('button', 'New subnet') // modal is open screen.getByRole('dialog', { name: 'Create subnet' }) - const ipv4 = screen.getByRole('textbox', { name: 'IPv4 block' }) - userEvent.type(ipv4, '1.1.1.2/24') + typeByRole('textbox', 'IPv4 block', '1.1.1.2/24') + + typeByRole('textbox', 'Name', 'mock-subnet-2') + + // submit the form + await clickByRole('button', 'Create subnet') + + // wait for modal to close + await waitForElementToBeRemoved(() => + screen.queryByTestId('create-vpc-subnet-modal') + ) + + // table refetches and now includes second subnet + await screen.findByText('mock-subnet') + await screen.findByText('mock-subnet-2') + }, 10000) // otherwise it flakes in CI + }) + + describe('firewall rule', () => { + it('create works', async () => { + renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc') + await clickByRole('tab', 'Firewall Rules') + + // default rules show up in the table + for (const { name } of defaultFirewallRules) { + await screen.findByText(name) + } + // the one we'll be adding is not there + expect(screen.queryByRole('cell', { name: 'my-new-rule' })).toBeNull() + + // modal is not already open + expect( + screen.queryByRole('dialog', { name: 'Create firewall rule' }) + ).toBeNull() + + // click button to open modal + await clickByRole('button', 'New rule') + + // modal is open + screen.getByRole('dialog', { name: 'Create firewall rule' }) + + typeByRole('textbox', 'Name', 'my-new-rule') + + await clickByRole('radio', 'Outgoing') + + // input type="number" becomes spinbutton for some reason + typeByRole('spinbutton', 'Priority', '5') + + await clickByRole('button', 'Target type') + await clickByRole('option', 'VPC') + typeByRole('textbox', 'Target name', 'my-target-vpc') + await clickByRole('button', 'Add target') + + // target is added to targets table + screen.getByRole('cell', { name: 'my-target-vpc' }) + + await clickByRole('button', 'Host type') + await clickByRole('option', 'Instance') + typeByRole('textbox', 'Value', 'host-filter-instance') + await clickByRole('button', 'Add host filter') + + // host is added to hosts table + screen.getByRole('cell', { name: 'host-filter-instance' }) + + // TODO: test invalid port range once I put an error message in there + typeByRole('textbox', 'Port filter', '123-456') + await clickByRole('button', 'Add port filter') + + // port range is added to port ranges table + screen.getByRole('cell', { name: '123-456' }) + await clickByRole('checkbox', 'UDP') + + // submit the form + await clickByRole('button', 'Create rule') + + // wait for modal to close + await waitForElementToBeRemoved( + () => screen.queryByText('Create firewall rule'), + // fails in CI without a longer timeout (default 1000). boo + { timeout: 2000 } + ) + + // table refetches and now includes the new rule as well as the originals + await screen.findByText('my-new-rule') + screen.getByRole('cell', { + name: 'instance host-filter-instance UDP 123-456', + }) + + for (const { name } of defaultFirewallRules) { + screen.getByText(name) + } + }, 15000) + + it('edit works', async () => { + renderAppAt('/orgs/maze-war/projects/mock-project/vpcs/mock-vpc') + await clickByRole('tab', 'Firewall Rules') + + // default rules show up in the table + for (const { name } of defaultFirewallRules) { + await screen.findByText(name) + } + expect(screen.getAllByRole('row').length).toEqual(5) // 4 + header + + // the one we'll be adding is not there + expect(screen.queryByRole('cell', { name: 'new-rule-name' })).toBeNull() + + // modal is not already open + expect( + screen.queryByRole('dialog', { name: 'Edit firewall rule' }) + ).toBeNull() + + // click more button on allow-icmp row to get menu, then click Edit + const allowIcmpRow = screen.getByRole('row', { name: /allow-icmp/ }) + const more = getByRole(allowIcmpRow, 'button', { name: 'More' }) + await userEvent.click(more) + await clickByRole('menuitem', 'Edit') + + // now the modal is open + screen.getByRole('dialog', { name: 'Edit firewall rule' }) + + // name is populated const name = screen.getByRole('textbox', { name: 'Name' }) - userEvent.type(name, 'mock-subnet-2') + expect(name).toHaveValue('allow-icmp') + + // priority is populated + const priority = screen.getByRole('spinbutton', { name: 'Priority' }) + expect(priority).toHaveValue(65534) + + // protocol is populated + expect(screen.getByRole('checkbox', { name: /ICMP/ })).toBeChecked() + expect(screen.getByRole('checkbox', { name: /TCP/ })).not.toBeChecked() + expect(screen.getByRole('checkbox', { name: /UDP/ })).not.toBeChecked() + + // targets default vpc + screen.getByRole('cell', { name: 'vpc' }) + screen.getByRole('cell', { name: 'default' }) + + // update name + await userEvent.clear(name) + await userEvent.type(name, 'new-rule-name') + + // add host filter + await clickByRole('button', 'Host type') + await clickByRole('option', 'Instance') + typeByRole('textbox', 'Value', 'edit-filter-instance') + await clickByRole('button', 'Add host filter') + + // host is added to hosts table + screen.getByRole('cell', { name: 'edit-filter-instance' }) // submit the form - fireEvent.click(screen.getByRole('button', { name: 'Create subnet' })) + await clickByRole('button', 'Update rule') // wait for modal to close await waitForElementToBeRemoved( - () => screen.queryByRole('dialog', { name: 'Create subnet' }), + () => screen.queryByText('Edit firewall rule'), // fails in CI without a longer timeout (default 1000). boo { timeout: 2000 } ) - // table refetches and now includes second subnet - screen.getByRole('cell', { name: 'mock-subnet' }) - await screen.findByRole('cell', { name: 'mock-subnet-2' }) - }, 10000) // otherwise it flakes in CI + // table refetches and now includes the updated rule name, not the old name + await screen.findByText('new-rule-name') + expect(screen.queryByRole('cell', { name: 'allow-icmp' })).toBeNull() + expect(screen.getAllByRole('row').length).toEqual(5) // 4 + header + + screen.getByRole('cell', { + name: 'instance edit-filter-instance ICMP', + }) + + // other 3 rules are still there + const rest = defaultFirewallRules.filter((r) => r.name !== 'allow-icmp') + for (const { name } of rest) { + screen.getByRole('cell', { name }) + } + }, 20000) }) }) diff --git a/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx b/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx new file mode 100644 index 0000000000..a311ad5bac --- /dev/null +++ b/app/pages/project/networking/VpcPage/modals/firewall-rules.tsx @@ -0,0 +1,588 @@ +import React from 'react' +import { Form, Formik, useFormikContext } from 'formik' +import * as Yup from 'yup' + +import { + Button, + CheckboxField, + Delete10Icon, + Dropdown, + FieldTitle, + NumberTextField, + Radio, + RadioGroup, + SideModal, + Table, + TextField, + TextFieldError, + TextFieldHint, +} from '@oxide/ui' +import type { + ErrorResponse, + VpcFirewallRule, + VpcFirewallRuleUpdate, +} from '@oxide/api' +import { + parsePortRange, + useApiMutation, + useApiQueryClient, + firewallRuleGetToPut, +} from '@oxide/api' +import { getServerError } from 'app/util/errors' + +type FormProps = { + error: ErrorResponse | null + id: string +} + +type Values = { + enabled: boolean + priority: string + name: string + description: string + action: VpcFirewallRule['action'] + direction: VpcFirewallRule['direction'] + + protocols: NonNullable + + // port subform + ports: NonNullable + portRange: string + + // host subform + hosts: NonNullable + hostType: string + hostValue: string + + // target subform + targets: VpcFirewallRule['targets'] + targetType: string + targetValue: string +} + +// TODO: pressing enter in ports, hosts, and targets value field should "submit" subform + +// the moment the two forms diverge, inline them rather than introducing BS +// props here +const CommonForm = ({ id, error }: FormProps) => { + const { setFieldValue, values } = useFormikContext() + return ( +
+ + {/* omitting value prop makes it a boolean value. beautiful */} + {/* TODO: better text or heading or tip or something on this checkbox */} + Enabled +
+ Name + +
+
+ + Description {/* TODO: indicate optional */} + + +
+
+ +
+ Priority + + Must be 0–65535 + + + +
+
+ Action + + Allow + Deny + +
+
+ Direction of traffic + + Incoming + Outgoing + +
+
+ +

Targets

+ { + setFieldValue('targetType', item?.value) + }} + /> +
+ Target name + +
+ +
+ {/* TODO does this clear out the form or the existing targets? */} + + +
+ + + + + Type + Name + + + + + {values.targets.map((t) => ( + + {/* TODO: should be the pretty type label, not the type key */} + {t.type} + {t.value} + + { + setFieldValue( + 'targets', + values.targets.filter( + (t1) => t1.value !== t.value || t1.type !== t.type + ) + ) + }} + /> + + + ))} + +
+
+ +

Host filters

+ { + setFieldValue('hostType', item?.value) + }} + /> +
+ {/* For everything but IP this is a name, but for IP it's an IP. + So we should probably have the label on this field change when the + host type changes. Also need to confirm that it's just an IP and + not a block. */} + Value + + For IP, an address. For the rest, a name. [TODO: copy] + + +
+ +
+ + +
+ + + + + Type + Value + + + + + {values.hosts.map((h) => ( + + {/* TODO: should be the pretty type label, not the type key */} + {h.type} + {h.value} + + { + setFieldValue( + 'hosts', + values.hosts.filter( + (h1) => h1.value !== h.value && h1.type !== h.type + ) + ) + }} + /> + + + ))} + +
+
+ +
+ Port filter + + A single port (1234) or a range (1234-2345) + + + +
+ + +
+
+ + + + Range + + + + + {values.ports.map((p) => ( + + {/* TODO: should be the pretty type label, not the type key */} + {p} + + { + setFieldValue( + 'ports', + values.ports.filter((p1) => p1 !== p) + ) + }} + /> + + + ))} + +
+
+ +
+ Protocols +
+ + TCP + +
+
+ + UDP + +
+
+ + ICMP + +
+
+
+ +
{getServerError(error)}
+
+
+ ) +} + +const valuesToRuleUpdate = (values: Values): VpcFirewallRuleUpdate => ({ + name: values.name, + status: values.enabled ? 'enabled' : 'disabled', + action: values.action, + description: values.description, + direction: values.direction, + filters: { + hosts: values.hosts, + ports: values.ports, + protocols: values.protocols, + }, + priority: parseInt(values.priority, 10), + targets: values.targets, +}) + +type CreateProps = { + isOpen: boolean + onDismiss: () => void + orgName: string + projectName: string + vpcName: string + existingRules: VpcFirewallRule[] +} + +export function CreateFirewallRuleModal({ + isOpen, + onDismiss, + orgName, + projectName, + vpcName, + existingRules, +}: CreateProps) { + const parentIds = { orgName, projectName, vpcName } + const queryClient = useApiQueryClient() + + function dismiss() { + updateRules.reset() + onDismiss() + } + + const updateRules = useApiMutation('vpcFirewallRulesPut', { + onSuccess() { + queryClient.invalidateQueries('vpcFirewallRulesGet', parentIds) + dismiss() + }, + }) + + const formId = 'create-firewall-rule-form' + + return ( + + { + const otherRules = existingRules + .filter((r) => r.name !== values.name) + .map(firewallRuleGetToPut) + updateRules.mutate({ + ...parentIds, + body: { + rules: [...otherRules, valuesToRuleUpdate(values)], + }, + }) + }} + > + + + + + + + + ) +} + +type EditProps = { + onDismiss: () => void + orgName: string + projectName: string + vpcName: string + originalRule: VpcFirewallRule | null + existingRules: VpcFirewallRule[] +} + +// TODO: this whole thing. shouldn't take much to fill in the initialValues +// based on the rule being edited +export function EditFirewallRuleModal({ + onDismiss, + orgName, + projectName, + vpcName, + originalRule, + existingRules, +}: EditProps) { + const parentIds = { orgName, projectName, vpcName } + const queryClient = useApiQueryClient() + + function dismiss() { + updateRules.reset() + onDismiss() + } + + const updateRules = useApiMutation('vpcFirewallRulesPut', { + onSuccess() { + queryClient.invalidateQueries('vpcFirewallRulesGet', parentIds) + dismiss() + }, + }) + + if (!originalRule) return null + + const formId = 'edit-firewall-rule-form' + return ( + + { + // note different filter logic from create: filter by the *original* name + const otherRules = existingRules + .filter((r) => r.name !== originalRule.name) + .map(firewallRuleGetToPut) + updateRules.mutate({ + ...parentIds, + body: { + rules: [...otherRules, valuesToRuleUpdate(values)], + }, + }) + }} + > + + + + + + + + ) +} diff --git a/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx b/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx index a0e6f1af36..08aae20c1b 100644 --- a/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx +++ b/app/pages/project/networking/VpcPage/modals/vpc-subnets.tsx @@ -90,6 +90,7 @@ export function CreateVpcSubnetModal({ title="Create subnet" isOpen={isOpen} onDismiss={dismiss} + data-testid="create-vpc-subnet-modal" > { const vpcParams = useParams('orgName', 'projectName', 'vpcName') - const { Table, Column } = useQueryTable('vpcFirewallRulesGet', vpcParams) + const { data } = useApiQuery('vpcFirewallRulesGet', vpcParams) + const rules = useMemo(() => data?.rules || [], [data]) + + const [createModalOpen, setCreateModalOpen] = useState(false) + const [editing, setEditing] = useState(null) + + const actions = (rule: VpcFirewallRule): MenuAction[] => [ + { + label: 'Edit', + onActivate: () => setEditing(rule), + }, + ] + + const table = useTable( + { + data: rules, + columns, + // @ts-expect-error types are wrong. this is included in the docs + autoResetSelectedRows: false, + }, + useRowSelect, + (hooks) => { + hooks.visibleColumns.push((columns) => [ + getSelectCol(), + ...columns, + getActionsCol(actions), + ]) + } + ) return ( - - - - - - - -
+ <> +
+ + setCreateModalOpen(false)} + existingRules={rules} + /> + setEditing(null)} + existingRules={rules} + originalRule={editing} // modal is open if this is non-null + /> +
+ + ) } diff --git a/app/test-utils.tsx b/app/test-utils.tsx index f3a6efe536..7a8b766b42 100644 --- a/app/test-utils.tsx +++ b/app/test-utils.tsx @@ -1,6 +1,6 @@ import React from 'react' import { BrowserRouter } from 'react-router-dom' -import { render } from '@testing-library/react' +import { fireEvent, render, screen } from '@testing-library/react' import { QueryClient, QueryClientProvider } from 'react-query' import { rest } from 'msw' import { setupServer } from 'msw/node' @@ -23,6 +23,9 @@ setLogger({ error: () => {}, }) +/***************************************** + * MSW + ****************************************/ const server = setupServer(...handlers) beforeAll(() => server.listen()) @@ -48,6 +51,9 @@ export function override( ) } +/***************************************** + * RENDERING + ****************************************/ const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -80,6 +86,24 @@ export function renderAppAt(url: string) { }) } +/***************************************** + * TESTING LIBRARY + ****************************************/ + export * from '@testing-library/react' -export { default as userEvent } from '@testing-library/user-event' -export { customRender as render } +import userEvent from '@testing-library/user-event' +export { customRender as render, userEvent } + +// convenience functions so we can click and type in a one-liner. these were +// initially created to use the user-event library, but it was remarkably slow. +// see if those issues are improved before trying that again + +export async function clickByRole(role: string, name: string) { + const element = screen.getByRole(role, { name }) + await userEvent.click(element) +} + +export function typeByRole(role: string, name: string, text: string) { + const element = screen.getByRole(role, { name }) + fireEvent.change(element, { target: { value: text } }) +} diff --git a/libs/api-mocks/msw/db.ts b/libs/api-mocks/msw/db.ts index 1915c1a1aa..3cd25c41a8 100644 --- a/libs/api-mocks/msw/db.ts +++ b/libs/api-mocks/msw/db.ts @@ -131,6 +131,7 @@ const initDb = { disks: [mock.disk], vpcs: [mock.vpc], vpcSubnets: [mock.vpcSubnet], + vpcFirewallRules: [...mock.defaultFirewallRules], } const clone = (o: unknown) => JSON.parse(JSON.stringify(o)) diff --git a/libs/api-mocks/msw/handlers.ts b/libs/api-mocks/msw/handlers.ts index 31ad942091..e519904b37 100644 --- a/libs/api-mocks/msw/handlers.ts +++ b/libs/api-mocks/msw/handlers.ts @@ -1,6 +1,7 @@ import type { ResponseTransformer } from 'msw' import { rest, context, compose } from 'msw' import type { ApiTypes as Api } from '@oxide/api' +import { sortBy } from '@oxide/util' import type { Json } from '../json-type' import { sessionMe } from '../session' import type { @@ -311,4 +312,38 @@ export const handlers = [ return res(ctx.status(204)) } ), + + rest.get | GetErr>( + '/api/organizations/:orgName/projects/:projectName/vpcs/:vpcName/firewall/rules', + (req, res, ctx) => { + const vpc = lookupVpc(req, res, ctx) + if (vpc.err) return vpc.err + const rules = db.vpcFirewallRules.filter((r) => r.vpc_id === vpc.ok.id) + return res(json({ rules: sortBy(rules, (r) => r.name) })) + } + ), + + rest.put< + Json, + VpcParams, + Json | PostErr + >( + '/api/organizations/:orgName/projects/:projectName/vpcs/:vpcName/firewall/rules', + (req, res, ctx) => { + const vpc = lookupVpc(req, res, ctx) + if (vpc.err) return vpc.err + const rules = req.body.rules.map((rule) => ({ + vpc_id: vpc.ok.id, + id: 'firewall-rule-' + randomHex(), + ...rule, + ...getTimestamps(), + })) + // replace existing rules for this VPC with the new ones + db.vpcFirewallRules = [ + ...db.vpcFirewallRules.filter((r) => r.vpc_id !== vpc.ok.id), + ...rules, + ] + return res(json({ rules: sortBy(rules, (r) => r.name) })) + } + ), ] diff --git a/libs/api-mocks/vpc.ts b/libs/api-mocks/vpc.ts index 02a4ec079d..d118794b9d 100644 --- a/libs/api-mocks/vpc.ts +++ b/libs/api-mocks/vpc.ts @@ -2,20 +2,25 @@ import type { Json } from './json-type' import { project } from './project' import type { Vpc, + VpcFirewallRule, VpcResultsPage, VpcSubnet, VpcSubnetResultsPage, } from '@oxide/api' +const time_created = new Date(2021, 0, 1).toISOString() +const time_modified = new Date(2021, 0, 2).toISOString() + export const vpc: Json = { id: 'vpc-id', name: 'mock-vpc', description: 'a fake vpc', dns_name: 'mock-vpc', - time_created: new Date(2021, 0, 1).toISOString(), - time_modified: new Date(2021, 0, 2).toISOString(), project_id: project.id, system_router_id: 'router-id', // ??? + ipv6_prefix: 'fdf6:1818:b6e1::/48', + time_created, + time_modified, } export const vpcs: Json = { items: [vpc] } @@ -43,3 +48,73 @@ export const vpcSubnet2: Json = { export const vpcSubnets: Json = { items: [vpcSubnet], } + +export const defaultFirewallRules: Json = [ + { + id: 'firewall-rule-id-1', + name: 'allow-internal-inbound', + status: 'enabled', + direction: 'inbound', + targets: [{ type: 'vpc', value: 'default' }], + action: 'allow', + description: + 'allow inbound traffic to all instances within the VPC if originated within the VPC', + filters: { + hosts: [{ type: 'vpc', value: 'default' }], + }, + priority: 65534, + time_created, + time_modified, + vpc_id: vpc.id, + }, + { + id: 'firewall-rule-id-2', + name: 'allow-ssh', + status: 'enabled', + direction: 'inbound', + targets: [{ type: 'vpc', value: 'default' }], + description: 'allow inbound TCP connections on port 22 from anywhere', + filters: { + ports: ['22'], + protocols: ['TCP'], + }, + action: 'allow', + priority: 65534, + time_created, + time_modified, + vpc_id: vpc.id, + }, + { + id: 'firewall-rule-id-3', + name: 'allow-icmp', + status: 'enabled', + direction: 'inbound', + targets: [{ type: 'vpc', value: 'default' }], + description: 'allow inbound ICMP traffic from anywhere', + filters: { + protocols: ['ICMP'], + }, + action: 'allow', + priority: 65534, + time_created, + time_modified, + vpc_id: vpc.id, + }, + { + id: 'firewall-rule-id-4', + name: 'allow-rdp', + status: 'enabled', + direction: 'inbound', + targets: [{ type: 'vpc', value: 'default' }], + description: 'allow inbound TCP connections on port 3389 from anywhere', + filters: { + ports: ['3389'], + protocols: ['TCP'], + }, + action: 'allow', + priority: 65534, + time_created, + time_modified, + vpc_id: vpc.id, + }, +] diff --git a/libs/api/__generated__/Api.ts b/libs/api/__generated__/Api.ts index cb9491ac41..8200c2e17f 100644 --- a/libs/api/__generated__/Api.ts +++ b/libs/api/__generated__/Api.ts @@ -53,7 +53,7 @@ export type Disk = { } /** - * Create-time parameters for a [`Disk`] + * Create-time parameters for a [`Disk`](omicron_common::api::external::Disk) */ export type DiskCreate = { description: string @@ -69,7 +69,7 @@ export type DiskCreate = { } /** - * Parameters for the [`Disk`] to be attached or detached to an instance + * Parameters for the [`Disk`](omicron_common::api::external::Disk) to be attached or detached to an instance */ export type DiskIdentifier = { disk: Name @@ -170,7 +170,7 @@ export type Instance = { export type InstanceCpuCount = number /** - * Create-time parameters for an [`Instance`] + * Create-time parameters for an [`Instance`](omicron_common::api::external::Instance) */ export type InstanceCreate = { description: string @@ -181,7 +181,7 @@ export type InstanceCreate = { } /** - * Migration parameters for an [`Instance`] + * Migration parameters for an [`Instance`](omicron_common::api::external::Instance) */ export type InstanceMigrate = { dstSledUuid: string @@ -223,16 +223,27 @@ export type InstanceState = */ export type Ipv4Net = string +/** Regex pattern for validating Ipv4Net */ +export const ipv4NetPattern = + '^(10.(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9].){2}(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9])/(1[0-9]|2[0-8]|[8-9]))$^(172.16.(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9]).(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9])/(1[2-9]|2[0-8]))$^(192.168.(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9]).(25[0-5]|[1-2][0-4][0-9]|[1-9][0-9]|[0-9])/(1[6-9]|2[0-8]))$' + /** * An IPv6 subnet, including prefix and subnet mask */ export type Ipv6Net = string +/** Regex pattern for validating Ipv6Net */ +export const ipv6NetPattern = + '^(fd|FD)[0-9a-fA-F]{2}:((([0-9a-fA-F]{1,4}:){6}[0-9a-fA-F]{1,4})|(([0-9a-fA-F]{1,4}:){1,6}:))/(6[4-9]|[7-9][0-9]|1[0-1][0-9]|12[0-6])$' + /** * An inclusive-inclusive range of IP ports. The second port may be omitted to represent a single port */ export type L4PortRange = string +/** Regex pattern for validating L4PortRange */ +export const l4PortRangePattern = '^[0-9]{1,5}(-[0-9]{1,5})?$' + export type LoginParams = { username: string } @@ -242,11 +253,17 @@ export type LoginParams = { */ export type MacAddr = string +/** Regex pattern for validating MacAddr */ +export const macAddrPattern = '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + /** * Names must begin with a lower case ASCII letter, be composed exclusively of lowercase ASCII, uppercase ASCII, numbers, and '-', and may not end with a '-'. */ export type Name = string +/** Regex pattern for validating Name */ +export const namePattern = '[a-z](|[a-zA-Z0-9-]*[a-zA-Z0-9])' + /** * A `NetworkInterface` represents a virtual network interface device. */ @@ -334,7 +351,7 @@ export type Organization = { } /** - * Create-time parameters for an [`Organization`] + * Create-time parameters for an [`Organization`](crate::external_api::views::Organization) */ export type OrganizationCreate = { description: string @@ -356,7 +373,7 @@ export type OrganizationResultsPage = { } /** - * Updateable properties of an [`Organization`] + * Updateable properties of an [`Organization`](crate::external_api::views::Organization) */ export type OrganizationUpdate = { description?: string | null @@ -391,7 +408,7 @@ export type Project = { } /** - * Create-time parameters for a [`Project`] + * Create-time parameters for a [`Project`](crate::external_api::views::Project) */ export type ProjectCreate = { description: string @@ -413,7 +430,7 @@ export type ProjectResultsPage = { } /** - * Updateable properties of a [`Project`] + * Updateable properties of a [`Project`](crate::external_api::views::Project) */ export type ProjectUpdate = { description?: string | null @@ -473,6 +490,9 @@ export type Role = { */ export type RoleName = string +/** Regex pattern for validating RoleName */ +export const roleNamePattern = '[a-z-]+.[a-z-]+' + /** * A single page of results */ @@ -670,6 +690,10 @@ export type SledResultsPage = { */ export type TimeseriesName = string +/** Regex pattern for validating TimeseriesName */ +export const timeseriesNamePattern = + '(([a-z]+[a-z0-9]*)(_([a-z0-9]+))*):(([a-z]+[a-z0-9]*)(_([a-z0-9]+))*)' + /** * The schema for a timeseries. * @@ -752,6 +776,10 @@ export type Vpc = { * unique, immutable, system-controlled identifier for each resource */ id: string + /** + * The unique local IPv6 address range for subnets in this VPC + */ + ipv6Prefix: Ipv6Net /** * unique, mutable, user-controlled identifier for each resource */ @@ -775,11 +803,12 @@ export type Vpc = { } /** - * Create-time parameters for a [`Vpc`] + * Create-time parameters for a [`Vpc`](crate::external_api::views::Vpc) */ export type VpcCreate = { description: string dnsName: Name + ipv6Prefix?: Ipv6Net | null name: Name } @@ -831,6 +860,10 @@ export type VpcFirewallRule = { * timestamp when this resource was last modified */ timeModified: Date + /** + * the VPC to which this rule belongs + */ + vpcId: string } export type VpcFirewallRuleAction = 'allow' | 'deny' @@ -870,20 +903,6 @@ export type VpcFirewallRuleHostFilter = */ export type VpcFirewallRuleProtocol = 'TCP' | 'UDP' | 'ICMP' -/** - * A single page of results - */ -export type VpcFirewallRuleResultsPage = { - /** - * list of items on this page of results - */ - items: VpcFirewallRule[] - /** - * token used to fetch the next page of results (if any) - */ - nextPage?: string | null -} - export type VpcFirewallRuleStatus = 'disabled' | 'enabled' /** @@ -914,6 +933,10 @@ export type VpcFirewallRuleUpdate = { * reductions on the scope of the rule */ filters: VpcFirewallRuleFilter + /** + * name of the rule, unique to this VPC + */ + name: Name /** * the relative priority of this rule */ @@ -929,14 +952,18 @@ export type VpcFirewallRuleUpdate = { } /** - * Updateable properties of a [`Vpc`]'s firewall Note that VpcFirewallRules are implicitly created along with a Vpc, so there is no explicit creation. + * Updateable properties of a `Vpc`'s firewall Note that VpcFirewallRules are implicitly created along with a Vpc, so there is no explicit creation. */ -export type VpcFirewallRuleUpdateParams = Record +export type VpcFirewallRuleUpdateParams = { + rules: VpcFirewallRuleUpdate[] +} /** - * Response to an update replacing [`Vpc`]'s firewall + * Collection of a [`Vpc`]'s firewall rules */ -export type VpcFirewallRuleUpdateResult = Record +export type VpcFirewallRules = { + rules: VpcFirewallRule[] +} /** * A single page of results @@ -984,7 +1011,7 @@ export type VpcRouter = { } /** - * Create-time parameters for a [`VpcRouter`] + * Create-time parameters for a [`VpcRouter`](omicron_common::api::external::VpcRouter) */ export type VpcRouterCreate = { description: string @@ -1008,7 +1035,7 @@ export type VpcRouterResultsPage = { } /** - * Updateable properties of a [`VpcRouter`] + * Updateable properties of a [`VpcRouter`](omicron_common::api::external::VpcRouter) */ export type VpcRouterUpdate = { description?: string | null @@ -1054,7 +1081,7 @@ export type VpcSubnet = { } /** - * Create-time parameters for a [`VpcSubnet`] + * Create-time parameters for a [`VpcSubnet`](crate::external_api::views::VpcSubnet) */ export type VpcSubnetCreate = { description: string @@ -1078,7 +1105,7 @@ export type VpcSubnetResultsPage = { } /** - * Updateable properties of a [`VpcSubnet`] + * Updateable properties of a [`VpcSubnet`](crate::external_api::views::VpcSubnet) */ export type VpcSubnetUpdate = { description?: string | null @@ -1088,7 +1115,7 @@ export type VpcSubnetUpdate = { } /** - * Updateable properties of a [`Vpc`] + * Updateable properties of a [`Vpc`](crate::external_api::views::Vpc) */ export type VpcUpdate = { description?: string | null @@ -1427,18 +1454,6 @@ export interface ProjectVpcsDeleteVpcParams { } export interface VpcFirewallRulesGetParams { - /** - * Maximum number of items returned by a single call - */ - limit?: number | null - - /** - * Token returned by previous call to retreive the subsequent page - */ - pageToken?: string | null - - sortBy?: NameSortMode - orgName: Name projectName: Name @@ -2445,13 +2460,12 @@ export class Api extends HttpClient { * List firewall rules for a VPC. */ vpcFirewallRulesGet: ( - { orgName, projectName, vpcName, ...query }: VpcFirewallRulesGetParams, + { orgName, projectName, vpcName }: VpcFirewallRulesGetParams, params: RequestParams = {} ) => - this.request({ + this.request({ path: `/organizations/${orgName}/projects/${projectName}/vpcs/${vpcName}/firewall/rules`, method: 'GET', - query: query, ...params, }), @@ -2463,7 +2477,7 @@ export class Api extends HttpClient { data: VpcFirewallRuleUpdateParams, params: RequestParams = {} ) => - this.request({ + this.request({ path: `/organizations/${orgName}/projects/${projectName}/vpcs/${vpcName}/firewall/rules`, method: 'PUT', body: data, diff --git a/libs/api/__generated__/OMICRON_VERSION b/libs/api/__generated__/OMICRON_VERSION index 6f9fadd6e8..4364450e94 100644 --- a/libs/api/__generated__/OMICRON_VERSION +++ b/libs/api/__generated__/OMICRON_VERSION @@ -1,2 +1,2 @@ # generated file. do not update manually. see docs/update-pinned-api.md -c4e76cb01fa791c4dc9722072800c8969397aa03 +f615ee43803902fc8ed37a7e66a39677f885dbdb diff --git a/libs/api/index.ts b/libs/api/index.ts index b56ac09a69..bbf1c625a5 100644 --- a/libs/api/index.ts +++ b/libs/api/index.ts @@ -26,6 +26,7 @@ export const useApiQuery = getUseApiQuery(api.methods) export const useApiMutation = getUseApiMutation(api.methods) export const useApiQueryClient = getUseApiQueryClient() +export * from './util' export * from './__generated__/Api' // for convenience so we can do `import type { ApiTypes } from '@oxide/api'` diff --git a/libs/api/util.spec.ts b/libs/api/util.spec.ts new file mode 100644 index 0000000000..7f4a08b6c3 --- /dev/null +++ b/libs/api/util.spec.ts @@ -0,0 +1,33 @@ +import { parsePortRange } from './util' + +describe('parsePortRange', () => { + describe('parses', () => { + it('parses single ports up to 5 digits', () => { + expect(parsePortRange('0')).toEqual([0, 0]) + expect(parsePortRange('1')).toEqual([1, 1]) + expect(parsePortRange('123')).toEqual([123, 123]) + expect(parsePortRange('12356')).toEqual([12356, 12356]) + }) + + it('parses ranges', () => { + expect(parsePortRange('123-456')).toEqual([123, 456]) + expect(parsePortRange('1-45690')).toEqual([1, 45690]) + expect(parsePortRange('5-5')).toEqual([5, 5]) + }) + }) + + describe('rejects', () => { + it('nonsense', () => { + expect(parsePortRange('12a5')).toEqual(null) + expect(parsePortRange('lkajsdfha')).toEqual(null) + }) + + it('p2 < p1', () => { + expect(parsePortRange('123-45')).toEqual(null) + }) + + it('too many digits', () => { + expect(parsePortRange('239032')).toEqual(null) + }) + }) +}) diff --git a/libs/api/util.ts b/libs/api/util.ts new file mode 100644 index 0000000000..59154371b8 --- /dev/null +++ b/libs/api/util.ts @@ -0,0 +1,40 @@ +/// Helpers for working with API objects +import type { + VpcFirewallRule, + VpcFirewallRuleUpdate, +} from './__generated__/Api' +import { pick } from '@oxide/util' + +type PortRange = [number, number] + +/** Parse '1234' into [1234, 1234] and '80-100' into [80, 100] */ +// TODO: parsing should probably throw errors rather than returning +// null so we can annotate the failure with a reason +export function parsePortRange(portRange: string): PortRange | null { + // TODO: pull pattern from openapi spec (requires generator work) + const match = /^([0-9]{1,5})((?:-)[0-9]{1,5})?$/.exec(portRange) + if (!match) return null + + const p1 = parseInt(match[1], 10) + // API treats a single port as a range with the same start and end + const p2 = match[2] ? parseInt(match[2].slice(1), 10) : p1 + + if (p2 < p1) return null + + return [p1, p2] +} + +export const firewallRuleGetToPut = ( + rule: VpcFirewallRule +): NoExtraKeys => + pick( + rule, + 'name', + 'action', + 'description', + 'direction', + 'filters', + 'priority', + 'status', + 'targets' + ) diff --git a/libs/table/QueryTable.tsx b/libs/table/QueryTable.tsx index 23314cb7cf..382abbab71 100644 --- a/libs/table/QueryTable.tsx +++ b/libs/table/QueryTable.tsx @@ -67,7 +67,7 @@ interface QueryTableProps { rowId?: | string | ((row: Row, relativeIndex: number, parent: unknown) => string) - actions?: MakeActions + actions?: MakeActions> pagination?: 'inline' | 'page' pageSize?: number children: React.ReactNode @@ -123,11 +123,7 @@ const makeQueryTable = ( options ) - const tableData = useMemo( - () => (data as any)?.items || [], - // eslint-disable-next-line react-hooks/exhaustive-deps - [(data as any)?.items] - ) + const tableData = useMemo(() => (data as any)?.items || [], [data]) const getRowId = useCallback( (row, relativeIndex, parent) => { diff --git a/libs/table/columns/action-col.tsx b/libs/table/columns/action-col.tsx index bba89cf9cb..c121f29f60 100644 --- a/libs/table/columns/action-col.tsx +++ b/libs/table/columns/action-col.tsx @@ -3,11 +3,8 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@reach/menu-button' import type { Row } from 'react-table' import React from 'react' import { kebabCase } from '@oxide/util' -import type { ApiListMethods, ResultItem } from '@oxide/api' -export type MakeActions = ( - item: ResultItem -) => Array +export type MakeActions = (item: Item) => Array export type MenuAction = { label: string @@ -15,14 +12,16 @@ export type MenuAction = { disabled?: boolean } -// eslint-disable-next-line @typescript-eslint/no-explicit-any -export function getActionsCol(actionsCreator: MakeActions) { +export function getActionsCol(actionsCreator: MakeActions) { return { id: 'menu', className: 'w-12', Cell: ({ row }: { row: Row }) => { const type = row.original - const actions = actionsCreator(type).filter(Boolean) as MenuAction[] + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const actions = actionsCreator(type as any).filter( + Boolean + ) as MenuAction[] return (
diff --git a/libs/ui/lib/checkbox/Checkbox.tsx b/libs/ui/lib/checkbox/Checkbox.tsx index d01d1d70c5..eec0de7682 100644 --- a/libs/ui/lib/checkbox/Checkbox.tsx +++ b/libs/ui/lib/checkbox/Checkbox.tsx @@ -1,4 +1,7 @@ import React from 'react' +import type { FieldAttributes } from 'formik' +import { Field } from 'formik' + import { Checkmark12Icon } from '@oxide/ui' import { classed } from '@oxide/util' import cn from 'classnames' @@ -22,7 +25,7 @@ export type CheckboxProps = { indeterminate?: boolean children?: React.ReactNode className?: string -} & React.ComponentProps<'input'> +} & Omit, 'type'> // ref function is from: https://davidwalsh.name/react-indeterminate. this makes // the native input work with indeterminate. you can't pass indeterminate as a @@ -30,6 +33,7 @@ export type CheckboxProps = { // examples using forwardRef to allow passing ref from outside: // https://github.com/tannerlinsley/react-table/discussions/1989 +/** Checkbox component that handles label, styling, and indeterminate state */ export const Checkbox = ({ indeterminate, children, @@ -53,3 +57,11 @@ export const Checkbox = ({ )} ) + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +type CheckboxFieldProps = CheckboxProps & Omit, 'type'> + +/** Formik Field version of Checkbox */ +export const CheckboxField = (props: CheckboxFieldProps) => ( + +) diff --git a/libs/ui/lib/dropdown/Dropdown.tsx b/libs/ui/lib/dropdown/Dropdown.tsx index 3bdad7172f..0c7d23b1e1 100644 --- a/libs/ui/lib/dropdown/Dropdown.tsx +++ b/libs/ui/lib/dropdown/Dropdown.tsx @@ -23,6 +23,7 @@ export interface DropdownProps { */ showLabel?: boolean className?: string + onChange?: (value: Item | null | undefined) => void } export const Dropdown: FC = ({ @@ -33,12 +34,16 @@ export const Dropdown: FC = ({ placeholder, showLabel = true, className, + onChange, }) => { const itemToString = (item: Item | null) => (item ? item.label : '') const select = useSelect({ initialSelectedItem: items.find((i) => i.value === defaultValue) || null, items, itemToString, + onSelectedItemChange(changes) { + onChange?.(changes.selectedItem) + }, }) const hintId = hint ? `${select.getLabelProps().id}-hint` : null diff --git a/libs/ui/lib/radio-group/RadioGroup.tsx b/libs/ui/lib/radio-group/RadioGroup.tsx index 0ac16c6dd3..83f3d4e61c 100644 --- a/libs/ui/lib/radio-group/RadioGroup.tsx +++ b/libs/ui/lib/radio-group/RadioGroup.tsx @@ -6,8 +6,8 @@ *
* Pick a foot * - * Left - * Right + * Left + * Right * *
* @@ -17,8 +17,8 @@ * Pick a foot *

Don't think about it too hard

* - * Left - * Right + * Left + * Right * * * diff --git a/libs/ui/lib/side-modal/SideModal.tsx b/libs/ui/lib/side-modal/SideModal.tsx index 4e3886c4e0..344b2fe3dc 100644 --- a/libs/ui/lib/side-modal/SideModal.tsx +++ b/libs/ui/lib/side-modal/SideModal.tsx @@ -31,7 +31,7 @@ export function SideModal({ aria-labelledby={titleId} >
{/* Title */} diff --git a/libs/ui/lib/text-field/TextField.tsx b/libs/ui/lib/text-field/TextField.tsx index 877468847f..f7e73d2876 100644 --- a/libs/ui/lib/text-field/TextField.tsx +++ b/libs/ui/lib/text-field/TextField.tsx @@ -8,18 +8,20 @@ import { ErrorMessage, Field } from 'formik' // through, but couldn't get it to work. FieldAttributes is closest but // it makes a bunch of props required that should be optional. Instead we simply // take the props of an input field (which are part of the Field props) and -// manually tack on validate. Omit `type` because this is always a text field. -export type TextFieldProps = Omit, 'type'> & { +// manually tack on validate. +export type TextFieldProps = React.ComponentProps<'input'> & { validate?: FieldValidator // error is used to style the wrapper, also to put aria-invalid on the input error?: boolean disabled?: boolean className?: string + fieldClassName?: string } export const TextField = ({ error, className, + fieldClassName, ...fieldProps }: TextFieldProps) => (
) +// TODO: do this properly: extract a NumberField that styles the up and down +// buttons for when we do want them *and* add a flag to hide them using +// appearance-textfield +export const NumberTextField = ({ + fieldClassName, + ...props +}: Omit) => ( + +) + type HintProps = { // ID required as a reminder to pass aria-describedby on TextField id: string diff --git a/libs/util/array.spec.ts b/libs/util/array.spec.ts new file mode 100644 index 0000000000..90fbcf7dd9 --- /dev/null +++ b/libs/util/array.spec.ts @@ -0,0 +1,16 @@ +import { sortBy } from './array' + +test('sortBy', () => { + expect(sortBy(['d', 'b', 'c', 'a'])).toEqual(['a', 'b', 'c', 'd']) + + expect( + sortBy([{ x: 'd' }, { x: 'b' }, { x: 'c' }, { x: 'a' }], (o) => o.x) + ).toEqual([{ x: 'a' }, { x: 'b' }, { x: 'c' }, { x: 'd' }]) + + expect( + sortBy( + [{ x: [0, 0, 0, 0] }, { x: [0, 0, 0] }, { x: [0] }, { x: [0, 0] }], + (o) => o.x.length + ) + ).toEqual([{ x: [0] }, { x: [0, 0] }, { x: [0, 0, 0] }, { x: [0, 0, 0, 0] }]) +}) diff --git a/libs/util/array.ts b/libs/util/array.ts new file mode 100644 index 0000000000..868f934a26 --- /dev/null +++ b/libs/util/array.ts @@ -0,0 +1,11 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const identity = (x: any) => x + +/** Returns a new array sorted by `by`. Assumes return value of `by` is + * comparable. Default value of `by` is the identity function. */ +export function sortBy(arr: T[], by: (t: T) => any = identity) { + const copy = [...arr] + copy.sort((a, b) => (by(a) < by(b) ? -1 : by(a) > by(b) ? 1 : 0)) + return copy +} +/* eslint-enable @typescript-eslint/no-explicit-any */ diff --git a/libs/util/index.ts b/libs/util/index.ts index 7e6663878a..7550edef8b 100644 --- a/libs/util/index.ts +++ b/libs/util/index.ts @@ -4,3 +4,4 @@ export * from './invariant' export * from './object' export * from './children' export * from './unreachable' +export * from './array' diff --git a/package.json b/package.json index 22aefa3981..14b78a1d2a 100644 --- a/package.json +++ b/package.json @@ -8,9 +8,8 @@ "build": "vite build", "build-for-nexus": "yarn install && API_URL='' vite build", "build:themes": "./tools/build_themes.sh", - "ci": "yarn tsc && yarn lint && yarn test:run", + "ci": "yarn tsc && yarn lint && yarn test run", "test": "DEBUG_PRINT_LIMIT=0 vitest", - "test:run": "DEBUG_PRINT_LIMIT=0 vitest run", "lint": "eslint --ext .js,.ts,.tsx,.json .", "fmt": "prettier --write", "gen": "plop", @@ -52,7 +51,8 @@ "react-transition-group": "^4.4.1", "recharts": "^2.1.6", "tslib": "^2.0.0", - "uuid": "^8.3.2" + "uuid": "^8.3.2", + "yup": "^0.32.11" }, "devDependencies": { "@babel/core": "^7.13.10", @@ -75,11 +75,10 @@ "@storybook/manager-webpack5": "^6.4.12", "@storybook/react": "^6.4.12", "@storybook/theming": "^6.4.12", - "@testing-library/dom": "^8.11.1", "@testing-library/jest-dom": "^5.14.1", "@testing-library/react": "^12.0.0", "@testing-library/react-hooks": "^7.0.1", - "@testing-library/user-event": "^13.5.0", + "@testing-library/user-event": "14.0.0-beta.10", "@types/jscodeshift": "^0.11.2", "@types/mousetrap": "^1.6.8", "@types/node": "^16.11.9", @@ -126,7 +125,7 @@ "typescript": "4.5.5", "url-loader": "^3.0.0", "vite": "^2.7.10", - "vitest": "^0.2.3", + "vitest": "^0.2.6", "webpack": "^5.40.0", "whatwg-fetch": "^3.6.2" }, diff --git a/packer/oxapi_demo b/packer/oxapi_demo index fdd1395ff5..2eeb9a13ae 100755 --- a/packer/oxapi_demo +++ b/packer/oxapi_demo @@ -231,7 +231,7 @@ function cmd_project_create_demo function cmd_project_delete { [[ $# != 2 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME" - do_curl "/organizations/$1/projects/$2" -X DELETE + do_curl_authn "/organizations/$1/projects/$2" -X DELETE } function cmd_project_get @@ -249,7 +249,7 @@ function cmd_project_list_instances function cmd_project_list_disks { [[ $# != 2 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME" - do_curl "/organizations/$1/projects/$2/disks" + do_curl_authn "/organizations/$1/projects/$2/disks" } function cmd_project_list_vpcs @@ -308,14 +308,14 @@ function cmd_instance_attach_disk { [[ $# != 4 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME INSTANCE_NAME DISK_NAME" mkjson disk="$4" | - do_curl "/organizations/$1/projects/$2/instances/$3/disks/attach" -X POST -T - + do_curl_authn "/organizations/$1/projects/$2/instances/$3/disks/attach" -X POST -T - } function cmd_instance_detach_disk { [[ $# != 4 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME INSTANCE_NAME DISK_NAME" mkjson disk="$4" | - do_curl "/organizations/$1/projects/$2/instances/$3/disks/detach" -X POST -T - + do_curl_authn "/organizations/$1/projects/$2/instances/$3/disks/detach" -X POST -T - } function cmd_instance_list_disks @@ -328,13 +328,13 @@ function cmd_disk_create_demo { [[ $# != 3 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME DISK_NAME" mkjson name="$3" description="a disk called $3" size=1024 | - do_curl "/organizations/$1/projects/$2/disks" -X POST -T - + do_curl_authn "/organizations/$1/projects/$2/disks" -X POST -T - } function cmd_disk_get { [[ $# != 3 ]] && usage "expected ORGANIZATION_NAME PROJECT_NAME DISK_NAME" - do_curl "/organizations/$1/projects/$2/disks/$3" + do_curl_authn "/organizations/$1/projects/$2/disks/$3" } function cmd_disk_delete diff --git a/tailwind.config.js b/tailwind.config.js index d8c82d395a..21a13b0bf5 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -105,6 +105,11 @@ module.exports = { ) addUtilities(textUtilities) addUtilities(colorUtilities) + addUtilities({ + '.appearance-textfield': { + appearance: 'textfield', + }, + }) }), ], } diff --git a/tools/create_gcp_instance.sh b/tools/create_gcp_instance.sh index 572936e97f..7919c1bd63 100755 --- a/tools/create_gcp_instance.sh +++ b/tools/create_gcp_instance.sh @@ -41,7 +41,7 @@ retry 2 gcloud compute instances create "$INSTANCE_NAME" \ --description="Machine automatically generated from branch ${BRANCH_NAME} of the oxidecomputer/console git repo." \ --hostname="${INSTANCE_NAME}.internal.oxide.computer" \ --zone=$ZONE \ - --image=packer-1644345572 \ + --image=packer-1644347964 \ --maintenance-policy=TERMINATE \ --restart-on-failure \ --machine-type=$INSTANCE_TYPE \ diff --git a/types/util.d.ts b/types/util.d.ts index 9ab07c2da5..a33d66d9f9 100644 --- a/types/util.d.ts +++ b/types/util.d.ts @@ -13,3 +13,34 @@ type Optional = Pick, K> & Omit /** Join types for `P1` and `P2` where `P2` takes precedence in conflicts */ type Assign = Omit & P2 + +/** + * Produce a version of `FewerKeys` that assigns a type of `never` to all keys + * of `MoreKeys` that aren't in `FewerKeys`. Why? Good question: + * + * In most cases TS will error if you try to assign an object to a variable if + * there are keys that are not in the type of that variable. But there are + * situations where it will not: + * + * type X = { a: number } + * const x: X = { a: 1, b: 2 } // error: b is not in X + * const arr: X[] = [{ a: 1, b: 2 }].map((x) => x) // no error + * + * To avoid this, we can use NoExtraKeys; + * + * type X = { a: number } + * type Y = { a: number; b: number } + * type StrictX = NoExtraKeys + * const arr: StrictX[] = [{ a: 1, b: 2 }].map((x) => x) // error + * + * Which produces the following error: + * + * Type '{ a: number; b: number; }' is not comparable to type '{ b?: undefined; }'. + * Types of property 'b' are incompatible. + * Type 'number' is not comparable to type 'undefined'.ts(2352) + * + * The `?` is necessary, otherwise the resulting type is impossible to instantiate. + **/ +type NoExtraKeys = FewerKeys & { + [K in Exclude]?: never +} diff --git a/yarn.lock b/yarn.lock index b2b206110a..4cd8b1f795 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1295,6 +1295,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.15.4": + version "7.16.7" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.16.7.tgz#03ff99f64106588c9c403c6ecb8c3bafbbdff1fa" + integrity sha512-9E9FJowqAsytyOY6LG+1KuueckRL+aQW+mKvXRXnuFGyRAyepJPmEo9vgMfXUA6O9u3IeEdv9MAkppFcaQwogQ== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/template@^7.12.13", "@babel/template@^7.12.7", "@babel/template@^7.15.4": version "7.15.4" resolved "https://registry.npmjs.org/@babel/template/-/template-7.15.4.tgz" @@ -3062,20 +3069,6 @@ lz-string "^1.4.4" pretty-format "^27.0.2" -"@testing-library/dom@^8.11.1": - version "8.11.1" - resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.11.1.tgz#03fa2684aa09ade589b460db46b4c7be9fc69753" - integrity sha512-3KQDyx9r0RKYailW2MiYrSSKEfH0GTkI51UGEvJenvcoDoeRYs0PZpi2SXqtnMClQvCqdtTTpOfFETDTVADpAg== - dependencies: - "@babel/code-frame" "^7.10.4" - "@babel/runtime" "^7.12.5" - "@types/aria-query" "^4.2.0" - aria-query "^5.0.0" - chalk "^4.1.0" - dom-accessibility-api "^0.5.9" - lz-string "^1.4.4" - pretty-format "^27.0.2" - "@testing-library/jest-dom@^5.14.1": version "5.14.1" resolved "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-5.14.1.tgz" @@ -3110,10 +3103,10 @@ "@babel/runtime" "^7.12.5" "@testing-library/dom" "^8.0.0" -"@testing-library/user-event@^13.5.0": - version "13.5.0" - resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-13.5.0.tgz#69d77007f1e124d55314a2b73fd204b333b13295" - integrity sha512-5Kwtbo3Y/NowpkbRuSepbyMFkZmHgD+vPzYB/RJ4oxt5Gj/avFFBYjhw27cqSVPVw/3a67NK1PbiIr9k4Gwmdg== +"@testing-library/user-event@14.0.0-beta.10": + version "14.0.0-beta.10" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.0.0-beta.10.tgz#7ac68e9542e30aa1744f354a7f026d7a70647de2" + integrity sha512-a3iA66OE1PfcDc1BlfGm4yqgRid78en4GtAEsn6PwMIteVJzZe1aWztvZsWbEPX85y4JXaPwRiLD9aSloi0cwA== dependencies: "@babel/runtime" "^7.12.5" @@ -3337,6 +3330,11 @@ "@types/interpret" "*" "@types/node" "*" +"@types/lodash@^4.14.175": + version "4.14.178" + resolved "https://registry.yarnpkg.com/@types/lodash/-/lodash-4.14.178.tgz#341f6d2247db528d4a13ddbb374bcdc80406f4f8" + integrity sha512-0d5Wd09ItQWH1qFbEyQ7oTQ3GZrMfth5JkbN3EvTKLXcHLRDSXeLnlvlOn0wvxVIwK5o2M8JzP/OWz7T3NRsbw== + "@types/mdast@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/mdast/-/mdast-3.0.3.tgz" @@ -4272,11 +4270,6 @@ aria-query@^4.2.2: "@babel/runtime" "^7.10.2" "@babel/runtime-corejs3" "^7.10.2" -aria-query@^5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/aria-query/-/aria-query-5.0.0.tgz#210c21aaf469613ee8c9a62c7f86525e058db52c" - integrity sha512-V+SM7AbUwJ+EBnB8+DXs0hPZHO0W6pqBcc0dW90OwtVG02PswOu/teuARoLQjdDOH+t9pJgGnW5/Qmouf3gPJg== - arr-diff@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/arr-diff/-/arr-diff-4.0.0.tgz" @@ -5104,16 +5097,16 @@ ccount@^1.0.0: resolved "https://registry.npmjs.org/ccount/-/ccount-1.1.0.tgz" integrity sha512-vlNK021QdI7PNeiUh/lKkC/mNHHfV0m/Ad5JoI0TYtlBnJAslM/JIkm/tGC88bkLIwO6OQ5uV6ztS6kVAtCDlg== -chai@^4.3.4: - version "4.3.5" - resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.5.tgz#784cf398a30cd45b8980181ba1a8c866c225b5df" - integrity sha512-0gKhNDL29PUlmwz1CG42p/OaBf1v0YD3oH4//YMS1niT7rLH9tC+lqTgk+SvdbhMLd7ToTtxA61orNBmpSO/DA== +chai@^4.3.6: + version "4.3.6" + resolved "https://registry.yarnpkg.com/chai/-/chai-4.3.6.tgz#ffe4ba2d9fa9d6680cc0b370adae709ec9011e9c" + integrity sha512-bbcp3YfHCUzMOvKqsztczerVgBKSsEijCySNlHHbX3VG1nskvqjz5Rfso1gGwD6w6oOV3eI60pKuMOV5MV7p3Q== dependencies: assertion-error "^1.1.0" check-error "^1.0.2" deep-eql "^3.0.1" get-func-name "^2.0.0" - loupe "^2.3.0" + loupe "^2.3.1" pathval "^1.1.1" type-detect "^4.0.5" @@ -6290,11 +6283,6 @@ dom-accessibility-api@^0.5.6: resolved "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.6.tgz" integrity sha512-DplGLZd8L1lN64jlT27N9TVSESFR5STaEJvX+thCby7fuCHonfPpAlodYc3vuUYbDuDec5w8AMP7oCM5TWFsqw== -dom-accessibility-api@^0.5.9: - version "0.5.10" - resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.10.tgz#caa6d08f60388d0bb4539dd75fe458a9a1d0014c" - integrity sha512-Xu9mD0UjrJisTmv7lmVSDMagQcU9R5hwAbxsaAE/35XPnPLJobbuREfV/rraiSaEj/UOvgrzQs66zyTWTlyd+g== - dom-converter@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/dom-converter/-/dom-converter-0.2.0.tgz" @@ -9432,7 +9420,7 @@ locate-path@^6.0.0: lodash-es@^4.17.21: version "4.17.21" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.21.tgz#43e626c46e6591b7750beb2b50117390c609e3ee" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== lodash._reinterpolate@^3.0.0: @@ -9512,13 +9500,12 @@ loose-envify@^1.0.0, loose-envify@^1.1.0, loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" -loupe@^2.3.0: - version "2.3.0" - resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.0.tgz#cfae54d12853592e0ec455af490fd6867e26875e" - integrity sha512-b6TVXtF01VErh8IxN/MfdiWLQmttrenN98PPGS01kym8kGycJ9tqBXD6D+4sNEDhgE83+H0Mk1cVSl0mD1nNSg== +loupe@^2.3.1: + version "2.3.2" + resolved "https://registry.yarnpkg.com/loupe/-/loupe-2.3.2.tgz#799a566ba5aa8d11b93ddccc92c569bbae7e9490" + integrity sha512-QgVamnvj0jX1LMPlCAq0MK6hATORFtGqHoUKXTkwNe13BqlN6aePQCKnnTcFvdDYEEITcJ+gBl4mTW7YJtJbyQ== dependencies: get-func-name "^2.0.0" - type-detect "^4.0.8" lower-case-first@^1.0.0: version "1.0.2" @@ -10020,6 +10007,11 @@ nano-time@1.0.0: dependencies: big-integer "^1.6.16" +nanoclone@^0.2.1: + version "0.2.1" + resolved "https://registry.yarnpkg.com/nanoclone/-/nanoclone-0.2.1.tgz#dd4090f8f1a110d26bb32c49ed2f5b9235209ed4" + integrity sha512-wynEP02LmIbLpcYw8uBKpcfF6dmg2vcpKqxeH5UcoKEYdExslsdUA4ugFauuaeYdTB76ez6gJW8XAZ6CgkXYxA== + nanoid@^3.1.23, nanoid@^3.1.28, nanoid@^3.1.30: version "3.2.0" resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.2.0.tgz#62667522da6673971cca916a6d3eff3f415ff80c" @@ -11230,6 +11222,11 @@ prop-types@^15.0.0, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2: object-assign "^4.1.1" react-is "^16.8.1" +property-expr@^2.0.4: + version "2.0.5" + resolved "https://registry.yarnpkg.com/property-expr/-/property-expr-2.0.5.tgz#278bdb15308ae16af3e3b9640024524f4dc02cb4" + integrity sha512-IJUkICM5dP5znhCckHSv30Q4b5/JA5enCtkRHYaOVOAocnH/1BQEYTC5NMfT3AVl/iXKdr3aqQbQn9DxyWknwA== + property-information@^5.0.0, property-information@^5.3.0: version "5.6.0" resolved "https://registry.npmjs.org/property-information/-/property-information-5.6.0.tgz" @@ -13234,10 +13231,10 @@ tinypool@^0.1.1: resolved "https://registry.yarnpkg.com/tinypool/-/tinypool-0.1.1.tgz#99eaf29d030feeca2da6c1d6b33f90fc18093bc7" integrity sha512-sW2fQZ2BRb/GX5v55NkHiTrbMLx0eX0xNpP+VGhOe2f7Oo04+LeClDyM19zCE/WCy7jJ8kzIJ0Ojrxj3UhN9Sg== -tinyspy@^0.2.8: - version "0.2.8" - resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-0.2.8.tgz#b821b3d43a7d5ae47bc575a5d8627e84fdf4e809" - integrity sha512-4VXqQzzh9gC5uOLk77cLr9R3wqJq07xJlgM9IUdCNJCet139r+046ETKbU1x7mGs7B0k7eopyH5U6yflbBXNyA== +tinyspy@^0.2.10: + version "0.2.10" + resolved "https://registry.yarnpkg.com/tinyspy/-/tinyspy-0.2.10.tgz#7f684504bda345620f7a6a8462c618ef3d055517" + integrity sha512-Qij6rGWCDjWIejxCXXVi6bNgvrYBp3PbqC4cBP/0fD6WHDOHCw09Zd13CsxrDqSR5PFq01WeqDws8t5lz5sH0A== title-case@^2.1.0: version "2.1.1" @@ -13318,6 +13315,11 @@ token-transformer@^0.0.17: dependencies: yargs "^17.3.1" +toposort@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/toposort/-/toposort-2.0.2.tgz#ae21768175d1559d48bef35420b2f4962f09c330" + integrity sha1-riF2gXXRVZ1IvvNUILL0li8JwzA= + tough-cookie@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.0.0.tgz" @@ -13437,7 +13439,7 @@ type-check@~0.3.2: dependencies: prelude-ls "~1.1.2" -type-detect@^4.0.0, type-detect@^4.0.5, type-detect@^4.0.8: +type-detect@^4.0.0, type-detect@^4.0.5: version "4.0.8" resolved "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz" integrity sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g== @@ -13937,17 +13939,17 @@ vite@^2.7.10: optionalDependencies: fsevents "~2.3.2" -vitest@^0.2.3: - version "0.2.3" - resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.2.3.tgz#aee880ed31ece34dacb215870924f7696b7ae89a" - integrity sha512-hkJpXkJJ52yyBrnoyqox6eoo4mzYmlm+OwSNSFqhMidY75wEGV1/DsAR6kx6WLucI3CtlToAzfI94WZGfgRUrg== +vitest@^0.2.6: + version "0.2.6" + resolved "https://registry.yarnpkg.com/vitest/-/vitest-0.2.6.tgz#1a658c3deec3893f34d5a0b00f81da7329fd1b1e" + integrity sha512-qjjWJm+rpmqOmG3uSoFAh/8m9iZWzYmoWJTqqLKZYKRZSo0P+ibC05Atu8l1JRUrEgsgJIGot4JcPO4N42TX9Q== dependencies: "@types/chai" "^4.3.0" "@types/chai-subset" "^1.3.3" - chai "^4.3.4" + chai "^4.3.6" local-pkg "^0.4.1" tinypool "^0.1.1" - tinyspy "^0.2.8" + tinyspy "^0.2.10" vite ">=2.7.13" vm-browserify@^1.0.1: @@ -14387,6 +14389,19 @@ yocto-queue@^0.1.0: resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== +yup@^0.32.11: + version "0.32.11" + resolved "https://registry.yarnpkg.com/yup/-/yup-0.32.11.tgz#d67fb83eefa4698607982e63f7ca4c5ed3cf18c5" + integrity sha512-Z2Fe1bn+eLstG8DRR6FTavGD+MeAwyfmouhHsIUgaADz8jvFKbO/fXc2trJKZg+5EBjh4gGm3iU/t3onKlXHIg== + dependencies: + "@babel/runtime" "^7.15.4" + "@types/lodash" "^4.14.175" + lodash "^4.17.21" + lodash-es "^4.17.21" + nanoclone "^0.2.1" + property-expr "^2.0.4" + toposort "^2.0.2" + zwitch@^1.0.0: version "1.0.5" resolved "https://registry.npmjs.org/zwitch/-/zwitch-1.0.5.tgz"