diff --git a/apps/api/test/rooms.test.ts b/apps/api/test/rooms.test.ts index a182ca7e..1b632714 100644 --- a/apps/api/test/rooms.test.ts +++ b/apps/api/test/rooms.test.ts @@ -166,6 +166,24 @@ describe('POST /box on a chosen GPU', () => { expect(box.gpuPciId).toBeNull(); }); + test('a fleet room is found by the name a client shows, without the vendor', async () => { + await fleet([[V710]]); + const p = await person('fleet-short-name', 'paid'); + const res = await post(p.headers, { on: 'fleet', model: 'Radeon Pro V710' }); + expect(res.status).toBe(201); + expect((await json(res)).data.gpuPciId).toBe(V710.pciId); + }); + + test('a box is named by the card it is pinned to, not its host\'s first card', async () => { + const p = await person('pinned-name'); + const host = await Fixtures.machine(p.owner, 'two-cards'); + await Machine.setGpus({ id: host, gpus: [BIG, V710] }); + const res = await post(p.headers, { on: 'own', machineId: host, pciId: V710.pciId }); + expect(res.status).toBe(201); + const listed = (await json(await app.request('/box', { headers: p.headers }))).data; + expect(listed.find((b: { machineId: string }) => b.machineId === host).gpu).toBe(V710.model); + }); + test('a fleet room is placed by model on the least-loaded machine with one free', async () => { const f = await fleet([[V710], [V710]]); const a = await person('fleet-model-a'); diff --git a/packages/core/src/billing/checkout-room.ts b/packages/core/src/billing/checkout-room.ts index 8d3b6030..858f207b 100644 --- a/packages/core/src/billing/checkout-room.ts +++ b/packages/core/src/billing/checkout-room.ts @@ -32,7 +32,7 @@ export namespace CheckoutRoom { /** Refuse a model the fleet does not have. */ export async function assertKnown(model: string): Promise { - if (!(await knownModels()).includes(model)) { + if (!(await knownModels()).some((m) => Rooms.sameModel(m, model))) { throw new VisibleError( 'validation', ErrorCodes.Validation.INVALID_PARAMETER, @@ -54,9 +54,10 @@ export namespace CheckoutRoom { cards: readonly Placement.Card[] ): { model: string; substituted: boolean } | null { const free = cards.filter((c) => c.free); - if (free.some((c) => c.model === requested)) return { model: requested, substituted: false }; + const exact = free.find((c) => Rooms.sameModel(c.model, requested)); + if (exact) return { model: exact.model, substituted: false }; if (free.length === 0) return null; - const wanted = cards.find((c) => c.model === requested); + const wanted = cards.find((c) => Rooms.sameModel(c.model, requested)); const wantedVendor = wanted ? vendor(wanted.pciId) : ''; const wantedVram = wanted?.vramMib ?? 0; const rank = (c: Placement.Card) => [ diff --git a/packages/core/src/billing/rooms.ts b/packages/core/src/billing/rooms.ts index 07626ba6..e9579685 100644 --- a/packages/core/src/billing/rooms.ts +++ b/packages/core/src/billing/rooms.ts @@ -32,6 +32,24 @@ export namespace Rooms { return entryModels().some((m) => name.includes(m.toLowerCase())); } + /** + * A card's name without the vendor: clients show `RTX 3060` and + * `Radeon RX 9060 XT`, hosts report `NVIDIA GeForce RTX 3060` and + * `AMD Radeon RX 9060 XT`. + */ + export function bareModel(model: string): string { + let m = model.trim(); + for (const prefix of ['NVIDIA ', 'GeForce ', 'AMD ', 'Intel(R) ', 'Intel ']) { + if (m.toLowerCase().startsWith(prefix.toLowerCase())) m = m.slice(prefix.length); + } + return m.toLowerCase(); + } + + /** Whether two names are one card, with or without the vendor. */ + export function sameModel(a: string, b: string): boolean { + return bareModel(a) === bareModel(b); + } + export function isPro(plan: string | null | undefined): boolean { return plan === 'paid'; } diff --git a/packages/core/src/box/index.ts b/packages/core/src/box/index.ts index 2e8a2031..03deff9e 100644 --- a/packages/core/src/box/index.ts +++ b/packages/core/src/box/index.ts @@ -177,7 +177,7 @@ export namespace Box { .orderBy(BoxTable.timeCreated) .then((rows) => rows.map((r) => - serialize(r.box, Machine.gpuName(r.gpus), { + serialize(r.box, Machine.pinnedName(r.gpus, r.box), { host: r.host, fleet: r.organisationId !== null }) diff --git a/packages/core/src/box/placement.ts b/packages/core/src/box/placement.ts index d84526f5..17458666 100644 --- a/packages/core/src/box/placement.ts +++ b/packages/core/src/box/placement.ts @@ -338,7 +338,9 @@ export namespace Placement { export const onFleetModel = (model: string): Placer => async () => { - const free = (await lockedFreeCards(fleetOrganisation())).filter((c) => c.model === model); + const free = (await lockedFreeCards(fleetOrganisation())).filter((c) => + Rooms.sameModel(c.model, model) + ); if (free.length === 0) { throw new VisibleError( 'rate_limit', diff --git a/packages/core/src/machine/index.ts b/packages/core/src/machine/index.ts index 04cebeb3..246b4a95 100644 --- a/packages/core/src/machine/index.ts +++ b/packages/core/src/machine/index.ts @@ -85,6 +85,20 @@ export namespace Machine { return gpus?.[0]?.model ?? null; } + /** + * The card a box is pinned to, by its slot, else its PCI id; the host's + * first card for a box that names none. + */ + export function pinnedName( + gpus: readonly { model: string; pciId: string; slot?: string }[] | null | undefined, + pin: { gpuPciId?: string | null; gpuSlot?: string | null } + ): string | null { + const card = + (pin.gpuSlot ? gpus?.find((g) => g.slot === pin.gpuSlot) : undefined) ?? + (pin.gpuPciId ? gpus?.find((g) => g.pciId === pin.gpuPciId) : undefined); + return card?.model ?? gpuName(gpus); + } + export const Info = z .object({ id: z.string().meta({