Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack
- A `Partition` is a metal-stack failure domain (one site / room) — the term is always "partition", never "zone". A plan has no topology variant: the partitions it holds say what it is.
- Fabric types per partition: `leaf-spine` | `leaf-spine-superspine` (`FabricConfig.fabricType`); superspines and storage leaves only enter the BOM/validation when configured.
- **Central rack**: spines, exits, superspines, mgmt spines and mgmt servers live together in a central rack per partition; compute racks hold ToR leaves, worker/storage server groups, and a mgmt leaf. There is no separate OOB switch — the mgmt leaf carries BMC/out-of-band access.
- **Control plane** (`Plan.controlPlane`, plan-level: one control plane serves every partition): the Kubernetes cluster running metal-api, masterdata-api, the IPAM and their databases. `hosting: 'kaas'` is a managed cluster that orders no hardware and shows as a capsule on each partition's routers; `hosting: 'on-prem'` are nodes this plan buys, either in the host partition's central rack (attached to its exit switches, `placement: 'central-rack'`) or in a control-plane rack of their own with a leaf pair uplinked to the spines (`'own-rack'`). `src/derive/controlPlane.ts` is the single source of those placement rules and the BOM, the elevations, the topology and validation all ask it; its leaves count in `spinePortsPerSpine()` and its node ports in the exit switch budget. These nodes are never metal-stack-managed machines, so they stay out of `planNodes()` and the compatibility list does not apply. Validation is deliberately thin: an error without nodes, a warning below three (etcd quorum). Where the cluster runs is free per the deployment guide, so nothing checks reachability.
- **Rack kinds** (`Rack.kind`): `single` (one physical rack) or `rack-group` (three physical racks sharing the middle rack's leaf pair and mgmt leaf; chassis are distributed evenly per server group — each goes to the physical rack holding the fewest chassis of its group, the least-used by height units among those, ties mid → left → right). `physicalRacks()` in `derive/rackLayout.ts` is the single source of that spread; the topology graph and the elevations both consume it, so a rack group always shows as three physical racks — in the racks view inside one dashed group box (`RackElevation.group`). A group's `name` names the group ("Rack group 1"); its physical racks are named by `memberNames` (left, middle, right). Names are editable, but defaults are unique per partition: `newRack()` / `withRackKind()` in `model/defaults.ts` number every physical rack above the highest `Rack <N>` in use (a group takes three numbers, switching kinds keeps names unique), and validation warns on duplicate physical rack names. BOM sections, IP-plan subnets and rack-level validation use the group name; elevations and the topology use the physical names. In both views the mgmt leaf sits at the top of a compute rack, above the leaves. Leaf port capacity counts only spine uplinks against front-panel ports — leaf↔mgmt connectivity uses the switches' dedicated OOB mgmt port.
- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines and mgmt servers (2 vs 1, `mgmtDeviceCount()`). Production tiers use Edgecore AS7726 (the AS7712 is vendor end-of-life), management tiers AS4630/AS4625. Every switch has exactly one management interface: leaves connect it to the rack's mgmt leaf, central-rack switches and routers to the mgmt spines — derived, never configured. `FabricConfig.leafSpineLinks` (default 1) is the number of 100G links from each leaf to each spine. External networks (internet, company networks, storage) attach in the central rack: internet and company networks at the internet routers (at the exits when a partition has no routers), a storage network at the partition's storage leaves, or at the exits when it has none (`attachesAtStorageLeaves()` in `derive/topology.ts` is the single source of that rule, used by the derivation and by `Diagram` to draw storage capsules over the storage box). `filterTopology()` drops an external network whose attachment points all fell away.
- **GPUs.** `ServerGroup.gpu` (optional `{ modelId, perNode }`) fits every node of a group alike: a BOM line at `nodes × perNode`, watts folded into the chassis slot so the rack power estimate picks them up, and a validation error when `perNode` exceeds the server model's catalog `gpuCapable` (absent = takes no GPUs). `gpusForServer()` populates the dropdown, so the field only appears for GPU-capable models.
Expand Down
17 changes: 9 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,15 @@ default plan. Use **Export JSON** to save a plan file and **Import JSON** to loa
The whole app operates on a single `Plan` document, described by Zod schemas in
`src/model/plan.ts`. Everything else is derived from it and never stored:

| Module | Derives |
| -------------------------- | ------------------------------------------------------- |
| `src/derive/bom.ts` | BOM lines and quantities |
| `src/derive/topology.ts` | the topology graph (nodes and links) |
| `src/derive/rackLayout.ts` | rack elevations and the rack-group spread |
| `src/derive/validate.ts` | validation issues |
| `src/derive/nodes.ts` | node tallies per rack, partition and plan |
| `src/derive/ip/` | CIDR arithmetic, the IP address plan and its validation |
| Module | Derives |
| ---------------------------- | ------------------------------------------------------- |
| `src/derive/bom.ts` | BOM lines and quantities |
| `src/derive/topology.ts` | the topology graph (nodes and links) |
| `src/derive/controlPlane.ts` | where the control plane's hardware lands |
| `src/derive/rackLayout.ts` | rack elevations and the rack-group spread |
| `src/derive/validate.ts` | validation issues |
| `src/derive/nodes.ts` | node tallies per rack, partition and plan |
| `src/derive/ip/` | CIDR arithmetic, the IP address plan and its validation |

Hardware facts — part numbers, port counts, height units, nodes per chassis, and metal-stack
compatibility — live in `src/model/catalog.ts`. The compatibility data mirrors the official
Expand Down
68 changes: 68 additions & 0 deletions src/derive/bom.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -441,3 +441,71 @@ describe('deriveBom scope', () => {
expect(spares).not.toContain('cable-mtp-trunk#spare')
})
})

describe('control plane', () => {
/** A plan whose control plane runs on-prem, `patch` applied on top. */
function onPrem(patch: Partial<Plan['controlPlane']> = {}): Plan {
const plan = createEmptyPlan()
plan.controlPlane = { ...plan.controlPlane, hosting: 'on-prem', ...patch }
return plan
}

function line(plan: Plan, catalogId: string) {
return deriveBom(plan).find((l) => l.catalogId === catalogId)
}

it('orders nothing for a managed control plane', () => {
const kaas = createEmptyPlan()
expect(kaas.controlPlane.hosting).toBe('kaas')
// The default plan has mgmt servers of the same model; on-prem adds to it.
const managed = line(kaas, 'server-mgmt-121h')?.quantity ?? 0
expect(line(onPrem(), 'server-mgmt-121h')?.quantity).toBe(managed + 3)
})

it('adds nodes, NICs, optics and mgmt copper in the central rack', () => {
const plan = onPrem()
const nodes = line(plan, 'server-mgmt-121h')!
const nodeReason = nodes.reasons.find((r) => r.detail.includes('control plane nodes'))!
expect(nodeReason).toMatchObject({ quantity: 3, where: 'Central rack' })

// 3 nodes × 1 dual-port 25G NIC, 6 server ports, 2 breakout groups.
const nics = line(plan, 'nic-e810-xxvda2')!
expect(nics.reasons.find((r) => r.detail.includes('control plane'))?.quantity).toBe(3)
const optics = line(plan, 'sfp-25g-sr')!
expect(optics.reasons.find((r) => r.detail.includes('control plane'))?.quantity).toBe(6)
const breakouts = line(plan, 'cable-mtp-breakout')!
expect(breakouts.reasons.find((r) => r.detail.includes('control plane'))?.quantity).toBe(2)

// One mgmt interface per node to the mgmt spines.
const copper = line(plan, 'cable-rj45')!
expect(
copper.reasons.find((r) => r.detail.includes('control plane nodes × 1 mgmt interface')),
).toMatchObject({ quantity: 3, where: 'Central rack' })
})

it('uses 100G point to point for 2x100G nodes', () => {
const plan = onPrem({ uplink: '2x100G' })
const nics = line(plan, 'nic-e810-cqda2')!
expect(nics.reasons.find((r) => r.detail.includes('control plane'))?.quantity).toBe(3)
const trunks = line(plan, 'cable-mtp-trunk')!
expect(trunks.reasons.find((r) => r.detail.includes('control plane'))?.quantity).toBe(6)
// The nodes contribute no breakout; the rack's workers still do.
const breakouts = line(plan, 'cable-mtp-breakout')!
expect(breakouts.reasons.some((r) => r.detail.includes('control plane'))).toBe(false)
})

it('gives an own rack its leaves, licenses and mgmt leaf', () => {
const plan = onPrem({ placement: 'own-rack' })
const where = plan.controlPlane.rack.name
const leaves = line(plan, 'switch-as7726')!
expect(leaves.reasons.find((r) => r.where === where)).toMatchObject({ quantity: 2 })
const licenses = line(plan, 'lic-sonic-eb-100g')!
expect(licenses.reasons.some((r) => r.where === where)).toBe(true)
const mgmtLeaf = line(plan, 'switch-as4630')!
expect(mgmtLeaf.reasons.some((r) => r.where === where)).toBe(true)

// Those leaves uplink to the spines like any other rack's.
const central = line(createEmptyPlan(), 'cable-mtp-trunk')?.quantity ?? 0
expect(line(plan, 'cable-mtp-trunk')!.quantity).toBeGreaterThan(central)
})
})
129 changes: 107 additions & 22 deletions src/derive/bom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import {
type Plan,
type Rack,
type ServerGroup,
type UplinkSpeed,
} from '../model/plan'
import { controlPlaneLeafCount, hasOwnRack, inCentralRack } from './controlPlane'

// The BOM is always derived from the Plan, never stored. Every quantity rule
// lives here and gets a unit test in bom.test.ts. Every rule also records a
Expand Down Expand Up @@ -235,35 +237,48 @@ function addServerGroup(bom: BomBuilder, group: ServerGroup): void {
bom.add(group.gpu.modelId, gpus, `${group.count} ${role} nodes × ${group.gpu.perNode} GPU`)
}

addNodeUplinks(bom, group.count, group.uplink, role)
}

/** NIC, transceivers and cables for dual-attached nodes, whatever they are:
* server groups on their rack's leaves and the control-plane nodes on the
* switch they hang off. `what` names them in the reasons ("worker",
* "control plane"). */
function addNodeUplinks(bom: BomBuilder, nodes: number, uplink: UplinkSpeed, what: string): void {
// Every node carries one dual-port NIC matching its uplink speed.
const uplinkPorts = 2 * group.count
if (group.uplink === '2x25G') {
bom.add('nic-e810-xxvda2', group.count, `${group.count} ${role} nodes × 1 NIC`)
// 25G server ports terminate on 100G leaf ports via 4x25G breakout:
// server side gets a 25G-SR transceiver per port, the leaf side one
const uplinkPorts = 2 * nodes
if (uplink === '2x25G') {
bom.add('nic-e810-xxvda2', nodes, `${nodes} ${what} nodes × 1 NIC`)
// 25G server ports terminate on 100G switch ports via 4x25G breakout:
// server side gets a 25G-SR transceiver per port, the switch side one
// 100G-SR4 per started group of four, joined by an MTP breakout cable.
bom.add('sfp-25g-sr', uplinkPorts, `${group.count} ${role} nodes × 2 server ports`)
const leafPorts = Math.ceil(uplinkPorts / 4)
bom.add('sfp-25g-sr', uplinkPorts, `${nodes} ${what} nodes × 2 server ports`)
const switchPorts = Math.ceil(uplinkPorts / 4)
bom.add(
'sfp-100g-sr4',
leafPorts,
`${uplinkPorts} × 25G ${role} ports / 4 per breakout, leaf side`,
switchPorts,
`${uplinkPorts} × 25G ${what} ports / 4 per breakout, switch side`,
)
bom.add(
'cable-mtp-breakout',
switchPorts,
`${uplinkPorts} × 25G ${what} ports / 4 per breakout`,
)
bom.add('cable-mtp-breakout', leafPorts, `${uplinkPorts} × 25G ${role} ports / 4 per breakout`)
} else {
bom.add('nic-e810-cqda2', group.count, `${group.count} ${role} nodes × 1 NIC`)
bom.add('nic-e810-cqda2', nodes, `${nodes} ${what} nodes × 1 NIC`)
// 100G point-to-point: a 100G-SR4 transceiver on each end plus an MTP
// trunk cable per link.
bom.add('sfp-100g-sr4', 2 * uplinkPorts, `${uplinkPorts} × 100G ${role} links × 2 ends`)
bom.add('cable-mtp-trunk', uplinkPorts, `${uplinkPorts} × 100G ${role} links`)
bom.add('sfp-100g-sr4', 2 * uplinkPorts, `${uplinkPorts} × 100G ${what} links × 2 ends`)
bom.add('cable-mtp-trunk', uplinkPorts, `${uplinkPorts} × 100G ${what} links`)
}
}

/** Links each spine terminates: `leafSpineLinks` per leaf, one per exit,
* superspine and storage leaf. */
export function spinePortsPerSpine(partition: Partition): number {
* superspine and storage leaf. `controlPlaneLeaves` are the leaves of a
* separate control-plane rack, which uplink like any other leaves. */
export function spinePortsPerSpine(partition: Partition, controlPlaneLeaves = 0): number {
const { fabric } = partition
const leaves = partition.racks.reduce((n, r) => n + r.leafCount, 0)
const leaves = partition.racks.reduce((n, r) => n + r.leafCount, 0) + controlPlaneLeaves
const superspines = fabric.fabricType === 'leaf-spine-superspine' ? fabric.superspineCount : 0
return (
leaves * fabric.leafSpineLinks + fabric.exitSwitchCount + superspines + fabric.storageLeafCount
Expand All @@ -275,8 +290,8 @@ export function routerLinks(partition: Partition): number {
return 2 * partition.fabric.routerCount * partition.fabric.exitSwitchCount
}

function addFabricLinks(bom: BomBuilder, partition: Partition): void {
const perSpine = spinePortsPerSpine(partition)
function addFabricLinks(bom: BomBuilder, plan: Plan, partition: Partition): void {
const perSpine = spinePortsPerSpine(partition, controlPlaneLeafCount(plan, partition))
const links = perSpine * partition.fabric.spineCount
const why = `${perSpine} links per spine × ${partition.fabric.spineCount} spines`
bom.add('sfp-100g-sr4', 2 * links, `${links} fabric links × 2 ends (${why})`)
Expand Down Expand Up @@ -340,7 +355,76 @@ function addMgmtLinks(bom: BomBuilder, partition: Partition, central: BomBuilder
/** Section the central-rack rules are reported under. */
const CENTRAL_RACK = 'Central rack'

function addPartition(bom: BomBuilder, partition: Partition): void {
/** The on-prem control-plane cluster: its nodes, their uplinks and, for a
* control-plane rack of its own, the leaf pair and mgmt leaf that serve
* them. A KaaS control plane orders nothing. Emitted inside the host
* partition's central-rack block so the section order stays physical. */
function addControlPlane(
bom: BomBuilder,
plan: Plan,
partition: Partition,
prodCentral: BomBuilder,
mgmtCentral: BomBuilder,
): void {
const cp = plan.controlPlane
const central = inCentralRack(plan, partition)
const ownRack = hasOwnRack(plan, partition)
if (!central && !ownRack) return

const { fabric } = partition
const nodes = cp.nodeCount
const where = central ? CENTRAL_RACK : cp.rack.name
const prod = central ? prodCentral : bom.on('production').at(where)
const mgmt = central ? mgmtCentral : bom.on('management').at(where)

prod.add(cp.nodeModelId, nodes, `${nodes} control plane nodes`)
addNodeUplinks(prod, nodes, cp.uplink, 'control plane')

if (ownRack) {
// A rack of its own: leaves uplinked to the spines like a compute
// rack's, plus a mgmt leaf for the nodes' BMC ports.
addSwitch(
prod,
fabric.nos,
cp.rack.leafModelId,
cp.rack.leafCount,
`${cp.rack.leafCount} leaves`,
)
addSwitch(
mgmt,
fabric.nos,
fabric.mgmt.leafModelId,
fabric.mgmt.leafPerRack,
`${fabric.mgmt.leafPerRack} mgmt leaves`,
)
mgmt.add('cable-rj45', nodes, `${nodes} control plane nodes × 1 BMC port to the mgmt leaf`)
mgmt.add(
'cable-rj45',
cp.rack.leafCount,
`${cp.rack.leafCount} leaves × 1 mgmt interface to the mgmt leaf`,
)
const mgmtCount = mgmtDeviceCount(fabric.mgmt)
const speed = mgmtUplinkSpeed(fabric.mgmt.leafModelId, fabric.mgmt.spineModelId)
const links = fabric.mgmt.leafPerRack * mgmtCount
const why = `${fabric.mgmt.leafPerRack} mgmt leaves × ${mgmtCount} mgmt spines`
mgmt.add(
speed === '25G' ? 'sfp-25g-sr' : 'sfp-10g-sr',
2 * links,
`${links} mgmt uplinks × 2 ends (${why})`,
)
mgmt.add('cable-lc-duplex', links, `${links} mgmt uplinks (${why})`)
} else {
// In the central rack the nodes reach the management network the same
// way every device there does: one interface to the mgmt spines.
mgmt.add(
'cable-rj45',
nodes,
`${nodes} control plane nodes × 1 mgmt interface to the mgmt spines`,
)
}
}

function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void {
const { fabric } = partition
// Network and location are tagged once here, so every rule below inherits
// the network it belongs to and the section it is reported under.
Expand Down Expand Up @@ -395,7 +479,8 @@ function addPartition(bom: BomBuilder, partition: Partition): void {
// compute rack does, and the racks follow in plan order. That makes the
// reason sections come out in physical order for free: for any line, the
// central rack is the first section, then Rack 1, Rack 2 and so on.
addFabricLinks(prodCentral, partition)
addControlPlane(bom, plan, partition, prodCentral, mgmtCentral)
addFabricLinks(prodCentral, plan, partition)
addMgmtLinks(bom.on('management'), partition, mgmtCentral)

for (const rack of partition.racks) {
Expand Down Expand Up @@ -446,7 +531,7 @@ export function deriveBom(plan: Plan, scope: BomScope = 'all'): BomLine[] {
const multi = plan.partitions.length > 1
const bom = new BomBuilder(scope)
for (const partition of plan.partitions) {
addPartition(multi ? bom.inPartition(partition.name) : bom, partition)
addPartition(multi ? bom.inPartition(partition.name) : bom, plan, partition)
}
const lines = bom.build()
return [...lines, ...spareLines(lines, plan.sparesPerLine)]
Expand All @@ -459,7 +544,7 @@ export function deriveBomByPartition(
): { partition: Partition; lines: BomLine[] }[] {
return plan.partitions.map((partition) => {
const bom = new BomBuilder(scope)
addPartition(bom, partition)
addPartition(bom, plan, partition)
return { partition, lines: bom.build() }
})
}
Loading
Loading