diff --git a/mock-api/msw/db.ts b/mock-api/msw/db.ts index d04c37a2b..f9e1b26ed 100644 --- a/mock-api/msw/db.ts +++ b/mock-api/msw/db.ts @@ -24,17 +24,33 @@ export const notFoundErr = (msg: string) => { return json({ error_code: 'ObjectNotFound', message } as const, { status: 404 }) } -export const badSelectorErr = (resource: string, parents: string[]) => { - const message = `when ${resource} is specified by ID, ${commaSeries(parents, 'and')} should not be specified` - return json({ error_code: 'InvalidRequest', message }, { status: 400 }) -} - export const lookupById = (table: T[], id: string) => { const item = table.find((i) => i.id === id) if (!item) throw notFoundErr(`by id ${id}`) return item } +/** + * Given an object representing (potentially) parent selectors for a resource, + * throw an error if any of the keys in that object have truthy values. For + * example, if selecting an instance by ID, we would pass in an object with + * `project` as the key and error out if and only if `parentSelector.project` + * is present. + */ +function ensureNoParentSelectors( + /** Resource name to be used in error message */ + resourceLabel: string, + parentSelector: Record +) { + const keysWithValues = Object.entries(parentSelector) + .filter(([_, v]) => v) + .map(([k]) => k) + if (keysWithValues.length > 0) { + const message = `when ${resourceLabel} is specified by ID, ${commaSeries(keysWithValues, 'and')} should not be specified` + throw json({ error_code: 'InvalidRequest', message }, { status: 400 }) + } +} + export const getIpFromPool = (poolName: string | undefined) => { const pool = lookup.ipPool({ pool: poolName }) const ipPoolRange = db.ipPoolRanges.find((range) => range.ip_pool_id === pool.id) @@ -61,7 +77,10 @@ export const lookup = { instance({ instance: id, ...projectSelector }: PP.Instance): Json { if (!id) throw notFoundErr('no instance specified') - if (isUuid(id)) return lookupById(db.instances, id) + if (isUuid(id)) { + ensureNoParentSelectors('instance', projectSelector) + return lookupById(db.instances, id) + } const project = lookup.project(projectSelector) const instance = db.instances.find((i) => i.project_id === project.id && i.name === id) @@ -75,7 +94,10 @@ export const lookup = { }: PP.NetworkInterface): Json { if (!id) throw notFoundErr('no NIC specified') - if (isUuid(id)) return lookupById(db.networkInterfaces, id) + if (isUuid(id)) { + ensureNoParentSelectors('network interface', instanceSelector) + return lookupById(db.networkInterfaces, id) + } const instance = lookup.instance(instanceSelector) @@ -89,7 +111,10 @@ export const lookup = { disk({ disk: id, ...projectSelector }: PP.Disk): Json { if (!id) throw notFoundErr('no disk specified') - if (isUuid(id)) return lookupById(db.disks, id) + if (isUuid(id)) { + ensureNoParentSelectors('disk', projectSelector) + return lookupById(db.disks, id) + } const project = lookup.project(projectSelector) @@ -102,7 +127,7 @@ export const lookup = { if (!id) throw notFoundErr('no floating IP specified') if (isUuid(id)) { - if (projectSelector.project) throw badSelectorErr('floating IP', ['project']) + ensureNoParentSelectors('floating IP', projectSelector) return lookupById(db.floatingIps, id) } @@ -117,7 +142,10 @@ export const lookup = { snapshot({ snapshot: id, ...projectSelector }: PP.Snapshot): Json { if (!id) throw notFoundErr('no snapshot specified') - if (isUuid(id)) return lookupById(db.snapshots, id) + if (isUuid(id)) { + ensureNoParentSelectors('snapshot', projectSelector) + return lookupById(db.snapshots, id) + } const project = lookup.project(projectSelector) const snapshot = db.snapshots.find((i) => i.project_id === project.id && i.name === id) @@ -128,7 +156,10 @@ export const lookup = { vpc({ vpc: id, ...projectSelector }: PP.Vpc): Json { if (!id) throw notFoundErr('no VPC specified') - if (isUuid(id)) return lookupById(db.vpcs, id) + if (isUuid(id)) { + ensureNoParentSelectors('vpc', projectSelector) + return lookupById(db.vpcs, id) + } const project = lookup.project(projectSelector) const vpc = db.vpcs.find((v) => v.project_id === project.id && v.name === id) @@ -139,7 +170,10 @@ export const lookup = { vpcRouter({ router: id, ...vpcSelector }: PP.VpcRouter): Json { if (!id) throw notFoundErr('no router specified') - if (isUuid(id)) return lookupById(db.vpcRouters, id) + if (isUuid(id)) { + ensureNoParentSelectors('router', vpcSelector) + return lookupById(db.vpcRouters, id) + } const vpc = lookup.vpc(vpcSelector) const router = db.vpcRouters.find((r) => r.vpc_id === vpc.id && r.name === id) @@ -153,7 +187,10 @@ export const lookup = { }: PP.VpcRouterRoute): Json { if (!id) throw notFoundErr('no route specified') - if (isUuid(id)) return lookupById(db.vpcRouterRoutes, id) + if (isUuid(id)) { + ensureNoParentSelectors('route', routerSelector) + return lookupById(db.vpcRouterRoutes, id) + } const router = lookup.vpcRouter(routerSelector) const route = db.vpcRouterRoutes.find( @@ -166,7 +203,10 @@ export const lookup = { vpcSubnet({ subnet: id, ...vpcSelector }: PP.VpcSubnet): Json { if (!id) throw notFoundErr('no subnet specified') - if (isUuid(id)) return lookupById(db.vpcSubnets, id) + if (isUuid(id)) { + ensureNoParentSelectors('subnet', vpcSelector) + return lookupById(db.vpcSubnets, id) + } const vpc = lookup.vpc(vpcSelector) const subnet = db.vpcSubnets.find((s) => s.vpc_id === vpc.id && s.name === id) @@ -177,16 +217,33 @@ export const lookup = { image({ image: id, project: projectId }: PP.Image): Json { if (!id) throw notFoundErr('no image specified') - if (isUuid(id)) return lookupById(db.images, id) + // We match the API logic: + // + // match image_selector { + // (image ID, no project) => + // look up image in DB by ID + // if project ID is present, it's a project image, otherwise silo image + // (image Name, project specified) => it's a project image + // (image Name, no project) => it's a silo image + // (image ID, project specified) => error + // } + // + // https://github.com/oxidecomputer/omicron/blob/219121a/nexus/src/app/image.rs#L32-L76 + + if (isUuid(id)) { + // this matches the error case above + ensureNoParentSelectors('image', { project: projectId }) + return lookupById(db.images, id) + } let image: Json | undefined - if (projectId === undefined) { - // silo image - image = db.images.find((d) => d.project_id === undefined && d.name === id) - } else { + if (projectId) { // project image const project = lookup.project({ project: projectId }) image = db.images.find((d) => d.project_id === project.id && d.name === id) + } else { + // silo image + image = db.images.find((d) => d.project_id === undefined && d.name === id) } if (!image) throw notFoundErr(`image '${id}'`) @@ -262,8 +319,15 @@ export const lookup = { samlIdp({ provider: id, silo }: PP.IdentityProvider): Json { if (!id) throw notFoundErr('no IdP specified') - const dbSilo = lookup.silo({ silo }) + if (isUuid(id)) { + ensureNoParentSelectors('identity provider', { silo }) + return lookupById( + db.identityProviders.filter((o) => o.type === 'saml').map((o) => o.provider), + id + ) + } + const dbSilo = lookup.silo({ silo }) const dbIdp = db.identityProviders.find( ({ type, siloId, provider }) => type === 'saml' && siloId === dbSilo.id && provider.name === id diff --git a/mock-api/msw/handlers.ts b/mock-api/msw/handlers.ts index 29dff60b8..8089c8351 100644 --- a/mock-api/msw/handlers.ts +++ b/mock-api/msw/handlers.ts @@ -7,7 +7,7 @@ */ import { delay } from 'msw' import * as R from 'remeda' -import { v4 as uuid } from 'uuid' +import { validate as isUuid, v4 as uuid } from 'uuid' import { diskCan, @@ -288,15 +288,22 @@ export const handlers = makeHandlers({ return 204 }, - floatingIpAttach({ path, query, body }) { - const floatingIp = lookup.floatingIp({ ...path, ...query }) - if (floatingIp.instance_id) { + floatingIpAttach({ path: { floatingIp }, query: { project }, body }) { + const dbFloatingIp = lookup.floatingIp({ floatingIp, project }) + if (dbFloatingIp.instance_id) { throw 'floating IP cannot be attached to one instance while still attached to another' } - const instance = lookup.instance({ ...path, ...query, instance: body.parent }) - floatingIp.instance_id = instance.id + // Following the API logic here, which says that when the instance is passed + // by name, we pull the project ID off the floating IP. + // + // https://github.com/oxidecomputer/omicron/blob/e434307/nexus/src/app/external_ip.rs#L171-L201 + const dbInstance = lookup.instance({ + instance: body.parent, + project: isUuid(body.parent) ? undefined : project, + }) + dbFloatingIp.instance_id = dbInstance.id - return floatingIp + return dbFloatingIp }, floatingIpDetach({ path, query }) { const floatingIp = lookup.floatingIp({ ...path, ...query }) @@ -359,13 +366,16 @@ export const handlers = makeHandlers({ return json(image, { status: 202 }) }, - imageDemote({ path, query }) { - const image = lookup.image({ ...path, ...query }) - const project = lookup.project({ ...path, ...query }) + imageDemote({ path: { image }, query: { project } }) { + // unusual case because the project is never used to resolve the image. you + // can only demote silo images, and whether we have an image name or ID, if + // there is no project specified, the lookup assumes it's a silo image + const dbImage = lookup.image({ image }) + const dbProject = lookup.project({ project }) - image.project_id = project.id + dbImage.project_id = dbProject.id - return json(image, { status: 202 }) + return json(dbImage, { status: 202 }) }, instanceList({ query }) { const project = lookup.project(query)