diff --git a/src/derive/bom.ts b/src/derive/bom.ts index 73592e3..2b03e38 100644 --- a/src/derive/bom.ts +++ b/src/derive/bom.ts @@ -311,13 +311,22 @@ function addServerGroup(bom: BomBuilder, group: ServerGroup): void { // Drives per node: the configuration's list, or the default pair; an // explicitly empty list orders none. - for (const drive of config.drives ?? drivesForServer(group.modelId)) { + const drives = config.drives ?? drivesForServer(group.modelId) + for (const drive of drives) { bom.add( drive.modelId, count * drive.perNode, `${who} × ${drive.perNode} drive${drive.perNode === 1 ? '' : 's'}`, ) } + for (const bay of catalog[group.modelId]?.driveBays ?? []) { + if ( + bay.adapterModelId && + drives.some((drive) => drive.perNode > 0 && catalog[drive.modelId]?.driveType === bay.type) + ) { + bom.add(bay.adapterModelId, count, `${who} × 1 expansion card for fitted M.2 drives`) + } + } } addNodeUplinks(bom, group.count, group.uplink, role) diff --git a/src/derive/orv3.test.ts b/src/derive/orv3.test.ts index f86fe71..50cd86d 100644 --- a/src/derive/orv3.test.ts +++ b/src/derive/orv3.test.ts @@ -234,9 +234,10 @@ describe('ORV3 rack and power BOM', () => { ).toBe(true) rack.servers[0].count = 83 expect( - validatePlan(plan).some((issue) => issue.message.includes('includes 4OU of power shelves')), + validatePlan(plan).some((issue) => + issue.message.includes('Rack height exceeded: 91OU of 44OU used.'), + ), ).toBe(true) - expect(deriveRackLayout(plan)[0].racks[1].usedU).toBe(91) }) }) @@ -288,7 +289,10 @@ describe('ORV3 racks', () => { expect(qty('cpu-xeon-4509y')).toBe(3) expect(qty('mem-ddr5r-16g')).toBe(6) expect(qty('nic-e810-xxvda2')).toBe(3) - expect(bom.filter((line) => line.category === 'drive')).toEqual([]) + expect(qty('drive-m2-sata-480')).toBe(3) + expect(qty('drive-e1s-1920')).toBe(3) + expect(qty('drive-m2-480')).toBe(0) + expect(qty('drive-nvme-960')).toBe(0) expect(rackBmcPorts(rack)).toBe(3) const bmc = bom.find((line) => line.catalogId === 'cable-rj45')! expect( diff --git a/src/derive/orv3Components.test.ts b/src/derive/orv3Components.test.ts new file mode 100644 index 0000000..67a08a8 --- /dev/null +++ b/src/derive/orv3Components.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest' +import { catalog, cpusForServer } from '../model/catalog' +import { createEmptyPlan } from '../model/defaults' +import { exportPlanJson, importPlanJson } from '../io/json' +import { deriveBom } from './bom' +import { validatePlan } from './validate' + +function sledPlan(modelId = 'server-gigabyte-to25-z12') { + const plan = createEmptyPlan() + const rack = plan.partitions[0].racks[0] + rack.widthInches = 21 + rack.servers[0].modelId = modelId + rack.servers[0].count = 3 + return plan +} + +function quantities(plan: ReturnType) { + return Object.fromEntries(deriveBom(plan).map((line) => [line.catalogId, line.quantity])) +} + +function errors(plan: ReturnType) { + return validatePlan(plan) + .filter((issue) => issue.severity === 'error') + .map((issue) => issue.message) +} + +function warnings(plan: ReturnType) { + return validatePlan(plan) + .filter((issue) => issue.severity === 'warning') + .map((issue) => issue.message) +} + +describe('ORv3 sled components', () => { + it('orders AMD CPU, RAM and storage per node, with shared trays', () => { + const plan = sledPlan() + expect(quantities(plan)).toMatchObject({ + 'cpu-epyc-9015': 3, + 'mem-ddr5r-16g': 6, + 'chassis-gigabyte-to25-bt0': 2, + 'drive-m2-480': 3, + 'drive-e1s-1920': 3, + 'adapter-gigabyte-cmtp192': 3, + 'rack-adapter-19-to-21': 3, // Switches only; the internal card needs no rack kit. + }) + expect(errors(plan)).toEqual([]) + expect( + validatePlan(plan).some((issue) => /not modeled|no drives configured/.test(issue.message)), + ).toBe(false) + }) + + it.each([ + ['server-gigabyte-to25-s12', 'cpu-xeon-6542y', 250], + ['server-gigabyte-to25-z12', 'cpu-epyc-9254', 240], + ] as const)( + 'filters and validates CPU TDP on %s, including per-position overrides', + (modelId, cpuId, limit) => { + const original = catalog[cpuId] + const plan = sledPlan(modelId) + const group = plan.partitions[0].racks[0].servers[0] + group.nodeConfigs = { 1: { sizeId: group.sizeId, compute: { cpuModelId: cpuId } } } + try { + catalog[cpuId] = { ...original, powerWatts: limit } + expect(cpusForServer(modelId).map((cpu) => cpu.id)).toContain(cpuId) + expect(errors(plan)).toEqual([]) + + catalog[cpuId] = { ...original, powerWatts: limit + 1 } + expect(cpusForServer(modelId).map((cpu) => cpu.id)).not.toContain(cpuId) + expect(errors(plan)).toContain( + `Chassis position 2: ${original.model} has a stock TDP of ${limit + 1} W, but ${catalog[modelId].model} supports CPUs up to ${limit} W.`, + ) + } finally { + catalog[cpuId] = original + } + }, + ) + + it.each(['server-gigabyte-to25-s12', 'server-gigabyte-to25-z12'])( + 'accepts DIMM counts within slot capacity and faster-rated RDIMMs on %s', + (modelId) => { + const plan = sledPlan(modelId) + const group = plan.partitions[0].racks[0].servers[0] + group.compute = { dimmModelId: 'mem-ddr5r-6400-16g', dimmsPerNode: 3 } + expect(warnings(plan).filter((message) => /DIMM|RDIMM/.test(message))).toEqual([]) + expect(errors(plan)).toEqual([]) + group.compute.dimmsPerNode = 4 + expect(warnings(plan).filter((message) => /DIMM|RDIMM/.test(message))).toEqual([]) + expect(errors(plan)).toEqual([]) + }, + ) + + it('reports AMD revision qualification for the effective chassis-position CPU', () => { + const plan = sledPlan() + const group = plan.partitions[0].racks[0].servers[0] + group.sizeId = 'c1-large-x86' + expect(warnings(plan).some((message) => message.includes('EPYC 9005-qualified'))).toBe(false) + group.nodeConfigs = { 1: { sizeId: 'n1-medium-x86' } } + expect(warnings(plan)).toContain( + `Chassis position 2: ${catalog[group.modelId].cpuCompatibilityNotes!['cpu-epyc-9015']}`, + ) + }) + + it('reports Intel 24 GB RAM qualification for the effective chassis-position DIMM', () => { + const plan = sledPlan('server-gigabyte-to25-s12') + const group = plan.partitions[0].racks[0].servers[0] + group.nodeConfigs = { + 1: { sizeId: 'c1-large-x86', compute: { dimmModelId: 'mem-ddr5r-24g', dimmsPerNode: 8 } }, + } + expect(warnings(plan)).toContain( + `Chassis position 2: ${catalog[group.modelId].dimmCompatibilityNotes!['mem-ddr5r-24g']}`, + ) + group.nodeConfigs[1].compute!.dimmModelId = 'mem-ddr5r-32g' + expect(warnings(plan).some((message) => message.includes('24 Gbit'))).toBe(false) + }) + + it('round-trips drive overrides and counts adapters only for occupied sled positions', () => { + const plan = sledPlan() + const group = plan.partitions[0].racks[0].servers[0] + group.drives = [] + group.nodeConfigs = { + 0: { + sizeId: group.sizeId, + drives: [ + { modelId: 'drive-m2-480', perNode: 1 }, + { modelId: 'drive-e1s-3840', perNode: 4 }, + ], + }, + } + const imported = importPlanJson(exportPlanJson(plan)) + expect(quantities(imported)).toMatchObject({ + 'drive-e1s-3840': 8, + 'drive-m2-480': 2, + 'adapter-gigabyte-cmtp192': 2, + }) + expect(errors(imported)).toEqual([]) + }) + + it('omits drives and their adapter when the drive list is explicitly empty', () => { + const plan = sledPlan() + plan.partitions[0].racks[0].servers[0].drives = [] + expect(deriveBom(plan).filter((line) => line.category === 'drive')).toEqual([]) + expect(quantities(plan)['adapter-gigabyte-cmtp192']).toBeUndefined() + expect(warnings(plan).some((message) => message.includes('no drives configured'))).toBe(true) + }) + + it('accepts twelve AMD DIMMs and rejects a thirteenth', () => { + const plan = sledPlan() + const group = plan.partitions[0].racks[0].servers[0] + group.compute = { dimmsPerNode: 12 } + expect(errors(plan)).toEqual([]) + group.compute.dimmsPerNode = 13 + expect(errors(plan)).toContain( + 'TO25-Z12-AA01 supports 12 DIMM slots per CPU: 12 available, 13 configured.', + ) + }) + + it.each([ + ['server-gigabyte-to25-s12', 'drive-m2-480'], + ['server-gigabyte-to25-z12', 'drive-m2-sata-480'], + ['server-gigabyte-to25-z12', 'drive-nvme-960'], + ])('rejects incompatible %s drive %s', (modelId, drive) => { + const plan = sledPlan(modelId) + plan.partitions[0].racks[0].servers[0].drives = [{ modelId: drive, perNode: 1 }] + expect(errors(plan)).toContain( + `${catalog[drive].description} does not fit ${catalog[modelId].model} drive bays.`, + ) + }) + + it.each([ + ['server-gigabyte-to25-s12', 2], + ['server-gigabyte-to25-z12', 4], + ])('sums E1.S occupancy across different drive rows for %s', (modelId, bays) => { + const plan = sledPlan(modelId) + const group = plan.partitions[0].racks[0].servers[0] + group.drives = [ + { modelId: 'drive-e1s-1920', perNode: bays - 1 }, + { modelId: 'drive-e1s-3840', perNode: 1 }, + ] + expect(errors(plan)).toEqual([]) + group.drives[1].perNode = 2 + expect(errors(plan)).toContain( + `${catalog[modelId].model} has ${bays} E1.S bays per node, but ${bays + 1} drives are configured.`, + ) + }) + + it('flags duplicate M.2 rows and counts one card per node', () => { + const plan = sledPlan() + plan.partitions[0].racks[0].servers[0].drives = [ + { modelId: 'drive-m2-480', perNode: 1 }, + { modelId: 'drive-m2-480', perNode: 1 }, + ] + expect(errors(plan)).toContain( + 'TO25-Z12-AA01 has 1 M.2 bays per node, but 2 drives are configured.', + ) + expect(quantities(plan)['adapter-gigabyte-cmtp192']).toBe(3) + }) +}) diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index 6fc50d2..d604cdb 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -122,13 +122,19 @@ describe('node compute and NIC configuration', () => { expect(errors(plan)).toEqual([]) g.compute.dimmsPerNode = perCpu + 1 expect( - errors(plan).some((m) => m.includes(`has ${perCpu} DIMM slots per node with 1 CPU`)), + errors(plan).some((m) => + m.includes(`${perCpu} DIMM slots per CPU: ${perCpu} available, ${perCpu + 1} configured`), + ), ).toBe(true) g.compute = { cpuCount: 2, dimmsPerNode: perCpu * 2 } expect(errors(plan)).toEqual([]) g.compute.dimmsPerNode = perCpu * 2 + 1 expect( - errors(plan).some((m) => m.includes(`has ${perCpu * 2} DIMM slots per node with 2 CPUs`)), + errors(plan).some((m) => + m.includes( + `${perCpu} DIMM slots per CPU: ${perCpu * 2} available, ${perCpu * 2 + 1} configured`, + ), + ), ).toBe(true) }) @@ -158,7 +164,7 @@ describe('node compute and NIC configuration', () => { g.compute = { cpuCount: 2, dimmsPerNode: 16 } g.nodeConfigs = { 1: { sizeId: g.sizeId, compute: { dimmsPerNode: 16 } } } expect(errors(plan)).toEqual([ - 'Chassis position 2: SYS-220BT-HNTR has 8 DIMM slots per node with 1 CPU installed, but 16 DIMMs per node are configured.', + 'Chassis position 2: SYS-220BT-HNTR supports 8 DIMM slots per CPU: 8 available, 16 configured.', ]) g.nodeConfigs[1].compute!.cpuCount = 2 expect(errors(plan)).toEqual([]) @@ -210,7 +216,7 @@ describe('node compute and NIC configuration', () => { const plan = basePlan() group(plan).compute = { dimmsPerNode: 6 } expect(errors(plan)).toContain( - 'AS-3015MR-H8TNR has 4 DIMM slots per node, but 6 DIMMs per node are configured.', + 'AS-3015MR-H8TNR supports 4 DIMM slots per CPU: 4 available, 6 configured.', ) }) diff --git a/src/derive/validate.ts b/src/derive/validate.ts index ad89375..689aace 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -1,12 +1,14 @@ import { availableDimmSlots, catalog, + cpuFitsServer, dimmTypeForSocket, gpuFits, itemLabel, fitsRack, rackUnit, drivesForServer, + driveFitsServer, nicFitsServer, portCount, uplinkPortSpeed, @@ -263,7 +265,7 @@ function validateRack(issues: Issue[], partition: Partition, rack: Rack): void { issues, scope, 'warning', - `${itemLabel(group.modelId)}: no drives configured. Select compatible drives separately; the catalog does not yet model this sled's E1.S and boot-drive options.`, + `${itemLabel(group.modelId)}: no drives configured. Add a compatible M.2 or E1.S drive if local storage is required.`, ) } const label = positionListLabel(bucket.positions) @@ -271,7 +273,7 @@ function validateRack(issues: Issue[], partition: Partition, rack: Rack): void { checkGpu(issues, scope, group, bucket.config, prefix) checkCompute(issues, scope, group, bucket.config, prefix) checkNic(issues, scope, group, bucket.config, prefix) - checkDrives(issues, scope, bucket.config, prefix) + checkDrives(issues, scope, group.modelId, bucket.config, prefix) } } } @@ -380,6 +382,16 @@ function checkCompute( 'error', `${prefix}${itemLabel(cpu.id)} (${cpu.socket}) does not fit ${itemLabel(group.modelId)} (${socket}).`, ) + } else if (!cpuFitsServer(cpu, server)) { + report( + issues, + scope, + 'error', + `${prefix}${itemLabel(cpu.id)} has a stock TDP of ${cpu.powerWatts ?? 'unknown'} W, ` + + `but ${itemLabel(group.modelId)} supports CPUs up to ${server.maxCpuTdpWatts} W.`, + ) + } else if (server.cpuCompatibilityNotes?.[cpu.id]) { + report(issues, scope, 'warning', `${prefix}${server.cpuCompatibilityNotes[cpu.id]}`) } } @@ -408,6 +420,8 @@ function checkCompute( `${prefix}${itemLabel(group.modelId)} requires ${server.requiredDimmRatedSpeed} MT/s-rated DIMMs, ` + `but ${itemLabel(dimm.id)} is rated ${dimm.dimmRatedSpeed ?? 'unknown'} MT/s.`, ) + } else if (server.dimmCompatibilityNotes?.[dimm.id]) { + report(issues, scope, 'warning', `${prefix}${server.dimmCompatibilityNotes[dimm.id]}`) } } } @@ -418,11 +432,8 @@ function checkCompute( issues, scope, 'error', - `${prefix}${itemLabel(group.modelId)} has ${dimmSlots} DIMM slots per node` + - (server.cpuSockets - ? ` with ${cpuCount} CPU${cpuCount === 1 ? '' : 's'} installed, ` - : ', ') + - `but ${compute.dimmsPerNode} DIMMs per node are configured.`, + `${prefix}${itemLabel(group.modelId)} supports ${availableDimmSlots(group.modelId)} DIMM slots per CPU: ` + + `${dimmSlots} available, ${compute.dimmsPerNode} configured.`, ) } if (compute.dimmsPerNode && compute.dimmsPerNode < cpuCount) { @@ -436,12 +447,40 @@ function checkCompute( } } -/** Every configured drive must exist and be a drive. */ -function checkDrives(issues: Issue[], scope: Scope, config: NodeConfig, prefix: string): void { - for (const { modelId } of config.drives ?? []) { +function checkDrives( + issues: Issue[], + scope: Scope, + serverModelId: string, + config: NodeConfig, + prefix: string, +): void { + const drives = config.drives ?? drivesForServer(serverModelId) + for (const { modelId } of drives) { const drive = catalog[modelId] if (!drive || drive.category !== 'drive') { report(issues, scope, 'error', `${prefix}Unknown drive model "${modelId}".`) + } else if (!driveFitsServer(drive, serverModelId)) { + report( + issues, + scope, + 'error', + `${prefix}${drive.description} does not fit ${itemLabel(serverModelId)} drive bays.`, + ) + } + } + for (const bay of catalog[serverModelId]?.driveBays ?? []) { + const fitted = drives.reduce( + (sum, drive) => sum + (catalog[drive.modelId]?.driveType === bay.type ? drive.perNode : 0), + 0, + ) + if (fitted > bay.count) { + const label = bay.type === 'e1s-9.5-nvme' ? 'E1.S' : 'M.2' + report( + issues, + scope, + 'error', + `${prefix}${itemLabel(serverModelId)} has ${bay.count} ${label} bays per node, but ${fitted} drives are configured.`, + ) } } } @@ -710,21 +749,13 @@ export function validatePlan(plan: Plan): Issue[] { // Advanced section; the central rack's budget is partition-level. const inAdvanced = { where, target: { ...target, ...(rack.rackId && { field: 'advanced' }) } } const infrastructure = orv3Infrastructure(rack) - if (infrastructure) { + if (infrastructure && rack.heightUnits > infrastructure.usableHeightUnits) { report( issues, inAdvanced, 'warning', - `The BOM includes an ORV3 frame with bus bar, ${infrastructure.shelfCount} power shelves with ${infrastructure.psuCount} fitted PSU modules (N+N, ${infrastructure.psusPerFeed} per feed, sized to ${rack.powerWatts} W estimated equipment load), ${infrastructure.blankCount} slot blanks and their AC input cable sets for this physical rack. The diagram includes ${infrastructure.reservedHeightUnits}OU of power shelves in used height. Enter mounting height including power shelves; do not subtract shelf space manually. Confirm bus bar capacity, shelf configuration, site feeds and AC distribution for 19-inch devices with the supplier. Battery backup is optional and excluded. Shared rack infrastructure appears under Production in the BOM.`, + `Configured usable height ${rack.heightUnits}OU exceeds the reference ORV3 frame's ${infrastructure.usableHeightUnits}OU mounting capacity. Reduce usable height or arrange a different rack configuration with the supplier.`, ) - if (rack.heightUnits > infrastructure.usableHeightUnits) { - report( - issues, - inAdvanced, - 'warning', - `Configured usable height ${rack.heightUnits}OU exceeds the reference ORV3 frame's ${infrastructure.usableHeightUnits}OU mounting capacity. Reduce usable height or arrange a different rack configuration with the supplier.`, - ) - } } if (seen.has(rack.name)) { report( diff --git a/src/model/catalog.test.ts b/src/model/catalog.test.ts index 4d1697d..2dd4356 100644 --- a/src/model/catalog.test.ts +++ b/src/model/catalog.test.ts @@ -280,6 +280,12 @@ describe('reachability', () => { modelId: 'server-gigabyte-to25-s12', count: 3, }) + rack.servers.push({ + ...rack.servers[0], + id: 'orv3-amd', + modelId: 'server-gigabyte-to25-z12', + count: 1, + }) return plan } diff --git a/src/model/catalog.ts b/src/model/catalog.ts index c13719d..ec2c225 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -58,6 +58,7 @@ export type CatalogCategory = | 'switch' | 'server' | 'chassis' + | 'accessory' | 'rack' | 'power' | 'router' @@ -87,10 +88,12 @@ export type PortSpeed = '1G' | '10G' | '25G' | '100G' * of the X11 MicroCloud: no orderable CPUs exist for it, but its memory is * configurable like any other board's. */ export type CpuSocket = - 'AM5' | 'LGA-1700' | 'LGA-3647' | 'LGA-4189' | 'LGA-4677' | 'LGA-4710' | 'D-2100' + 'AM5' | 'SP5' | 'LGA-1700' | 'LGA-3647' | 'LGA-4189' | 'LGA-4677' | 'LGA-4710' | 'D-2100' export type DimmType = 'ddr5-ecc-udimm' | 'ddr5-rdimm' | 'ddr4-rdimm' +export type DriveType = 'm2-sata' | 'm2-nvme' | 'u3-nvme' | 'e1s-9.5-nvme' + export type GpuWidth = 'single' | 'double' export type NicFormFactor = 'siom' | 'micro-lp' @@ -99,6 +102,7 @@ export type NicFormFactor = 'siom' | 'micro-lp' // a specific DIMM speed rating, independently of their operating speed. export const dimmTypeForSocket: Record = { AM5: 'ddr5-ecc-udimm', + SP5: 'ddr5-rdimm', 'LGA-1700': 'ddr5-ecc-udimm', 'LGA-3647': 'ddr4-rdimm', 'LGA-4189': 'ddr4-rdimm', @@ -132,8 +136,11 @@ export interface CatalogItem { trayModelId?: string /** ORv3 sleds each have their own BMC, unlike the legacy chassis model. */ bmcPerNode?: boolean - /** Empty when the generic drive pair does not fit this platform. */ + /** Platform-specific defaults; an empty list deliberately orders no drives. */ defaultDrives?: DriveConfig[] + driveType?: DriveType + /** Verified bays per node, including any separately ordered adapter. */ + driveBays?: { type: DriveType; count: number; adapterModelId?: string }[] /** For multi-node chassis (MicroCloud, BigTwin): server nodes per chassis. */ nodesPerChassis?: number /** Typical power draw per chassis / device in watts, for rack power @@ -158,8 +165,13 @@ export interface CatalogItem { threads?: number /** CPU sockets on servers with modeled multi-CPU configuration. */ cpuSockets?: 2 + maxCpuTdpWatts?: number + /** Revision/firmware requirements that the plan cannot verify, keyed by CPU id. */ + cpuCompatibilityNotes?: Record /** Total ordinary DIMM slots per server node, across all CPU sockets. */ dimmSlots?: number + /** Additional qualification requirements, keyed by memory catalog id. */ + dimmCompatibilityNotes?: Record /** Memory module technology and capacity. */ dimmType?: DimmType dimmGiB?: number @@ -478,10 +490,23 @@ export const catalog: Record = { serverUsages: ['worker', 'storage'], socket: 'LGA-4677', dimmSlots: 8, + maxCpuTdpWatts: 250, + // User manual §§3-7-3/4: 24 Gbit restrictions. + // https://download.gigabyte.com/FileList/Manual/server_manual_e_TO25-S12-AA01_v1.pdf + dimmCompatibilityNotes: { + 'mem-ddr5r-24g': + '24 GB RDIMMs need CPU and BIOS qualification: the TO25-S12 manual limits 24 Gbit DRAM with 4th Gen Xeon to XCC processors and full eight-DIMM populations. Do not assume support from the socket or DDR5 type alone; confirm the selected CPU and DIMM combination with GIGABYTE.', + }, gpuCapable: 1, gpuWidth: 'double', - // The catalog's default M.2 NVMe / U.2 pair does not fit this sled. - defaultDrives: [], + driveBays: [ + { type: 'm2-sata', count: 1 }, + { type: 'e1s-9.5-nvme', count: 2 }, + ], + defaultDrives: [ + { modelId: 'drive-m2-sata-480', perNode: 1 }, + { modelId: 'drive-e1s-1920', perNode: 1 }, + ], }, 'server-gigabyte-to25-z12': { id: 'server-gigabyte-to25-z12', @@ -499,10 +524,33 @@ export const catalog: Record = { powerWatts: 800, status: 'alpha', serverUsages: ['worker', 'storage'], - // SP5 CPU and memory parts are not yet modeled; validation reports this. + socket: 'SP5', + dimmSlots: 12, + maxCpuTdpWatts: 240, + // https://www.gigabyte.com/Enterprise/Data-Center/TO25-Z12-AA01-rev-3x + cpuCompatibilityNotes: { + 'cpu-epyc-9015': + 'EPYC 9015 requires an EPYC 9005-qualified TO25-Z12 hardware revision and BIOS. Rev. 3.x lists EPYC 9005 support; confirm the exact barebone revision with GIGABYTE before ordering an older or unspecified sled.', + }, gpuCapable: 1, gpuWidth: 'double', - defaultDrives: [], + // https://download.gigabyte.com/FileList/DataSheet/TO25-Z12_datasheet_v1.3.pdf + driveBays: [ + { type: 'm2-nvme', count: 1, adapterModelId: 'adapter-gigabyte-cmtp192' }, + { type: 'e1s-9.5-nvme', count: 4 }, + ], + defaultDrives: [ + { modelId: 'drive-m2-480', perNode: 1 }, + { modelId: 'drive-e1s-1920', perNode: 1 }, + ], + }, + 'adapter-gigabyte-cmtp192': { + id: 'adapter-gigabyte-cmtp192', + category: 'accessory', + vendor: 'GIGABYTE', + model: 'CMTP192', + partNumber: '9CMTP192NR-00', + description: 'Internal M.2 NVMe expansion card, PCIe Gen4 x4, 2280/22110', }, 'chassis-gigabyte-to25-bt0': { id: 'chassis-gigabyte-to25-bt0', @@ -915,6 +963,33 @@ export const catalog: Record = { socket: 'AM5', }, + // https://www.amd.com/en/products/processors/server/epyc/9005-series/amd-epyc-9015.html + 'cpu-epyc-9015': { + id: 'cpu-epyc-9015', + category: 'cpu', + vendor: 'AMD', + model: 'EPYC 9015', + partNumber: '100-000001553', + description: '8 cores, 16 threads, 3.6 GHz base', + cores: 8, + threads: 16, + powerWatts: 125, + socket: 'SP5', + }, + // https://www.amd.com/en/products/processors/server/epyc/4th-generation-9004-and-8004-series/amd-epyc-9254.html + 'cpu-epyc-9254': { + id: 'cpu-epyc-9254', + category: 'cpu', + vendor: 'AMD', + model: 'EPYC 9254', + partNumber: '100-000000480', + description: '24 cores, 48 threads, 2.9 GHz base', + cores: 24, + threads: 48, + powerWatts: 200, + socket: 'SP5', + }, + // --- Memory (representative public orderable parts) --- 'mem-ddr5u-16g': { id: 'mem-ddr5u-16g', @@ -1019,6 +1094,7 @@ export const catalog: Record = { model: '7450 PRO', partNumber: 'MTFDKBA480TFR-1BC1ZABYY', description: '480 GB M.2 NVMe', + driveType: 'm2-nvme', }, 'drive-nvme-960': { id: 'drive-nvme-960', @@ -1027,6 +1103,37 @@ export const catalog: Record = { model: '7450 PRO', partNumber: 'MTFDKCB960TFR-1BC1ZABYY', description: '960 GB 2.5" U.3 NVMe', + driveType: 'u3-nvme', + }, + + // https://www.micron.com/products/storage/ssd/data-center-ssd/5400-sata-ssd/part-catalog + 'drive-m2-sata-480': { + id: 'drive-m2-sata-480', + category: 'drive', + vendor: 'Micron', + model: '5400 PRO', + partNumber: 'MTFDDAV480TGA-1BC1ZTAYY', + description: '480 GB M.2 SATA', + driveType: 'm2-sata', + }, + // https://www.kioxia.com/en-jp/business/ssd/data-center-ssd/xd7p.html + 'drive-e1s-1920': { + id: 'drive-e1s-1920', + category: 'drive', + vendor: 'KIOXIA', + model: 'XD7P', + partNumber: 'KXDZ1RJJ1T92', + description: '1.92 TB E1.S 9.5 mm NVMe', + driveType: 'e1s-9.5-nvme', + }, + 'drive-e1s-3840': { + id: 'drive-e1s-3840', + category: 'drive', + vendor: 'KIOXIA', + model: 'XD7P', + partNumber: 'KXDZ1RJJ3T84', + description: '3.84 TB E1.S 9.5 mm NVMe', + driveType: 'e1s-9.5-nvme', }, // --- GPUs (official metal-stack compatibility list) --- @@ -1332,11 +1439,23 @@ export function defaultNicId(uplink: UplinkSpeed): string { return uplink === '2x25G' ? 'nic-e810-xxvda2' : 'nic-e810-cqda2' } +/** Does not verify hardware revision or BIOS requirements. */ +export function cpuFitsServer(cpu: CatalogItem, server: CatalogItem): boolean { + return ( + cpu.category === 'cpu' && + server.category === 'server' && + !!server.socket && + cpu.socket === server.socket && + (!server.maxCpuTdpWatts || + (cpu.powerWatts !== undefined && cpu.powerWatts <= server.maxCpuTdpWatts)) + ) +} + export function cpusForServer(serverModelId: string): CatalogItem[] { - const socket = catalog[serverModelId]?.socket - if (!socket) return [] + const server = catalog[serverModelId] + if (!server?.socket) return [] return Object.values(catalog) - .filter((item) => item.category === 'cpu' && item.socket === socket) + .filter((item) => cpuFitsServer(item, server)) .sort(byAvailability) } @@ -1374,11 +1493,18 @@ export const defaultDrives: DriveConfig[] = [ { modelId: 'drive-nvme-960', perNode: 1 }, ] -/** Drive models offerable for boot and data media, current hardware first. - * Drive fit is not board-specific, so every server offers the same list. */ -export function driveOptions(): CatalogItem[] { +/** Check verified bay types where modeled; legacy servers retain their choices. */ +export function driveFitsServer(drive: CatalogItem, serverModelId: string): boolean { + const bays = catalog[serverModelId]?.driveBays + return !bays || bays.some((bay) => bay.type === drive.driveType) +} + +export function driveOptions(serverModelId?: string): CatalogItem[] { return Object.values(catalog) - .filter((item) => item.category === 'drive') + .filter( + (item) => + item.category === 'drive' && (!serverModelId || driveFitsServer(item, serverModelId)), + ) .sort(byAvailability) } diff --git a/src/model/sizes.test.ts b/src/model/sizes.test.ts index 15414b2..a42efe1 100644 --- a/src/model/sizes.test.ts +++ b/src/model/sizes.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest' -import { catalog, dimmsForServer, dimmTypeForSocket, type CpuSocket } from './catalog' +import { + catalog, + cpusForServer, + dimmsForServer, + dimmTypeForSocket, + type CpuSocket, +} from './catalog' import { nodeSizes, resolveNodeCompute } from './sizes' describe('node size catalog references', () => { @@ -23,6 +29,12 @@ describe('node size catalog references', () => { ) for (const server of Object.values(catalog)) { if (server.category !== 'server' || server.socket !== socket) continue + if (parts.cpuModelId) { + expect( + cpusForServer(server.id).map((item) => item.id), + `${size.id} ${server.id} CPU compatibility`, + ).toContain(parts.cpuModelId) + } expect( dimmsForServer(server.id).map((item) => item.id), `${size.id} ${server.id} memory compatibility`, diff --git a/src/model/sizes.ts b/src/model/sizes.ts index cca80b4..0ed0275 100644 --- a/src/model/sizes.ts +++ b/src/model/sizes.ts @@ -34,6 +34,7 @@ export const nodeSizes: NodeSize[] = [ memoryGiB: 32, parts: { AM5: { cpuModelId: 'cpu-epyc-4344p', dimmModelId: 'mem-ddr5u-16g', dimmsPerNode: 2 }, + SP5: { cpuModelId: 'cpu-epyc-9015', dimmModelId: 'mem-ddr5r-16g', dimmsPerNode: 2 }, 'LGA-1700': { cpuModelId: 'cpu-xeon-e2488', dimmModelId: 'mem-ddr5u-16g', @@ -68,6 +69,7 @@ export const nodeSizes: NodeSize[] = [ memoryGiB: 128, parts: { AM5: { cpuModelId: 'cpu-epyc-4344p', dimmModelId: 'mem-ddr5u-32g', dimmsPerNode: 4 }, + SP5: { cpuModelId: 'cpu-epyc-9015', dimmModelId: 'mem-ddr5r-32g', dimmsPerNode: 4 }, 'LGA-1700': { cpuModelId: 'cpu-xeon-e2488', dimmModelId: 'mem-ddr5u-32g', @@ -101,6 +103,7 @@ export const nodeSizes: NodeSize[] = [ cores: 24, memoryGiB: 192, parts: { + SP5: { cpuModelId: 'cpu-epyc-9254', dimmModelId: 'mem-ddr5r-32g', dimmsPerNode: 6 }, 'LGA-4189': { cpuModelId: 'cpu-xeon-5318y', dimmModelId: 'mem-ddr4r-32g', diff --git a/src/views/BomView.tsx b/src/views/BomView.tsx index 3925019..be8e7c1 100644 --- a/src/views/BomView.tsx +++ b/src/views/BomView.tsx @@ -33,6 +33,7 @@ const CATEGORY_LABEL = { switch: 'Switches', server: 'Servers', chassis: 'Chassis, trays and adapters', + accessory: 'Internal accessories', rack: 'Racks and bus bars', power: 'Power infrastructure', router: 'Routers', diff --git a/src/views/icons/index.tsx b/src/views/icons/index.tsx index 7762eda..4cd4bc3 100644 --- a/src/views/icons/index.tsx +++ b/src/views/icons/index.tsx @@ -138,6 +138,7 @@ export const CATEGORY_ICON: Record = { switch: NetworkSwitch, server: Server, chassis: Server, + accessory: Server, rack: Rack, power: Cable, router: Router, diff --git a/src/views/plan/RackSection.test.tsx b/src/views/plan/RackSection.test.tsx index 24ea6a8..7a0c9c2 100644 --- a/src/views/plan/RackSection.test.tsx +++ b/src/views/plan/RackSection.test.tsx @@ -20,6 +20,31 @@ function renderImportedRack(plan: Plan) { } describe('imported server configurations', () => { + it('shows AMD drive defaults and compatible choices after import', () => { + const plan = createEmptyPlan() + const rack = plan.partitions[0].racks[0] + rack.widthInches = 21 + rack.servers[0].modelId = 'server-gigabyte-to25-z12' + const { html } = renderImportedRack(plan) + expect(html).toContain('value="drive-m2-480" selected=""') + expect(html).toContain('value="drive-e1s-1920" selected=""') + expect(html).not.toContain('value="drive-m2-sata-480"') + expect(html).not.toContain('value="drive-nvme-960"') + }) + + it('keeps an incompatible imported sled drive visible for replacement', () => { + const plan = createEmptyPlan() + const rack = plan.partitions[0].racks[0] + rack.widthInches = 21 + rack.servers[0].modelId = 'server-gigabyte-to25-s12' + rack.servers[0].drives = [{ modelId: 'drive-m2-480', perNode: 1 }] + const { html } = renderImportedRack(plan) + expect(html).toContain( + '', + ) + expect(html).toContain('value="drive-m2-sata-480"') + }) + it('renders the CPU socket count and active RAM limit after import', () => { const plan = createEmptyPlan() const group = plan.partitions[0].racks[0].servers[0] @@ -79,7 +104,7 @@ describe('imported server configurations', () => { true, ) expect(html).toContain( - '', + '', ) }) diff --git a/src/views/plan/RackSection.tsx b/src/views/plan/RackSection.tsx index f82c4f0..0085a1a 100644 --- a/src/views/plan/RackSection.tsx +++ b/src/views/plan/RackSection.tsx @@ -37,7 +37,8 @@ function catalogOptions(items: CatalogItem[], id?: string, defaultLabel?: string const options = items.map((item) => ({ value: item.id, label: optionLabel(item) })) if (defaultLabel) options.unshift({ value: '', label: defaultLabel }) if (id && !options.some((option) => option.value === id)) { - options.push({ value: id, label: `${itemLabel(id)} (incompatible)` }) + const label = catalog[id] ? optionLabel(catalog[id]) : id + options.push({ value: id, label: `${label} (incompatible)` }) } return options } @@ -229,6 +230,7 @@ function ServerGroupRow({ // pair is stored as "unset" so the configuration keeps following the // default. const defaultDrives = drivesForServer(group.modelId) + const compatibleDrives = driveOptions(group.modelId) const shownDrives = shown.drives ?? defaultDrives const patchDrives = (next: DriveConfig[]) => { patchShown({ @@ -426,6 +428,9 @@ function ServerGroupRow({ )} @@ -498,12 +503,21 @@ function ServerGroupRow({ ({ - value: item.id, - // Model names collide across capacities (7450 PRO), - // so drives list their spec instead. - label: item.description, - }))} + options={[ + ...compatibleDrives.map((item) => ({ + value: item.id, + // Model names collide across capacities; show the spec. + label: item.description, + })), + ...(!compatibleDrives.some((item) => item.id === drive.modelId) + ? [ + { + value: drive.modelId, + label: `${catalog[drive.modelId]?.description ?? drive.modelId} (incompatible)`, + }, + ] + : []), + ]} onChange={(v) => patchDrives(shownDrives.map((d, n) => (n === i ? { ...d, modelId: v } : d))) } @@ -535,7 +549,7 @@ function ServerGroupRow({