From bc7e50e904c51c1fc4f6340953abd8043f28fa95 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:30:00 +0200 Subject: [PATCH 01/27] add mgmt firewall and wire the mgmt network one to one The deployment guide's routed out-of-band network is two chains: mgmt firewall i links to mgmt server i (interface and BMC) and to mgmt spine i's own mgmt port, and mgmt server i uplinks to mgmt spine i only. The planner drew and counted every mgmt server against every mgmt spine and had no mgmt firewall at all. The firewall is a vendor-neutral 1U server with 6x 1G RJ45 (pfSense or Debian with FRR); the Teltonika RUTXR1 of earlier setups is end-of-life (last order 2027-02-03). Its 100 W power draw is an estimate. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 6 ++-- src/derive/bom.test.ts | 19 ++++++---- src/derive/bom.ts | 25 ++++++++----- src/derive/rackLayout.test.ts | 12 ++++--- src/derive/rackLayout.ts | 1 + src/derive/topology.test.ts | 25 +++++++++++++ src/derive/topology.ts | 66 ++++++++++++++++++++++++++++++---- src/derive/validate.ts | 17 ++++----- src/model/catalog.ts | 13 +++++++ src/model/defaults.ts | 1 + src/model/plan.ts | 8 +++-- src/views/TopologyView.tsx | 7 +++- src/views/icons/index.tsx | 2 ++ src/views/topology/Diagram.tsx | 31 ++++++++++++---- 14 files changed, 183 insertions(+), 50 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d0a2bbd..04ae11f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -39,14 +39,14 @@ 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. +- **Central rack**: spines, exits, superspines, mgmt spines, mgmt servers and mgmt firewalls 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 ` 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. +- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines, mgmt servers and mgmt firewalls (2 vs 1, `mgmtDeviceCount()`). Each side is a chain as in the deployment guide's routed out-of-band network: mgmt firewall i links to mgmt server i (interface and BMC) and to mgmt spine i's own mgmt port, and mgmt server i uplinks to mgmt spine i only, never across. The firewall (`MgmtNetwork.firewallModelId`, catalog `firewall-mgmt`) is a vendor-neutral 1U server with 6× 1G RJ45; the Teltonika RUTXR1 of earlier setups is end-of-life. 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. - `ServerGroup.role` is `worker` | `storage` only. Management servers are never a compute-rack group — they are configured on the fabric's management network and live in the central rack. Legacy `management` groups are dropped on import in `model/normalize.ts`. - **Fabric bandwidth.** `src/derive/bandwidth.ts` is the single source of the oversubscription ratio: per rack `Σ nodes × (50 Gbit/s for 2x25G, 200 for 2x100G)` against `leafCount × spineCount × leafSpineLinks × 100 Gbit/s`, and per spine `leaves × leafSpineLinks × 100` against `superspineCount × 100` when a superspine tier exists. `FabricConfig.nonBlocking` (default `false`, so templates stay valid) turns a ratio above 1:1 into a validation error naming the links per leaf ↔ spine pair (or superspines) that would fix it; the ratio chips in the plan editor show it either way. North-south bandwidth (spines → exits → routers) is deliberately not part of this. -- **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router, and every mgmt server × every mgmt spine, to the mgmt spines. Management fiber: every mgmt leaf × every mgmt spine over LC duplex with SR transceivers — 25G when both switch models have 25G ports, else 10G (`mgmtUplinkSpeed()`). One NOS support license per switch, resolved by `nosLicenseId()` from the partition's `FabricConfig.nos` (`broadcom-sonic` by default, `edgecore-sonic` the alternative, chosen in the partition's Advanced section) and the switch's catalog `licenseClass` (`100g` / `25g`) — the vendors tier their licenses differently, so `nosOptions` in `catalog.ts` maps tier to SKU per NOS. Internet routers (`FabricConfig.routerCount`, default 2 for new plans) sit at the top of the central rack, link twice to every exit (validated against the exit's 100G ports) and take the internet and company networks in the topology. +- **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router and one link per mgmt server to its own mgmt spine; three per mgmt firewall (mgmt server, its BMC, the mgmt spine's mgmt port). Management fiber: every mgmt leaf × every mgmt spine over LC duplex with SR transceivers — 25G when both switch models have 25G ports, else 10G (`mgmtUplinkSpeed()`). One NOS support license per switch, resolved by `nosLicenseId()` from the partition's `FabricConfig.nos` (`broadcom-sonic` by default, `edgecore-sonic` the alternative, chosen in the partition's Advanced section) and the switch's catalog `licenseClass` (`100g` / `25g`) — the vendors tier their licenses differently, so `nosOptions` in `catalog.ts` maps tier to SKU per NOS. Internet routers (`FabricConfig.routerCount`, default 2 for new plans) sit at the top of the central rack, link twice to every exit (validated against the exit's 100G ports) and take the internet and company networks in the topology. - Server uplinks are configured per `ServerGroup`: `2x25G` (Intel E810-XXVDA2) or `2x100G` (Intel E810-CQDA2). 25G server ports terminate on 100G leaf ports via 4x25G breakout — this breakout math drives transceiver and cable quantities in the BOM. - `ServerGroup.count` is server _nodes_; chassis counts are derived via `nodesPerChassis` (MicroCloud = 8, BigTwin = 4). - The topology view shows devices and links only — no metal-stack component placements (the metal-core, metal-api, PXE and BMC chips were removed on purpose) and no link-count badges. Server groups have no edges at all — neither uplinks to the leaves nor BMC links to the mgmt leaf are drawn (too many edges); the BOM still counts them. The central rack is drawn in two columns (production left, management right) with spines and mgmt spines in the bottom row so rack uplinks don't cross other device rows. diff --git a/src/derive/bom.test.ts b/src/derive/bom.test.ts index b25d475..de4eec9 100644 --- a/src/derive/bom.test.ts +++ b/src/derive/bom.test.ts @@ -95,6 +95,8 @@ describe('deriveBom switches', () => { expect(quantity(plan, 'switch-as4630')).toBe(3) // 2 mgmt servers (redundant) in the central rack expect(quantity(plan, 'server-mgmt-121h')).toBe(2) + // one mgmt firewall per mgmt server + expect(quantity(plan, 'firewall-mgmt')).toBe(2) }) }) @@ -140,18 +142,20 @@ describe('deriveBom management cabling', () => { const plan = createEmptyPlan() // 1 chassis (8 nodes) BMC + 2 leaves × 1 mgmt interface // + (2 spines + 2 exits + 2 routers) × 1 mgmt interface - // + 2 mgmt servers x 2 mgmt spines - expect(quantity(plan, 'cable-rj45')).toBe(1 + 2 + 6 + 4) + // + 2 mgmt servers × 1 link to their own mgmt spine + // + 2 mgmt firewalls × 3 (mgmt server, its BMC, mgmt spine eth0) + expect(quantity(plan, 'cable-rj45')).toBe(1 + 2 + 6 + 2 + 6) // 9 nodes -> 2 chassis plan.partitions[0].racks[0].servers[0].count = 9 - expect(quantity(plan, 'cable-rj45')).toBe(2 + 2 + 6 + 4) + expect(quantity(plan, 'cable-rj45')).toBe(2 + 2 + 6 + 2 + 6) }) - it('halves the mgmt server links for a non-redundant management network', () => { + it('halves the mgmt server and firewall links for a non-redundant management network', () => { const plan = createEmptyPlan() plan.partitions[0].fabric.mgmt.redundant = false - // 1 + 2 + 6 + 1 x 1 - expect(quantity(plan, 'cable-rj45')).toBe(10) + // 1 + 2 + 6 + 1 mgmt server link + 1 firewall × 3 + expect(quantity(plan, 'cable-rj45')).toBe(13) + expect(quantity(plan, 'firewall-mgmt')).toBe(1) }) it('uplinks every mgmt leaf to every mgmt spine over 25G fiber', () => { @@ -222,7 +226,7 @@ describe('deriveBom reasons, spares and partition scope', () => { it('records one reason per contributing rule', () => { const plan = createEmptyPlan() const rj45 = deriveBom(plan).find((l) => l.catalogId === 'cable-rj45')! - expect(rj45.reasons).toHaveLength(4) + expect(rj45.reasons).toHaveLength(5) expect(rj45.reasons[0]).toEqual({ quantity: 6, detail: '6 switches and routers × 1 mgmt interface to the mgmt spines', @@ -239,6 +243,7 @@ describe('deriveBom reasons, spares and partition scope', () => { const plan = createEmptyPlan() const rj45 = deriveBom(plan).find((l) => l.catalogId === 'cable-rj45')! expect(rj45.reasons.map((r) => r.where)).toEqual([ + 'Central rack', 'Central rack', 'Central rack', 'Rack 1', diff --git a/src/derive/bom.ts b/src/derive/bom.ts index 4f0e924..7a05032 100644 --- a/src/derive/bom.ts +++ b/src/derive/bom.ts @@ -28,8 +28,10 @@ import { controlPlaneLeafCount, hasOwnRack, inCentralRack } from './controlPlane // one BMC/OOB port per server chassis to the rack's mgmt leaf (the sample // BOMs count per chassis, not per node), the single management interface // of every leaf to the rack's mgmt leaf, the single management interface -// of every central-rack switch and router to the mgmt spines, and every -// mgmt server to every mgmt spine. +// of every central-rack switch and router to the mgmt spines, each mgmt +// server to its own mgmt spine, and each mgmt firewall three times: to its +// mgmt server, to that server's BMC and to its mgmt spine's mgmt port +// (deployment guide, routed out-of-band network). // - Management network, fiber: every mgmt leaf uplinks once to every mgmt // spine over an LC duplex cable with an SR transceiver on both ends — 25G // when both switch models have 25G ports, 10G otherwise. @@ -315,8 +317,9 @@ function addMgmtLinks(bom: BomBuilder, partition: Partition, central: BomBuilder const speed = mgmtUplinkSpeed(mgmt.leafModelId, mgmt.spineModelId) const uplinkOptic = speed === '25G' ? 'sfp-25g-sr' : 'sfp-10g-sr' - // Central rack: the mgmt interface of every switch and router there, plus - // every mgmt server to every mgmt spine. + // Central rack: the mgmt interface of every switch and router there, each + // mgmt server to its own mgmt spine, and each mgmt firewall to its mgmt + // server, that server's BMC and its mgmt spine. const centralDevices = fabric.spineCount + fabric.exitSwitchCount + @@ -328,10 +331,11 @@ function addMgmtLinks(bom: BomBuilder, partition: Partition, central: BomBuilder centralDevices, `${centralDevices} switches and routers × 1 mgmt interface to the mgmt spines`, ) + central.add('cable-rj45', mgmtCount, `${mgmtCount} mgmt servers × 1 link to their mgmt spine`) central.add( 'cable-rj45', - mgmtCount * mgmtCount, - `${mgmtCount} mgmt servers × ${mgmtCount} mgmt spines`, + 3 * mgmtCount, + `${mgmtCount} mgmt firewalls × 3 (mgmt server, its BMC, mgmt spine mgmt port)`, ) // Per rack: BMC copper, the leaves' mgmt interfaces, and the mgmt leaf @@ -462,8 +466,8 @@ function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void { fabric.storageLeafCount, `${fabric.storageLeafCount} storage leaves`, ) - // Management network: spines and servers in the central rack (count - // driven by redundancy), one mgmt leaf per compute rack. + // Management network: spines, servers and firewalls in the central rack + // (count driven by redundancy), one mgmt leaf per compute rack. const mgmtCount = mgmtDeviceCount(fabric.mgmt) const redundancy = fabric.mgmt.redundant ? 'redundant' : 'non-redundant' addSwitch( @@ -474,6 +478,11 @@ function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void { `${mgmtCount} mgmt spines (${redundancy})`, ) mgmtCentral.add(fabric.mgmt.serverModelId, mgmtCount, `${mgmtCount} mgmt servers (${redundancy})`) + mgmtCentral.add( + fabric.mgmt.firewallModelId, + mgmtCount, + `${mgmtCount} mgmt firewalls (${redundancy})`, + ) // Everything the central rack contributes is emitted before anything a // compute rack does, and the racks follow in plan order. That makes the diff --git a/src/derive/rackLayout.test.ts b/src/derive/rackLayout.test.ts index b44305e..d29e0f8 100644 --- a/src/derive/rackLayout.test.ts +++ b/src/derive/rackLayout.test.ts @@ -10,12 +10,13 @@ describe('deriveRackLayout', () => { expect(central.name).toBe('Central rack') expect(central.maxPowerWatts).toBe(12000) - // 2 routers + 2 exits + 2 spines + 2 mgmt spines + 2 mgmt servers, all 1U - expect(central.slots).toHaveLength(10) - expect(central.usedU).toBe(10) + // 2 routers + 2 exits + 2 spines + 2 mgmt spines + 2 mgmt servers + // + 2 mgmt firewalls, all 1U + expect(central.slots).toHaveLength(12) + expect(central.usedU).toBe(12) expect(central.slots[0]).toMatchObject({ label: 'Router 1', topU: 42, units: 1 }) expect(central.slots[2]).toMatchObject({ label: 'Exit 1', topU: 40, units: 1 }) - expect(central.slots[9].topU).toBe(33) + expect(central.slots[11]).toMatchObject({ label: 'Mgmt firewall 2', topU: 31 }) }) it('lays out a compute rack: mgmt leaf on top, leaves, then server chassis', () => { @@ -124,7 +125,8 @@ describe('rack group', () => { // 2x AS7726 (300 W) + AS4630 (90 W) + one full MicroCloud (2000 W) expect(layout.racks[1].powerWatts).toBe(2690) // 2 routers (400 W) + 2 exits + 2 spines (300 W) + 2 mgmt spines (90 W) + 2 mgmt servers (500 W) - expect(layout.racks[0].powerWatts).toBe(3180) + // + 2 mgmt firewalls (100 W) + expect(layout.racks[0].powerWatts).toBe(3380) plan.partitions[0].racks[0].servers[0].count = 4 // half a MicroCloud expect(deriveRackLayout(plan)[0].racks[1].powerWatts).toBe(690 + 1000) diff --git a/src/derive/rackLayout.ts b/src/derive/rackLayout.ts index 82e9613..610fbb6 100644 --- a/src/derive/rackLayout.ts +++ b/src/derive/rackLayout.ts @@ -200,6 +200,7 @@ export function deriveRackLayout(plan: Plan): PartitionRackLayout[] { ...device('Storage leaf', fabric.storageLeafModelId, fabric.storageLeafCount, 'storage'), ...device('Mgmt spine', fabric.mgmt.spineModelId, mgmtCount, 'mgmt'), ...device('Mgmt server', fabric.mgmt.serverModelId, mgmtCount, 'mgmt'), + ...device('Mgmt firewall', fabric.mgmt.firewallModelId, mgmtCount, 'mgmt'), // On-prem control-plane nodes, when they share the central rack. ...(inCentralRack(plan, partition) ? device( diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index b60edf0..e98ad92 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -28,6 +28,31 @@ describe('deriveTopology', () => { const graph = deriveTopology(plan) expect(graph.partitions[0].central.mgmtSpines).toHaveLength(1) expect(graph.partitions[0].central.mgmtServers).toHaveLength(1) + expect(graph.partitions[0].central.mgmtFirewalls).toHaveLength(1) + }) + + it('chains each mgmt firewall, mgmt server and mgmt spine one to one', () => { + const graph = deriveTopology(createEmptyPlan()) + const { mgmtFirewalls, mgmtServers, mgmtSpines } = graph.partitions[0].central + expect(mgmtFirewalls).toHaveLength(2) + const linksOf = (id: string) => graph.links.filter((l) => l.from === id || l.to === id) + const ends = (id: string) => linksOf(id).map((l) => (l.from === id ? l.to : l.from)) + + mgmtServers.forEach((server, i) => { + expect(ends(server.id).sort()).toEqual([mgmtFirewalls[i].id, mgmtSpines[i].id].sort()) + }) + mgmtFirewalls.forEach((firewall, i) => { + expect(ends(firewall.id).sort()).toEqual([mgmtServers[i].id, mgmtSpines[i].id].sort()) + const toServer = linksOf(firewall.id).find((l) => l.to === mgmtServers[i].id) + expect(toServer).toMatchObject({ count: 2, speed: '1G', network: 'management' }) + }) + }) + + it('keeps the mgmt firewalls in management and central mode only', () => { + const graph = deriveTopology(createEmptyPlan()) + expect(filterTopology(graph, 'management').partitions[0].central.mgmtFirewalls).toHaveLength(2) + expect(filterTopology(graph, 'central').partitions[0].central.mgmtFirewalls).toHaveLength(2) + expect(filterTopology(graph, 'production').partitions[0].central.mgmtFirewalls).toHaveLength(0) }) it('labels the mgmt spine with the management network layer', () => { diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 73e91e1..64dc127 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -25,6 +25,7 @@ export type TopoNodeKind = | 'mgmt-spine' | 'mgmt-leaf' | 'mgmt-server' + | 'mgmt-firewall' | 'server-group' | 'control-plane' | 'external-network' @@ -61,8 +62,8 @@ export interface TopoRack { serverGroups: TopoNode[] } -/** Spines, exits, superspines, mgmt spines and mgmt servers all live in the - * partition's central rack. */ +/** Spines, exits, superspines, mgmt spines, mgmt servers and mgmt + * firewalls all live in the partition's central rack. */ export interface TopoCentralRack { /** Internet routers; internet and company networks attach here when * present. */ @@ -72,6 +73,9 @@ export interface TopoCentralRack { exits: TopoNode[] mgmtSpines: TopoNode[] mgmtServers: TopoNode[] + /** The bastions of the management network: firewall i guards mgmt + * server i and bootstraps mgmt spine i. */ + mgmtFirewalls: TopoNode[] } export interface TopoPartition { @@ -139,7 +143,13 @@ function deriveRack(partition: Partition, rack: Rack, links: TopoLink[]): TopoRa // Production leaves cross-connect into the management fabric. for (const leaf of leaves) { for (const mgmtLeaf of mgmtLeaves) { - links.push({ from: leaf.id, to: mgmtLeaf.id, count: 1, speed: '1G', network: 'management' }) + links.push({ + from: leaf.id, + to: mgmtLeaf.id, + count: 1, + speed: '1G', + network: 'management', + }) } } @@ -225,6 +235,13 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition mgmt.serverModelId, mgmtCount, ), + mgmtFirewalls: tier( + `${p}mgmtfirewall`, + 'mgmt-firewall', + 'Mgmt firewall', + mgmt.firewallModelId, + mgmtCount, + ), } const storageLeaves = tier( @@ -268,8 +285,14 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition links.push({ from: spine.id, to: mgmtSpine.id, count: 1, speed: '1G', network: 'management' }) } } - for (const mgmtServer of central.mgmtServers) { - for (const mgmtSpine of central.mgmtSpines) { + // Each side of the management network is a chain (deployment guide, + // routed out-of-band network): firewall i links to mgmt server i twice + // (its interface and its BMC) and to mgmt spine i's own mgmt port, and + // mgmt server i uplinks to mgmt spine i only. + const mgmtSpines = central.mgmtSpines + central.mgmtServers.forEach((mgmtServer, i) => { + const mgmtSpine = mgmtSpines[i] + if (mgmtSpine) { links.push({ from: mgmtServer.id, to: mgmtSpine.id, @@ -278,7 +301,29 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition network: 'management', }) } - } + }) + central.mgmtFirewalls.forEach((firewall, i) => { + const mgmtServer = central.mgmtServers[i] + const mgmtSpine = mgmtSpines[i] + if (mgmtServer) { + links.push({ + from: firewall.id, + to: mgmtServer.id, + count: 2, + speed: '1G', + network: 'management', + }) + } + if (mgmtSpine) { + links.push({ + from: firewall.id, + to: mgmtSpine.id, + count: 1, + speed: '1G', + network: 'management', + }) + } + }) const racks = partition.racks.flatMap((rack) => deriveRack(partition, rack, links)) @@ -469,7 +514,12 @@ export function deriveTopology(plan: Plan): TopologyGraph { * management network, or the central rack alone with both networks. */ export type TopologyMode = 'production' | 'management' | 'central' -const MGMT_KINDS = new Set(['mgmt-spine', 'mgmt-leaf', 'mgmt-server']) +const MGMT_KINDS = new Set([ + 'mgmt-spine', + 'mgmt-leaf', + 'mgmt-server', + 'mgmt-firewall', +]) function nodeVisible(node: TopoNode, mode: TopologyMode): boolean { if (mode === 'central') return true @@ -496,6 +546,7 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo exits: keep(p.central.exits), mgmtSpines: keep(p.central.mgmtSpines), mgmtServers: keep(p.central.mgmtServers), + mgmtFirewalls: keep(p.central.mgmtFirewalls), }, storageLeaves: mode === 'central' ? [] : keep(p.storageLeaves), racks: @@ -522,6 +573,7 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo ...p.central.exits, ...p.central.mgmtSpines, ...p.central.mgmtServers, + ...p.central.mgmtFirewalls, ...p.storageLeaves, ...(p.controlPlane ? [p.controlPlane.node] : []), ...p.racks.flatMap((r) => [...r.leaves, ...r.mgmtLeaves, ...r.serverGroups]), diff --git a/src/derive/validate.ts b/src/derive/validate.ts index d6ac61c..4239664 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -1,11 +1,5 @@ import { catalog, itemLabel, portCount, type SwitchRole } from '../model/catalog' -import { - mgmtDeviceCount, - type Partition, - type Plan, - type Rack, - type ServerGroup, -} from '../model/plan' +import { type Partition, type Plan, type Rack, type ServerGroup } from '../model/plan' import { formatGbps, formatRatio, @@ -371,14 +365,15 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v } // Mgmt spine port budgets, matching the cabling model in bom.ts: copper - // 1G ports for the mgmt servers and the management interface of every - // central-rack switch and router; fiber ports (25G, or 10G on switches - // without 25G) for the uplink of every mgmt leaf. + // 1G ports for its own mgmt server and the management interface of every + // central-rack switch and router (the mgmt firewall lands on the spine's + // own mgmt port, not a front-panel one); fiber ports (25G, or 10G on + // switches without 25G) for the uplink of every mgmt leaf. const mgmtSpine = catalog[fabric.mgmt.spineModelId] if (mgmtSpine && partition.racks.length > 0) { const superspines = fabric.fabricType === 'leaf-spine-superspine' ? fabric.superspineCount : 0 const copperNeeded = - mgmtDeviceCount(fabric.mgmt) + + 1 + fabric.spineCount + fabric.exitSwitchCount + superspines + diff --git a/src/model/catalog.ts b/src/model/catalog.ts index c3e7a7a..9565a26 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -353,6 +353,19 @@ export const catalog: Record = { ports: [{ speed: '100G', count: 4 }], powerWatts: 400, }, + 'firewall-mgmt': { + id: 'firewall-mgmt', + category: 'router', + // Vendor-neutral like the internet router: the deployment guide asks + // for firewall rules, destination and hairpin NAT, DHCP with ZTP + // options and BGP, which pfSense or Debian with FRR provide alike. The + // RUTXR1 of earlier setups is end-of-life at Teltonika (last order + // 2027-02-03). The power figure is an estimate for a small 1U server. + description: 'Management firewall, 6× 1G RJ45 (e.g. pfSense, or Debian with FRR)', + heightUnits: 1, + ports: [{ speed: '1G', count: 6 }], + powerWatts: 100, + }, // --- NICs (official metal-stack compatibility list) --- // Intel's ordering codes drop the hyphen of the marketing model name. diff --git a/src/model/defaults.ts b/src/model/defaults.ts index f20d63f..d76206a 100644 --- a/src/model/defaults.ts +++ b/src/model/defaults.ts @@ -26,6 +26,7 @@ export function defaultFabric(): FabricConfig { leafModelId: 'switch-as4630', leafPerRack: 1, serverModelId: 'server-mgmt-121h', + firewallModelId: 'firewall-mgmt', }, leafSpineLinks: 1, nonBlocking: false, diff --git a/src/model/plan.ts b/src/model/plan.ts index f65856d..63f1079 100644 --- a/src/model/plan.ts +++ b/src/model/plan.ts @@ -77,7 +77,8 @@ export const RackSchema = z.object({ export type Rack = z.infer /** The management network has its own topology: a flat L2 network or an L3 - * (routed) fabric, redundant (2 mgmt spines / 2 mgmt servers) or not. */ + * (routed) fabric, redundant (2 mgmt spines / 2 mgmt servers / 2 mgmt + * firewalls) or not. */ export const MgmtNetworkSchema = z.object({ layer: MgmtLayerSchema.default('l3'), redundant: z.boolean().default(true), @@ -85,10 +86,12 @@ export const MgmtNetworkSchema = z.object({ leafModelId: z.string().default('switch-as4630'), leafPerRack: z.number().int().min(0).default(1), serverModelId: z.string().default('server-mgmt-121h'), + firewallModelId: z.string().default('firewall-mgmt'), }) export type MgmtNetwork = z.infer -/** Redundancy drives the count of mgmt spines and mgmt servers. */ +/** Redundancy drives the count of mgmt spines, mgmt servers and mgmt + * firewalls. */ export function mgmtDeviceCount(mgmt: MgmtNetwork): number { return mgmt.redundant ? 2 : 1 } @@ -115,6 +118,7 @@ export const FabricConfigSchema = z.object({ leafModelId: 'switch-as4630', leafPerRack: 1, serverModelId: 'server-mgmt-121h', + firewallModelId: 'firewall-mgmt', }), /** 100G links from every leaf to every spine (uplink bundle size). Every * switch's single management interface connects to the management diff --git a/src/views/TopologyView.tsx b/src/views/TopologyView.tsx index 8e05b4a..7d2210f 100644 --- a/src/views/TopologyView.tsx +++ b/src/views/TopologyView.tsx @@ -13,6 +13,7 @@ const LEGEND_DEVICES: [LucideIcon, string][] = [ [NODE_ICON.router, 'Router'], [NODE_ICON['server-group'], 'Servers'], [NODE_ICON['mgmt-server'], 'Mgmt server'], + [NODE_ICON['mgmt-firewall'], 'Mgmt firewall'], [EXTERNAL_NETWORK_ICON.internet, 'Internet'], ] @@ -37,7 +38,11 @@ function LegendLine(props: { color: string; width: number; dashed?: boolean; lab const modes: { mode: TopologyMode; label: string; hint: string }[] = [ { mode: 'production', label: 'Production', hint: 'Routers, exits, spines, leaves and servers' }, - { mode: 'management', label: 'Management', hint: 'Mgmt spines, mgmt servers, mgmt leaves' }, + { + mode: 'management', + label: 'Management', + hint: 'Mgmt spines, mgmt servers, mgmt firewalls, mgmt leaves', + }, { mode: 'central', label: 'Central rack', hint: 'Only the central rack, both networks' }, ] diff --git a/src/views/icons/index.tsx b/src/views/icons/index.tsx index 273b4ef..9bf90e4 100644 --- a/src/views/icons/index.tsx +++ b/src/views/icons/index.tsx @@ -2,6 +2,7 @@ import { ArrowRight, Binary, Boxes, + BrickWallFire, Building, Cable, Calculator, @@ -120,6 +121,7 @@ export const NODE_ICON: Record = { 'mgmt-spine': NetworkSwitch, 'mgmt-leaf': NetworkSwitch, 'mgmt-server': ServerCog, + 'mgmt-firewall': BrickWallFire, 'server-group': Server, 'control-plane': MetalStack, 'external-network': Globe, diff --git a/src/views/topology/Diagram.tsx b/src/views/topology/Diagram.tsx index 7886324..9fe4775 100644 --- a/src/views/topology/Diagram.tsx +++ b/src/views/topology/Diagram.tsx @@ -13,7 +13,9 @@ import { // Renders the derived TopologyGraph as a fabric elevation. Each partition // draws its central rack as one physical unit with two columns: production // gear on the left (exits/superspines above the spines) and management gear -// on the right (mgmt servers above the mgmt spines). Keeping both tiers +// on the right (mgmt servers above the mgmt spines, each with its mgmt +// firewall on the outer side, so the chain firewall - server - mgmt spine +// of either side never crosses the other). Keeping both tiers // that connect downwards — spines and mgmt spines — in the bottom row means // the uplinks from the compute racks (ToR leaves to spines, mgmt leaf to // mgmt spines) never have to cross another row of devices. Compute racks @@ -104,6 +106,18 @@ function placeRow(rects: Map, nodes: TopoNode[], cx: number, y: nu } } +/** The mgmt servers with their firewalls on the outer side: + * [firewall 1, server 1, server 2, firewall 2]. Each server then sits over + * its own mgmt spine, and each firewall reaches both of its peers without + * crossing a link of the other side. */ +function mgmtServerRow(central: TopoPartition['central']): TopoNode[] { + return central.mgmtServers.flatMap((server, i) => { + const firewall = central.mgmtFirewalls[i] + if (!firewall) return [server] + return i % 2 === 0 ? [firewall, server] : [server, firewall] + }) +} + /** A centered row of external network capsules. */ function placeCapsules(rects: Map, nodes: TopoNode[], cx: number, y: number) { let x = cx - (nodes.length * (EXT_W + GAP) - GAP) / 2 @@ -217,8 +231,8 @@ function layoutPartition( : 0 // Central rack, two columns: production (optional routers, then - // superspines/exits, then spines) and management (mgmt servers over mgmt - // spines, aligned to the bottom rows). + // superspines/exits, then spines) and management (mgmt servers and + // firewalls over mgmt spines, aligned to the bottom rows). const hasRouters = central.routers.length > 0 // On-prem control-plane nodes in the central rack share the top row with // the routers; a managed cluster is a capsule above the rack instead. @@ -226,7 +240,8 @@ function layoutPartition( const row0 = [...central.routers, ...(cpBox ? [cpBox] : [])] const prodRow1W = rowWidth(central.superspines.length) + rowWidth(central.exits.length) + 56 const prodW = Math.max(prodRow1W, rowWidth(central.spines.length), rowWidth(row0.length)) - const mgmtW = Math.max(rowWidth(central.mgmtServers.length), rowWidth(central.mgmtSpines.length)) + const mgmtRow = mgmtServerRow(central) + const mgmtW = Math.max(rowWidth(mgmtRow.length), rowWidth(central.mgmtSpines.length)) const columnGap = prodW > 0 && mgmtW > 0 ? COLUMN_GAP : 0 const centralInnerW = prodW + columnGap + mgmtW const fabricW = Math.max(racksW + storageW, centralInnerW + 2 * RACK_PAD, 300) @@ -249,7 +264,7 @@ function layoutPartition( if (row0.length > 0) placeRow(rects, row0, prodCx, row0Y) placeGroupedRow(rects, central.superspines, central.exits, prodCx, row1Y) placeRow(rects, central.spines, prodCx, row2Y) - placeRow(rects, central.mgmtServers, mgmtCx, row1Y) + placeRow(rects, mgmtRow, mgmtCx, row1Y) placeRow(rects, central.mgmtSpines, mgmtCx, row2Y) const boxRect: Rect = { x: innerLeft - RACK_PAD, @@ -390,7 +405,10 @@ function NodeBox({ node, r, capsule }: { node: TopoNode; r: Rect; capsule?: bool node.kind === 'server-group' || node.kind === 'mgmt-server' || node.kind === 'router' const isExternal = capsule ?? node.kind === 'external-network' const isMgmt = - node.kind === 'mgmt-spine' || node.kind === 'mgmt-leaf' || node.kind === 'mgmt-server' + node.kind === 'mgmt-spine' || + node.kind === 'mgmt-leaf' || + node.kind === 'mgmt-server' || + node.kind === 'mgmt-firewall' const labelY = r.y + 18 const subY = labelY + 12 @@ -484,6 +502,7 @@ export default function Diagram({ ...p.central.exits, ...p.central.mgmtSpines, ...p.central.mgmtServers, + ...p.central.mgmtFirewalls, ...p.storageLeaves, ...(p.controlPlane ? [p.controlPlane.node] : []), ...p.racks.flatMap((r) => [...r.leaves, ...r.mgmtLeaves, ...r.serverGroups]), From 14d861251394501a40a316287e2109fafb46236d Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:30:02 +0200 Subject: [PATCH 02/27] draw one mgmt interface per spine Every switch has a single management interface, but the topology drew a line from each spine to every mgmt spine. The spines now take turns on the mgmt spines, as in the deployment guide (spine01 on mgmt-spine01). The BOM already counted one cable per spine. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/derive/topology.test.ts | 11 +++++++++++ src/derive/topology.ts | 23 ++++++++++++++++------- 3 files changed, 28 insertions(+), 8 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 04ae11f..28ed8e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -42,7 +42,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Central rack**: spines, exits, superspines, mgmt spines, mgmt servers and mgmt firewalls 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 ` 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, mgmt servers and mgmt firewalls (2 vs 1, `mgmtDeviceCount()`). Each side is a chain as in the deployment guide's routed out-of-band network: mgmt firewall i links to mgmt server i (interface and BMC) and to mgmt spine i's own mgmt port, and mgmt server i uplinks to mgmt spine i only, never across. The firewall (`MgmtNetwork.firewallModelId`, catalog `firewall-mgmt`) is a vendor-neutral 1U server with 6× 1G RJ45; the Teltonika RUTXR1 of earlier setups is end-of-life. 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. +- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines, mgmt servers and mgmt firewalls (2 vs 1, `mgmtDeviceCount()`). Each side is a chain as in the deployment guide's routed out-of-band network: mgmt firewall i links to mgmt server i (interface and BMC) and to mgmt spine i's own mgmt port, and mgmt server i uplinks to mgmt spine i only, never across. The firewall (`MgmtNetwork.firewallModelId`, catalog `firewall-mgmt`) is a vendor-neutral 1U server with 6× 1G RJ45; the Teltonika RUTXR1 of earlier setups is end-of-life. 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 (the topology spreads the spines over the mgmt spines in turn) — 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. - `ServerGroup.role` is `worker` | `storage` only. Management servers are never a compute-rack group — they are configured on the fabric's management network and live in the central rack. Legacy `management` groups are dropped on import in `model/normalize.ts`. - **Fabric bandwidth.** `src/derive/bandwidth.ts` is the single source of the oversubscription ratio: per rack `Σ nodes × (50 Gbit/s for 2x25G, 200 for 2x100G)` against `leafCount × spineCount × leafSpineLinks × 100 Gbit/s`, and per spine `leaves × leafSpineLinks × 100` against `superspineCount × 100` when a superspine tier exists. `FabricConfig.nonBlocking` (default `false`, so templates stay valid) turns a ratio above 1:1 into a validation error naming the links per leaf ↔ spine pair (or superspines) that would fix it; the ratio chips in the plan editor show it either way. North-south bandwidth (spines → exits → routers) is deliberately not part of this. diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index e98ad92..db1b4d0 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -48,6 +48,17 @@ describe('deriveTopology', () => { }) }) + it('gives every spine one mgmt interface, spread over the mgmt spines', () => { + const graph = deriveTopology(createEmptyPlan()) + const { spines, mgmtSpines } = graph.partitions[0].central + const mgmtSpineIds = new Set(mgmtSpines.map((n) => n.id)) + const toMgmt = graph.links.filter((l) => mgmtSpineIds.has(l.to)) + spines.forEach((spine, i) => { + const own = toMgmt.filter((l) => l.from === spine.id) + expect(own.map((l) => l.to)).toEqual([mgmtSpines[i % mgmtSpines.length].id]) + }) + }) + it('keeps the mgmt firewalls in management and central mode only', () => { const graph = deriveTopology(createEmptyPlan()) expect(filterTopology(graph, 'management').partitions[0].central.mgmtFirewalls).toHaveLength(2) diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 64dc127..1f9dc92 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -281,15 +281,24 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition network: 'production', }) } - for (const mgmtSpine of central.mgmtSpines) { - links.push({ from: spine.id, to: mgmtSpine.id, count: 1, speed: '1G', network: 'management' }) - } } - // Each side of the management network is a chain (deployment guide, - // routed out-of-band network): firewall i links to mgmt server i twice - // (its interface and its BMC) and to mgmt spine i's own mgmt port, and - // mgmt server i uplinks to mgmt spine i only. + // Every switch has a single mgmt interface: the spines take turns on the + // mgmt spines. Each side of the management network is a chain (deployment + // guide, routed out-of-band network): firewall i links to mgmt server i + // twice (its interface and its BMC) and to mgmt spine i's own mgmt port, + // and mgmt server i uplinks to mgmt spine i only. const mgmtSpines = central.mgmtSpines + central.spines.forEach((spine, i) => { + if (mgmtSpines.length === 0) return + const mgmtSpine = mgmtSpines[i % mgmtSpines.length] + links.push({ + from: spine.id, + to: mgmtSpine.id, + count: 1, + speed: '1G', + network: 'management', + }) + }) central.mgmtServers.forEach((mgmtServer, i) => { const mgmtSpine = mgmtSpines[i] if (mgmtSpine) { From ae55a9989e10c55afb9bc6fc71ae5d80ee753038 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 13:59:24 +0200 Subject: [PATCH 03/27] color mgmt links by the port they land on A link from a switch's eth0 into the management network looked exactly like a front-panel data link. TopoLink.mgmtPort now marks the links with an end on a dedicated management interface (leaf to mgmt leaf, spine to mgmt spine, mgmt firewall to mgmt spine); the diagram draws those red and the other management links green. Production stays blue. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/derive/topology.test.ts | 33 +++++++++++++++++++++++++++++++++ src/derive/topology.ts | 7 +++++++ src/views/TopologyView.tsx | 5 ++++- src/views/colors.ts | 4 ++++ src/views/topology/Diagram.tsx | 10 ++++++++-- 6 files changed, 57 insertions(+), 4 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 28ed8e5..8397bb9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Validation is derived too.** `src/derive/validate.ts` computes `Issue[]` from the plan: leaf/spine port-capacity checks (with the documented capacity model assumptions in its header comment) and compatibility-list checks. UI dropdowns are populated via `switchesForRole()` / `serversForUsage()` from the catalog, so incompatible hardware is filtered at the source. - **BOM lines carry reasons.** `BomBuilder.add(id, qty, detail)` records a `BomReason` (`{ quantity, detail, where }`) per contributing rule (`BomLine.reasons`); every rule must pass a short, human-readable detail. `where` is the section the BOM view groups by — `"Partition 2 - Rack 1"`, `"Central rack"`, or empty for plan-wide rules like spares — built from `BomBuilder.inPartition()` and `.at()`, so a rule states its place once instead of repeating it in the detail. Rules that apply per rack are emitted per rack rather than summed, so each rack shows in its own section. `addPartition` emits all central-rack rules before any rack's, and racks in plan order, so first-seen grouping yields the physical order (central rack, then Rack 1, Rack 2 …) for every line without a sort key — keep that emission order when adding rules. `reasonsByWhere()` does the grouping for the view and `formatReasons()` the one-cell text for the CSV/XLSX exports. No long dashes in BOM text, as anywhere in the UI (see Styling). `deriveBom(plan)` is the plan-wide BOM including spares (`Plan.sparesPerLine` per transceiver/cable line, category `spare`, id `#spare`); `deriveBomByPartition(plan)` gives per-partition BOMs without spares. Both take a `BomScope` (`all` | `production` | `management`) for the BOM view's tabs, which mirror the topology view's modes: a line can have contributions from both networks (25G SR optics serve server uplinks _and_ the mgmt fiber), so `BomBuilder.on(network)` tags each rule and the filter drops out-of-scope contributions — the two scopes sum to `all` per line. The partition selector is the orthogonal control, so any combination is reachable. - **Price book.** Prices are never part of the plan. `src/store/priceStore.ts` persists `{ currency, prices: Record }` under `metal-stack-planner/prices`; `src/io/priceBook.ts` holds the schema, JSON import/export and the money helpers (spares price like their base item). CSV/XLSX exporters accept an optional price book. -- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %; Fit also enlarges small diagrams, up to 160 %) and carries the legend in its footer; the side-panel thumbnail uses `fit`. Boxes carry a `target` so clicking a rack, rack group or central rack jumps to its editor section via `views/plan/navigate.ts`; hovering a node fades unrelated links and nodes. +- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %; Fit also enlarges small diagrams, up to 160 %) and carries the legend in its footer; the side-panel thumbnail uses `fit`. Boxes carry a `target` so clicking a rack, rack group or central rack jumps to its editor section via `views/plan/navigate.ts`; hovering a node fades unrelated links and nodes. Link color: production blue, external gray dashed; management links are green between front-panel ports and red where `TopoLink.mgmtPort` marks an end on a switch's management interface (eth0: leaf → mgmt leaf, spine → mgmt spine, mgmt firewall → mgmt spine). Mgmt nodes keep the brand amber. - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index db1b4d0..107b624 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -59,6 +59,39 @@ describe('deriveTopology', () => { }) }) + it('marks exactly the links that land on a switch management interface', () => { + const graph = deriveTopology(createEmptyPlan()) + const partition = graph.partitions[0] + const { spines, mgmtSpines, mgmtServers, mgmtFirewalls } = partition.central + const rack = partition.racks[0] + const kindOf = new Map( + [ + ...spines, + ...mgmtSpines, + ...mgmtServers, + ...mgmtFirewalls, + ...rack.leaves, + ...rack.mgmtLeaves, + ].map((n) => [n.id, n.kind]), + ) + const pair = (l: { from: string; to: string }) => `${kindOf.get(l.from)}>${kindOf.get(l.to)}` + const management = graph.links.filter((l) => l.network === 'management') + const marked = new Set(management.filter((l) => l.mgmtPort).map(pair)) + const unmarked = new Set(management.filter((l) => !l.mgmtPort).map(pair)) + + expect([...marked].sort()).toEqual([ + 'leaf>mgmt-leaf', + 'mgmt-firewall>mgmt-spine', + 'spine>mgmt-spine', + ]) + expect([...unmarked].sort()).toEqual([ + 'mgmt-firewall>mgmt-server', + 'mgmt-leaf>mgmt-spine', + 'mgmt-server>mgmt-spine', + ]) + expect(graph.links.filter((l) => l.network !== 'management' && l.mgmtPort)).toHaveLength(0) + }) + it('keeps the mgmt firewalls in management and central mode only', () => { const graph = deriveTopology(createEmptyPlan()) expect(filterTopology(graph, 'management').partitions[0].central.mgmtFirewalls).toHaveLength(2) diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 1f9dc92..ae72917 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -47,6 +47,10 @@ export interface TopoLink { count: number speed?: '1G' | '10G' | '25G' | '100G' network: LinkNetwork + /** One end lands on a switch's dedicated management interface (eth0) + * rather than a front-panel port: a leaf or spine reaching the + * management network, or the mgmt firewall bootstrapping a mgmt spine. */ + mgmtPort?: true } /** One physical rack. A rack group yields three of these that @@ -149,6 +153,7 @@ function deriveRack(partition: Partition, rack: Rack, links: TopoLink[]): TopoRa count: 1, speed: '1G', network: 'management', + mgmtPort: true, }) } } @@ -297,6 +302,7 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition count: 1, speed: '1G', network: 'management', + mgmtPort: true, }) }) central.mgmtServers.forEach((mgmtServer, i) => { @@ -330,6 +336,7 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition count: 1, speed: '1G', network: 'management', + mgmtPort: true, }) } }) diff --git a/src/views/TopologyView.tsx b/src/views/TopologyView.tsx index 7d2210f..bcb953f 100644 --- a/src/views/TopologyView.tsx +++ b/src/views/TopologyView.tsx @@ -97,7 +97,10 @@ export default function TopologyView() { )} {mode !== 'production' && ( - + <> + + + )} {mode !== 'management' && ( diff --git a/src/views/colors.ts b/src/views/colors.ts index bfd14a2..e2e4dbb 100644 --- a/src/views/colors.ts +++ b/src/views/colors.ts @@ -13,6 +13,10 @@ export const COLOR = { brandTint: 'var(--color-brand-tint)', production: 'var(--color-production)', mgmt: 'var(--color-mgmt)', + /** Management links between front-panel ports. */ + mgmtData: 'var(--color-green-600)', + /** Management links that land on a switch's management interface (eth0). */ + mgmtPort: 'var(--color-red-600)', /** Grays, lightest first. */ gray50: 'var(--color-gray-50)', gray100: 'var(--color-gray-100)', diff --git a/src/views/topology/Diagram.tsx b/src/views/topology/Diagram.tsx index 9fe4775..b030a3a 100644 --- a/src/views/topology/Diagram.tsx +++ b/src/views/topology/Diagram.tsx @@ -50,10 +50,16 @@ const EXT_H = 34 const LINK_COLOR: Record = { production: COLOR.production, - management: COLOR.mgmt, + management: COLOR.mgmtData, external: COLOR.gray500, } +/** Management links are colored by the port they land on: green between + * front-panel ports, red where one end is a switch's eth0. */ +function linkColor(link: TopoLink): string { + return link.mgmtPort ? COLOR.mgmtPort : LINK_COLOR[link.network] +} + const LINK_WIDTH: Record | 'none', number> = { '100G': 2.2, '25G': 1.4, @@ -570,7 +576,7 @@ export default function Diagram({ const to = rects.get(link.to) if (!from || !to) return null const { path } = linkGeometry(from, to) - const color = LINK_COLOR[link.network] + const color = linkColor(link) return ( Date: Sat, 26 Sep 2026 14:11:02 +0200 Subject: [PATCH 04/27] capture the pointer only once a press becomes a pan ZoomPane captured the pointer on every press, which retargets the click to the pan container: no click on a diagram box or node ever arrived, so "click a rack to open its editor" did nothing. Capture now starts after the pointer has moved PAN_THRESHOLD pixels. Co-Authored-By: Claude Opus 5.5 --- src/views/topology/ZoomPane.tsx | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/src/views/topology/ZoomPane.tsx b/src/views/topology/ZoomPane.tsx index f862f4e..259ca2d 100644 --- a/src/views/topology/ZoomPane.tsx +++ b/src/views/topology/ZoomPane.tsx @@ -11,6 +11,8 @@ const MIN_SCALE = 0.15 const MAX_SCALE = 3 const MAX_FIT = 1.6 const PAD = 16 +/** Pointer travel in px before a press counts as a pan instead of a click. */ +const PAN_THRESHOLD = 4 interface View { x: number @@ -31,6 +33,10 @@ export default function ZoomPane({ const [content, setContent] = useState({ w: 0, h: 0 }) const [dragging, setDragging] = useState(false) const drag = useRef<{ x: number; y: number; vx: number; vy: number } | null>(null) + // Capture the pointer only once a press has moved far enough to be a + // pan: capturing on press would retarget the click to this container, and + // a click on a diagram element would never arrive. + const panned = useRef(false) function fit(w = content.w, h = content.h) { const el = outer.current @@ -100,12 +106,17 @@ export default function ZoomPane({ onPointerDown={(e) => { if (e.button !== 0) return drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y } - setDragging(true) - e.currentTarget.setPointerCapture(e.pointerId) + panned.current = false }} onPointerMove={(e) => { const d = drag.current if (!d) return + if (!panned.current) { + if (Math.hypot(e.clientX - d.x, e.clientY - d.y) < PAN_THRESHOLD) return + panned.current = true + setDragging(true) + e.currentTarget.setPointerCapture(e.pointerId) + } setView((v) => ({ ...v, x: d.vx + (e.clientX - d.x), y: d.vy + (e.clientY - d.y) })) }} onPointerUp={() => { From 9e2d97b11b18c7ab9ad738e88ebeef07b36bb7c0 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 14:11:02 +0200 Subject: [PATCH 05/27] add a click-together builder to the topology tab A palette adds partitions, racks, rack groups, server groups, spines, exits, routers and external networks at the current selection, and an inspector shows the selected section's editor. Every palette item is an existing store action, so the topology, BOM and validation stay derived from the Plan and every add is undoable. The selection is view state and never part of the Plan. The placement rules live in views/topology/build.ts. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/views/TopologyView.tsx | 227 ++++++++++++++++++++++++++----- src/views/icons/index.tsx | 13 ++ src/views/topology/Diagram.tsx | 45 ++++-- src/views/topology/build.test.ts | 175 ++++++++++++++++++++++++ src/views/topology/build.ts | 198 +++++++++++++++++++++++++++ 6 files changed, 610 insertions(+), 50 deletions(-) create mode 100644 src/views/topology/build.test.ts create mode 100644 src/views/topology/build.ts diff --git a/CLAUDE.md b/CLAUDE.md index 8397bb9..e63579e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,7 +25,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Validation is derived too.** `src/derive/validate.ts` computes `Issue[]` from the plan: leaf/spine port-capacity checks (with the documented capacity model assumptions in its header comment) and compatibility-list checks. UI dropdowns are populated via `switchesForRole()` / `serversForUsage()` from the catalog, so incompatible hardware is filtered at the source. - **BOM lines carry reasons.** `BomBuilder.add(id, qty, detail)` records a `BomReason` (`{ quantity, detail, where }`) per contributing rule (`BomLine.reasons`); every rule must pass a short, human-readable detail. `where` is the section the BOM view groups by — `"Partition 2 - Rack 1"`, `"Central rack"`, or empty for plan-wide rules like spares — built from `BomBuilder.inPartition()` and `.at()`, so a rule states its place once instead of repeating it in the detail. Rules that apply per rack are emitted per rack rather than summed, so each rack shows in its own section. `addPartition` emits all central-rack rules before any rack's, and racks in plan order, so first-seen grouping yields the physical order (central rack, then Rack 1, Rack 2 …) for every line without a sort key — keep that emission order when adding rules. `reasonsByWhere()` does the grouping for the view and `formatReasons()` the one-cell text for the CSV/XLSX exports. No long dashes in BOM text, as anywhere in the UI (see Styling). `deriveBom(plan)` is the plan-wide BOM including spares (`Plan.sparesPerLine` per transceiver/cable line, category `spare`, id `#spare`); `deriveBomByPartition(plan)` gives per-partition BOMs without spares. Both take a `BomScope` (`all` | `production` | `management`) for the BOM view's tabs, which mirror the topology view's modes: a line can have contributions from both networks (25G SR optics serve server uplinks _and_ the mgmt fiber), so `BomBuilder.on(network)` tags each rule and the filter drops out-of-scope contributions — the two scopes sum to `all` per line. The partition selector is the orthogonal control, so any combination is reachable. - **Price book.** Prices are never part of the plan. `src/store/priceStore.ts` persists `{ currency, prices: Record }` under `metal-stack-planner/prices`; `src/io/priceBook.ts` holds the schema, JSON import/export and the money helpers (spares price like their base item). CSV/XLSX exporters accept an optional price book. -- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %; Fit also enlarges small diagrams, up to 160 %) and carries the legend in its footer; the side-panel thumbnail uses `fit`. Boxes carry a `target` so clicking a rack, rack group or central rack jumps to its editor section via `views/plan/navigate.ts`; hovering a node fades unrelated links and nodes. Link color: production blue, external gray dashed; management links are green between front-panel ports and red where `TopoLink.mgmtPort` marks an end on a switch's management interface (eth0: leaf → mgmt leaf, spine → mgmt spine, mgmt firewall → mgmt spine). Mgmt nodes keep the brand amber. +- **Topology view modes.** `filterTopology(graph, mode)` in `src/derive/topology.ts` derives the subgraph for `production` (default: no mgmt nodes/links), `management` (only mgmt nodes and links, no external networks) or `central` (central rack only, both networks). `TopologyView` toggles the mode; `Diagram` must render any of them (e.g. an empty production column). `ZoomPane` wraps the diagram (wheel zoom, drag pan, Fit / 100 %; Fit also enlarges small diagrams, up to 160 %) and carries the legend in its footer; the side-panel thumbnail uses `fit`. The topology tab is also a builder: a palette adds partitions, racks, rack groups, server groups, spines, exits, routers and external networks at the current selection, and an inspector shows the selected section's editor (`RackSection`, `CentralRackSection`, …) with an "Open in plan editor" jump via `views/plan/navigate.ts`. Clicking a box or node selects what it belongs to (`nodeSelections()`); `views/topology/build.ts` holds the pure rules (where an item lands, what is selected afterwards, which mode shows it), and every palette item is an existing store action, so the topology stays derived and every add is undoable. The selection is view state, never part of the Plan. `ZoomPane` captures the pointer only once a press moves past `PAN_THRESHOLD`; capturing on press retargets the click and no diagram click would arrive. hovering a node fades unrelated links and nodes. Link color: production blue, external gray dashed; management links are green between front-panel ports and red where `TopoLink.mgmtPort` marks an end on a switch's management interface (eth0: leaf → mgmt leaf, spine → mgmt spine, mgmt firewall → mgmt spine). Mgmt nodes keep the brand amber. - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. diff --git a/src/views/TopologyView.tsx b/src/views/TopologyView.tsx index bcb953f..a80ee91 100644 --- a/src/views/TopologyView.tsx +++ b/src/views/TopologyView.tsx @@ -1,11 +1,34 @@ -import { useState } from 'react' +import { useMemo, useState } from 'react' import { deriveTopology, filterTopology, type TopologyMode } from '../derive/topology' +import { validatePlan, type Issue, type IssueTarget } from '../derive/validate' +import type { Plan } from '../model/plan' import { usePlanStore } from '../store/planStore' -import { EXTERNAL_NETWORK_ICON, Icon, NODE_ICON, type LucideIcon } from './icons' - +import { + ACTION_ICON, + EXTERNAL_NETWORK_ICON, + Icon, + NODE_ICON, + PALETTE_ICON, + type LucideIcon, +} from './icons' +import CentralRackSection from './plan/CentralRackSection' +import ControlPlaneSection from './plan/ControlPlaneSection' +import ExternalNetworksSection from './plan/ExternalNetworksSection' +import RackSection from './plan/RackSection' import { navigateTo } from './plan/navigate' import ZoomPane from './topology/ZoomPane' import Diagram from './topology/Diagram' +import { + PALETTE, + modeShowing, + paletteAction, + paletteState, + resolveSelection, + selectionAfter, + type PaletteItemId, + type PlanAction, + type Selection, +} from './topology/build' import { COLOR } from './colors' const LEGEND_DEVICES: [LucideIcon, string][] = [ @@ -46,21 +69,135 @@ const modes: { mode: TopologyMode; label: string; hint: string }[] = [ { mode: 'central', label: 'Central rack', hint: 'Only the central rack, both networks' }, ] +/** Runs a builder action through the plan store, so it is undoable like + * any edit in the Plan tab. */ +function runAction(action: PlanAction): void { + const store = usePlanStore.getState() + switch (action.type) { + case 'addPartition': + return store.addPartition() + case 'addRack': + return store.addRack(action.partitionId, action.kind) + case 'addServerGroup': + return store.addServerGroup(action.partitionId, action.rackId) + case 'patchFabric': + return store.patchFabric(action.partitionId, action.patch) + case 'addExternalNetwork': + return store.addExternalNetwork() + } +} + +function Palette({ + plan, + selection, + onAdd, +}: { + plan: Plan + selection: Selection | undefined + onAdd: (id: PaletteItemId) => void +}) { + return ( +
+

Click to add

+
+ {PALETTE.map((item) => { + const state = paletteState(item.id, plan, selection) + return ( + + ) + })} +
+
+ ) +} + +/** Where "Open in plan editor" jumps: the networks section has no anchor + * of its own, so it opens the Plan tab at the top. */ +function editorTarget(selection: Selection | undefined): IssueTarget { + if (!selection || selection.section === 'networks') return {} + if (selection.section === 'control-plane') return { section: 'control-plane' } + return { partitionId: selection.partitionId, rackId: selection.rackId } +} + +function Inspector({ + plan, + issues, + selection, +}: { + plan: Plan + issues: Issue[] + selection: Selection | undefined +}) { + const partition = plan.partitions.find((p) => p.id === selection?.partitionId) + const rack = partition?.racks.find((r) => r.id === selection?.rackId) + const body = + selection?.section === 'networks' ? ( + + ) : selection?.section === 'control-plane' ? ( + + ) : partition && rack ? ( + + ) : partition ? ( + + ) : undefined + + if (!body) { + return ( +

+ Click the central rack, a rack or a device to edit it here, or add something from the + palette. +

+ ) + } + return ( +
+
+ +
+
{body}
+
+ ) +} + export default function TopologyView() { const plan = usePlanStore((s) => s.plan) const [mode, setMode] = useState('production') + const [picked, setPicked] = useState() + const selection = resolveSelection(plan, picked) + const issues = useMemo(() => validatePlan(plan), [plan]) const graph = filterTopology(deriveTopology(plan), mode) const hasContent = graph.partitions.some( (p) => p.racks.length > 0 || p.central.spines.length > 0 || p.central.exits.length > 0, ) - if (!hasContent) { - return ( -

- Nothing to draw yet. Add spines and racks in the Plan tab. -

- ) + function add(id: PaletteItemId) { + const before = usePlanStore.getState().plan + const action = paletteAction(id, before, selection) + if (!action) return + runAction(action) + setPicked(selectionAfter(action, before, usePlanStore.getState().plan)) + setMode(modeShowing(id, mode)) } return ( @@ -90,34 +227,50 @@ export default function TopologyView() { {modes.find((m) => m.mode === mode)?.hint} - - {mode !== 'management' && ( - - )} - {mode !== 'production' && ( - <> - - - - )} - {mode !== 'management' && ( - - )} - - {LEGEND_DEVICES.map(([icon, label]) => ( - - - {label} - - ))} - - - } - > - - +
+
+ +
+
+ {hasContent ? ( + + {mode !== 'management' && ( + + )} + {mode !== 'production' && ( + <> + + + + )} + {mode !== 'management' && ( + + )} + + {LEGEND_DEVICES.map(([icon, label]) => ( + + + {label} + + ))} + + + } + > + + + ) : ( +

+ Nothing to draw yet. Add a partition, spines and racks from the palette. +

+ )} +
+
+ +
+
) } diff --git a/src/views/icons/index.tsx b/src/views/icons/index.tsx index 9bf90e4..749f868 100644 --- a/src/views/icons/index.tsx +++ b/src/views/icons/index.tsx @@ -49,6 +49,7 @@ import type { TopoNodeKind } from '../../derive/topology' import type { CatalogCategory } from '../../model/catalog' import type { ExternalNetwork } from '../../model/plan' import type { View } from '../../store/planStore' +import type { PaletteItemId } from '../topology/build' import { MetalStack, NetworkSwitch, Rack, RackGroup } from './custom' // The single place icons come from (Lucide plus the custom switch and rack @@ -127,6 +128,18 @@ export const NODE_ICON: Record = { 'external-network': Globe, } +/** The topology builder's palette. */ +export const PALETTE_ICON: Record = { + partition: Layers, + rack: Rack, + 'rack-group': RackGroup, + 'server-group': Server, + spine: NetworkSwitch, + exit: NetworkSwitch, + router: Router, + 'external-network': Globe, +} + export const EXTERNAL_NETWORK_ICON: Record = { internet: Globe, company: Building, diff --git a/src/views/topology/Diagram.tsx b/src/views/topology/Diagram.tsx index b030a3a..01ae5c2 100644 --- a/src/views/topology/Diagram.tsx +++ b/src/views/topology/Diagram.tsx @@ -9,6 +9,7 @@ import { type TopoPartition, type TopoRack, } from '../../derive/topology' +import { nodeSelections, type Selection } from './build' // Renders the derived TopologyGraph as a fabric elevation. Each partition // draws its central rack as one physical unit with two columns: production @@ -81,11 +82,8 @@ function rowWidth(n: number): number { return n > 0 ? n * NODE_W + (n - 1) * GAP : 0 } -/** Editor section a diagram element belongs to (see views/plan/navigate). */ -export interface DiagramTarget { - partitionId: string - rackId?: string -} +/** What a click on a diagram element selects (see ./build). */ +export type DiagramTarget = Selection interface BoxLayout { rect: Rect @@ -475,15 +473,26 @@ function NodeBox({ node, r, capsule }: { node: TopoNode; r: Rect; capsule?: bool export default function Diagram({ graph, fit = false, - onNavigate, + onSelect, + selected, }: { graph: TopologyGraph fit?: boolean - /** Click on a rack, rack group or central rack box → jump to its editor section. */ - onNavigate?: (target: DiagramTarget) => void + /** Click on a box (central rack, rack, rack group) or a node selects + * what it belongs to. */ + onSelect?: (target: DiagramTarget) => void + /** Boxes belonging to this selection are outlined. */ + selected?: Selection }) { const layout = computeLayout(graph) const { rects } = layout + const selectionOf = nodeSelections(graph) + const isSelected = (target: DiagramTarget | undefined) => + !!selected && + !!target && + !selected.section && + target.partitionId === selected.partitionId && + target.rackId === selected.rackId const [hover, setHover] = useState(null) const interactive = !fit @@ -539,11 +548,11 @@ export default function Diagram({ onNavigate(box.target!) : undefined + interactive && box.target && onSelect ? () => onSelect(box.target!) : undefined } - style={interactive && box.target && onNavigate ? { cursor: 'pointer' } : undefined} + style={interactive && box.target && onSelect ? { cursor: 'pointer' } : undefined} > - {interactive && box.target && onNavigate && Edit {box.name} in the plan} + {interactive && box.target && onSelect && Select {box.name}} setHover(node.id) : undefined} onMouseLeave={interactive ? () => setHover(null) : undefined} + onClick={ + interactive && onSelect && selectionOf.has(node.id) + ? (e) => { + e.stopPropagation() + onSelect(selectionOf.get(node.id)!) + } + : undefined + } + style={interactive && onSelect ? { cursor: 'pointer' } : undefined} > diff --git a/src/views/topology/build.test.ts b/src/views/topology/build.test.ts new file mode 100644 index 0000000..25b19ff --- /dev/null +++ b/src/views/topology/build.test.ts @@ -0,0 +1,175 @@ +import { describe, expect, it } from 'vitest' +import { createEmptyPlan, defaultPartition, withRackKind } from '../../model/defaults' +import { CONTROL_PLANE_RACK_ID, deriveTopology } from '../../derive/topology' +import { + modeShowing, + nodeSelections, + paletteAction, + paletteState, + resolveSelection, + selectionAfter, + type PaletteItemId, +} from './build' + +describe('resolveSelection', () => { + it('keeps a rack that still exists', () => { + const plan = createEmptyPlan() + const partitionId = plan.partitions[0].id + const rackId = plan.partitions[0].racks[0].id + expect(resolveSelection(plan, { partitionId, rackId })).toEqual({ partitionId, rackId }) + }) + + it('falls back to the partition once the rack is gone', () => { + const plan = createEmptyPlan() + const partitionId = plan.partitions[0].id + expect(resolveSelection(plan, { partitionId, rackId: 'gone' })).toEqual({ partitionId }) + }) + + it('drops a selection whose partition is gone', () => { + const plan = createEmptyPlan() + expect(resolveSelection(plan, { partitionId: 'gone', rackId: 'gone' })).toBeUndefined() + }) + + it('maps the control-plane rack to the control plane section', () => { + const plan = createEmptyPlan() + const partitionId = plan.partitions[0].id + expect(resolveSelection(plan, { partitionId, rackId: CONTROL_PLANE_RACK_ID })).toEqual({ + section: 'control-plane', + }) + }) +}) + +describe('paletteState', () => { + it('adds racks to the selected partition, else to the first one', () => { + const plan = createEmptyPlan() + plan.partitions.push(defaultPartition('Partition 2')) + const second = plan.partitions[1] + expect(paletteState('rack', plan, { partitionId: second.id })).toEqual({ + enabled: true, + hint: 'Adds to Partition 2', + }) + expect(paletteState('rack', plan, undefined).hint).toBe(`Adds to ${plan.partitions[0].name}`) + }) + + it('needs a selected rack for a server group', () => { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + expect(paletteState('server-group', plan, { partitionId: partition.id })).toEqual({ + enabled: false, + hint: 'Select a rack first', + }) + expect( + paletteState('server-group', plan, { + partitionId: partition.id, + rackId: partition.racks[0].id, + }), + ).toEqual({ enabled: true, hint: `Adds to ${partition.racks[0].name}` }) + }) + + it('needs a partition for everything that lives in one', () => { + const plan = createEmptyPlan() + plan.partitions = [] + for (const id of ['rack', 'rack-group', 'spine', 'exit', 'router'] as PaletteItemId[]) { + expect(paletteState(id, plan, undefined)).toEqual({ + enabled: false, + hint: 'Add a partition first', + }) + } + expect(paletteState('partition', plan, undefined).enabled).toBe(true) + expect(paletteState('external-network', plan, undefined).enabled).toBe(true) + }) +}) + +describe('paletteAction', () => { + it('turns a tier item into a count increment on the fabric', () => { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + const sel = { partitionId: partition.id } + expect(paletteAction('spine', plan, sel)).toEqual({ + type: 'patchFabric', + partitionId: partition.id, + patch: { spineCount: partition.fabric.spineCount + 1 }, + }) + expect(paletteAction('router', plan, sel)).toEqual({ + type: 'patchFabric', + partitionId: partition.id, + patch: { routerCount: partition.fabric.routerCount + 1 }, + }) + }) + + it('adds a rack group as a rack of that kind', () => { + const plan = createEmptyPlan() + const partitionId = plan.partitions[0].id + expect(paletteAction('rack-group', plan, { partitionId })).toEqual({ + type: 'addRack', + partitionId, + kind: 'rack-group', + }) + }) + + it('does nothing for a disabled item', () => { + const plan = createEmptyPlan() + expect(paletteAction('server-group', plan, undefined)).toBeUndefined() + }) +}) + +describe('selectionAfter', () => { + it('selects the rack an add created', () => { + const before = createEmptyPlan() + const partitionId = before.partitions[0].id + const after = structuredClone(before) + after.partitions[0].racks.push({ ...after.partitions[0].racks[0], id: 'new-rack' }) + const action = { type: 'addRack', partitionId, kind: 'single' } as const + expect(selectionAfter(action, before, after)).toEqual({ partitionId, rackId: 'new-rack' }) + }) + + it('selects the partition an add created', () => { + const before = createEmptyPlan() + const after = structuredClone(before) + after.partitions.push({ ...after.partitions[0], id: 'new-partition' }) + expect(selectionAfter({ type: 'addPartition' }, before, after)).toEqual({ + partitionId: 'new-partition', + }) + }) + + it('keeps the rack selected after adding a server group to it', () => { + const plan = createEmptyPlan() + const partitionId = plan.partitions[0].id + const rackId = plan.partitions[0].racks[0].id + const action = { type: 'addServerGroup', partitionId, rackId } as const + expect(selectionAfter(action, plan, plan)).toEqual({ partitionId, rackId }) + }) +}) + +describe('modeShowing', () => { + it('switches to a mode that shows what was just added', () => { + expect(modeShowing('rack', 'central')).toBe('production') + expect(modeShowing('spine', 'management')).toBe('production') + expect(modeShowing('external-network', 'management')).toBe('production') + expect(modeShowing('spine', 'central')).toBe('central') + expect(modeShowing('rack', 'management')).toBe('management') + expect(modeShowing('partition', 'management')).toBe('management') + }) +}) + +describe('nodeSelections', () => { + it('selects the central rack, the rack group entity, or a plan-level section', () => { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + partition.racks[0] = withRackKind(partition, partition.racks[0], 'rack-group') + const rackId = partition.racks[0].id + const graph = deriveTopology(plan) + const p = graph.partitions[0] + const sel = nodeSelections(graph) + + expect(sel.get(p.central.spines[0].id)).toEqual({ partitionId: partition.id }) + expect(sel.get(p.central.mgmtFirewalls[0].id)).toEqual({ partitionId: partition.id }) + const left = p.racks.find((r) => r.entity?.position === 'left')! + expect(left.serverGroups.length).toBeGreaterThan(0) + expect(sel.get(left.serverGroups[0].id)).toEqual({ partitionId: partition.id, rackId }) + const mid = p.racks.find((r) => r.entity?.position === 'mid')! + expect(sel.get(mid.leaves[0].id)).toEqual({ partitionId: partition.id, rackId }) + expect(p.externalNetworks.length).toBeGreaterThan(0) + expect(sel.get(p.externalNetworks[0].id)).toEqual({ section: 'networks' }) + }) +}) diff --git a/src/views/topology/build.ts b/src/views/topology/build.ts new file mode 100644 index 0000000..45fea76 --- /dev/null +++ b/src/views/topology/build.ts @@ -0,0 +1,198 @@ +import type { FabricConfig, Plan, Rack } from '../../model/plan' +import { + CONTROL_PLANE_RACK_ID, + type TopoNode, + type TopologyGraph, + type TopologyMode, +} from '../../derive/topology' + +// The topology builder: a palette whose items add to the plan at the +// current selection. Every item becomes a plain store action, so the +// topology, the BOM and validation stay derived from the Plan; this module +// only decides where an item lands and what gets selected afterwards. The +// selection is view state and never part of the Plan. + +/** What the builder has selected: a partition (its central rack), a rack + * within it, or a plan-level section. */ +export interface Selection { + partitionId?: string + rackId?: string + section?: 'control-plane' | 'networks' +} + +export type PaletteItemId = + | 'partition' + | 'rack' + | 'rack-group' + | 'server-group' + | 'spine' + | 'exit' + | 'router' + | 'external-network' + +export const PALETTE: { id: PaletteItemId; label: string }[] = [ + { id: 'partition', label: 'Partition' }, + { id: 'rack', label: 'Rack' }, + { id: 'rack-group', label: 'Rack group' }, + { id: 'server-group', label: 'Server group' }, + { id: 'spine', label: 'Spine' }, + { id: 'exit', label: 'Exit' }, + { id: 'router', label: 'Router' }, + { id: 'external-network', label: 'External network' }, +] + +export type PlanAction = + | { type: 'addPartition' } + | { type: 'addRack'; partitionId: string; kind: Rack['kind'] } + | { type: 'addServerGroup'; partitionId: string; rackId: string } + | { type: 'patchFabric'; partitionId: string; patch: Partial } + | { type: 'addExternalNetwork' } + +/** The selection as far as it still exists in `plan`: undo, "Remove rack" + * or a removed partition can delete what was selected. */ +export function resolveSelection(plan: Plan, sel: Selection | undefined): Selection | undefined { + if (!sel) return undefined + if (sel.section) return { section: sel.section } + if (sel.rackId === CONTROL_PLANE_RACK_ID) return { section: 'control-plane' } + const partition = plan.partitions.find((p) => p.id === sel.partitionId) + if (!partition) return undefined + const rack = partition.racks.find((r) => r.id === sel.rackId) + return rack ? { partitionId: partition.id, rackId: rack.id } : { partitionId: partition.id } +} + +function landing(plan: Plan, sel: Selection | undefined) { + const resolved = resolveSelection(plan, sel) + const partition = + plan.partitions.find((p) => p.id === resolved?.partitionId) ?? plan.partitions[0] + const rack = partition?.racks.find((r) => r.id === resolved?.rackId) + return { partition, rack } +} + +const TIER_COUNT: Partial> = + { + spine: 'spineCount', + exit: 'exitSwitchCount', + router: 'routerCount', + } + +/** Whether an item can be added right now, and where it would land. */ +export function paletteState( + id: PaletteItemId, + plan: Plan, + sel: Selection | undefined, +): { enabled: boolean; hint: string } { + if (id === 'partition') return { enabled: true, hint: 'Adds a partition to the plan' } + if (id === 'external-network') return { enabled: true, hint: 'Adds a plan-wide network' } + const { partition, rack } = landing(plan, sel) + if (!partition) return { enabled: false, hint: 'Add a partition first' } + if (id === 'server-group') { + return rack + ? { enabled: true, hint: `Adds to ${rack.name}` } + : { enabled: false, hint: 'Select a rack first' } + } + return { enabled: true, hint: `Adds to ${partition.name}` } +} + +/** The store action an item stands for, or nothing when it is disabled. */ +export function paletteAction( + id: PaletteItemId, + plan: Plan, + sel: Selection | undefined, +): PlanAction | undefined { + if (!paletteState(id, plan, sel).enabled) return undefined + if (id === 'partition') return { type: 'addPartition' } + if (id === 'external-network') return { type: 'addExternalNetwork' } + const { partition, rack } = landing(plan, sel) + if (id === 'server-group') { + return { type: 'addServerGroup', partitionId: partition.id, rackId: rack!.id } + } + if (id === 'rack' || id === 'rack-group') { + return { + type: 'addRack', + partitionId: partition.id, + kind: id === 'rack' ? 'single' : 'rack-group', + } + } + const field = TIER_COUNT[id]! + return { + type: 'patchFabric', + partitionId: partition.id, + patch: { [field]: partition.fabric[field] + 1 }, + } +} + +/** What to select once `action` turned `before` into `after`: the rack or + * partition it created, or the place it changed. */ +export function selectionAfter(action: PlanAction, before: Plan, after: Plan): Selection { + switch (action.type) { + case 'addPartition': { + const known = new Set(before.partitions.map((p) => p.id)) + const created = after.partitions.find((p) => !known.has(p.id)) + return created ? { partitionId: created.id } : {} + } + case 'addRack': { + const known = new Set( + before.partitions.find((p) => p.id === action.partitionId)?.racks.map((r) => r.id), + ) + const created = after.partitions + .find((p) => p.id === action.partitionId) + ?.racks.find((r) => !known.has(r.id)) + return created + ? { partitionId: action.partitionId, rackId: created.id } + : { partitionId: action.partitionId } + } + case 'addServerGroup': + return { partitionId: action.partitionId, rackId: action.rackId } + case 'patchFabric': + return { partitionId: action.partitionId } + case 'addExternalNetwork': + return { section: 'networks' } + } +} + +/** A view mode in which a freshly added item is visible: compute racks and + * server groups are not drawn in central mode, production gear and + * external networks not in management mode. */ +export function modeShowing(id: PaletteItemId, mode: TopologyMode): TopologyMode { + const hiddenInCentral = id === 'rack' || id === 'rack-group' || id === 'server-group' + const hiddenInManagement = + id === 'spine' || id === 'exit' || id === 'router' || id === 'external-network' + if (mode === 'central' && hiddenInCentral) return 'production' + if (mode === 'management' && hiddenInManagement) return 'production' + return mode +} + +/** What clicking each node selects: central-rack gear its partition, rack + * gear its rack (a rack group's entity, from any of its three physical + * racks), external networks and the control plane their plan-level + * section. */ +export function nodeSelections(graph: TopologyGraph): Map { + const out = new Map() + const put = (nodes: TopoNode[], sel: Selection) => nodes.forEach((n) => out.set(n.id, sel)) + for (const p of graph.partitions) { + const partition = { partitionId: p.id } + const { central } = p + put( + [ + ...central.routers, + ...central.superspines, + ...central.spines, + ...central.exits, + ...central.mgmtSpines, + ...central.mgmtServers, + ...central.mgmtFirewalls, + ...p.storageLeaves, + ], + partition, + ) + put(p.externalNetworks, { section: 'networks' }) + if (p.controlPlane) put([p.controlPlane.node], { section: 'control-plane' }) + for (const rack of p.racks) { + put([...rack.leaves, ...rack.mgmtLeaves, ...rack.serverGroups], { + partitionId: p.id, + rackId: rack.entity?.id ?? rack.id, + }) + } + } + return out +} From c37c24a8d125153c716bf1842253fb80f615a15d Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 14:24:19 +0200 Subject: [PATCH 06/27] count 25G breakouts per leaf, not per server group A 4x25G breakout serves four ports of a single switch port, but a dual-attached group spreads its ports over the leaf pair. The BOM and the leaf capacity check rounded the group's ports up to fours as if one breakout could span both leaves, so any group whose node count is not a multiple of four came out short: 9 nodes need 3 + 3 breakouts, not 5; 10 nodes need 6, not 5. nodeSwitchPorts() is now the single rule for BOM lines (servers and on-prem control plane nodes) and leafPortsNeeded(). Groups of eight MicroCloud nodes, as in the templates, are unaffected. Found by laying out the physical cable plan and comparing it with the BOM. Co-Authored-By: Claude Opus 5.5 --- src/derive/bom.test.ts | 24 ++++++++------- src/derive/bom.ts | 60 +++++++++++++++++++++++++------------ src/derive/validate.test.ts | 5 ++-- src/derive/validate.ts | 23 +++++++------- 4 files changed, 71 insertions(+), 41 deletions(-) diff --git a/src/derive/bom.test.ts b/src/derive/bom.test.ts index de4eec9..afc084a 100644 --- a/src/derive/bom.test.ts +++ b/src/derive/bom.test.ts @@ -63,16 +63,20 @@ describe('deriveBom breakout math (2x25G)', () => { // 10 nodes x 2 ports = 20x 25G server ports const plan = planWithWorkers(10, '2x25G') expect(serverQuantity(plan, 'sfp-25g-sr')).toBe(20) - // 20 ports / 4 per breakout = 5 leaf-side 100G ports - expect(serverQuantity(plan, 'sfp-100g-sr4')).toBe(5) - expect(serverQuantity(plan, 'cable-mtp-breakout')).toBe(5) - }) - - it('rounds leaf-side ports up to a started breakout group', () => { - // 3 nodes x 2 ports = 6 ports -> ceil(6/4) = 2 - const plan = planWithWorkers(3, '2x25G') - expect(serverQuantity(plan, 'sfp-100g-sr4')).toBe(2) - expect(serverQuantity(plan, 'cable-mtp-breakout')).toBe(2) + // One port per node on each of the 2 leaves: 10 ports per leaf need + // ceil(10/4) = 3 breakouts each, since a breakout serves a single + // switch port: 6 leaf-side 100G ports, not 20 / 4 = 5. + expect(serverQuantity(plan, 'sfp-100g-sr4')).toBe(6) + expect(serverQuantity(plan, 'cable-mtp-breakout')).toBe(6) + }) + + it('rounds leaf-side ports up to a started breakout group per leaf', () => { + // 3 nodes: 3 ports per leaf -> 1 breakout on each of the 2 leaves + expect(serverQuantity(planWithWorkers(3, '2x25G'), 'cable-mtp-breakout')).toBe(2) + // 9 nodes: 9 ports per leaf -> 3 breakouts each, 6 in all + expect(serverQuantity(planWithWorkers(9, '2x25G'), 'cable-mtp-breakout')).toBe(6) + // 8 nodes: 8 ports per leaf -> 2 each, where per-leaf and per-group agree + expect(serverQuantity(planWithWorkers(8, '2x25G'), 'cable-mtp-breakout')).toBe(4) }) }) diff --git a/src/derive/bom.ts b/src/derive/bom.ts index 7a05032..20c8514 100644 --- a/src/derive/bom.ts +++ b/src/derive/bom.ts @@ -191,6 +191,22 @@ export function rackBmcPorts(rack: Rack): number { return rack.servers.reduce((n, g) => n + chassisCount(g), 0) } +/** 100G switch ports a group of dual-attached nodes takes. Node n's port p + * lands on switch (2n + p) mod `switches`; a 4x25G breakout serves four + * ports of one switch, so 25G ports are rounded up per switch, not per + * group (9 nodes on a leaf pair: 3 + 3 breakouts, not ceil(18 / 4) = 5). */ +export function nodeSwitchPorts(nodes: number, uplink: UplinkSpeed, switches: number): number { + const ports = 2 * nodes + if (uplink === '2x100G') return ports + if (switches <= 0) return Math.ceil(ports / 4) + let total = 0 + for (let s = 0; s < switches; s++) { + const onSwitch = Math.floor(ports / switches) + (s < ports % switches ? 1 : 0) + total += Math.ceil(onSwitch / 4) + } + return total +} + /** Fiber speed between mgmt leaf and mgmt spine: 25G if both have 25G * ports, else 10G. */ export function mgmtUplinkSpeed(leafModelId: string, spineModelId: string): '25G' | '10G' { @@ -222,7 +238,7 @@ function addSwitch( } } -function addServerGroup(bom: BomBuilder, group: ServerGroup): void { +function addServerGroup(bom: BomBuilder, group: ServerGroup, leafCount: number): void { const nodesPer = catalog[group.modelId]?.nodesPerChassis ?? 1 // The rack is the reason's section now, so the detail only has to say // which group within the rack it is. @@ -239,33 +255,33 @@ 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) + addNodeUplinks(bom, group.count, group.uplink, role, leafCount) } /** 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 { + * switches they hang off (`switches` of them). `what` names them in the + * reasons ("worker", "control plane"). */ +function addNodeUplinks( + bom: BomBuilder, + nodes: number, + uplink: UplinkSpeed, + what: string, + switches: number, +): void { // Every node carries one dual-port NIC matching its uplink speed. 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. + // 100G-SR4 per started group of four on each switch, joined by an MTP + // breakout cable. bom.add('sfp-25g-sr', uplinkPorts, `${nodes} ${what} nodes × 2 server ports`) - const switchPorts = Math.ceil(uplinkPorts / 4) - bom.add( - 'sfp-100g-sr4', - switchPorts, - `${uplinkPorts} × 25G ${what} ports / 4 per breakout, switch side`, - ) - bom.add( - 'cable-mtp-breakout', - switchPorts, - `${uplinkPorts} × 25G ${what} ports / 4 per breakout`, - ) + const switchPorts = nodeSwitchPorts(nodes, uplink, switches) + const why = `${uplinkPorts} × 25G ${what} ports over ${switches} switches, 4 per breakout` + bom.add('sfp-100g-sr4', switchPorts, `${why}, switch side`) + bom.add('cable-mtp-breakout', switchPorts, why) } else { 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 @@ -382,7 +398,13 @@ function addControlPlane( 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') + addNodeUplinks( + prod, + nodes, + cp.uplink, + 'control plane', + central ? fabric.exitSwitchCount : cp.rack.leafCount, + ) if (ownRack) { // A rack of its own: leaves uplinked to the spines like a compute @@ -508,7 +530,7 @@ function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void { `${fabric.mgmt.leafPerRack} mgmt leaves`, ) for (const group of rack.servers) { - addServerGroup(bom.on('production').at(rack.name), group) + addServerGroup(bom.on('production').at(rack.name), group, rack.leafCount) } } } diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index e02de5a..5d68d03 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -37,8 +37,9 @@ describe('leaf port capacity', () => { { id: 'a', role: 'worker', modelId: 'server-microcloud-x11', count: 30, uplink: '2x25G' }, { id: 'b', role: 'worker', modelId: 'server-bigtwin-x11', count: 2, uplink: '2x100G' }, ] - // 30 nodes x 2 = 60 x25G -> ceil(60/4)=15 ports; 2 nodes x 2 = 4 x100G ports - expect(leafPortsNeeded(rack)).toBe(19) + // 30 nodes: 30 x 25G ports per leaf -> ceil(30/4) = 8 breakouts on each of + // the 2 leaves = 16; 2 nodes x 2 = 4 x 100G ports + expect(leafPortsNeeded(rack)).toBe(20) }) it('flags a rack that exceeds leaf capacity', () => { diff --git a/src/derive/validate.ts b/src/derive/validate.ts index 4239664..e84a236 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -8,7 +8,13 @@ import { requiredSuperspines, spineBandwidth, } from './bandwidth' -import { deriveBom, mgmtUplinkSpeed, rackBmcPorts, spinePortsPerSpine } from './bom' +import { + deriveBom, + mgmtUplinkSpeed, + nodeSwitchPorts, + rackBmcPorts, + spinePortsPerSpine, +} from './bom' import { controlPlaneLeafCount, controlPlaneSwitchPorts, inCentralRack } from './controlPlane' import { validateIpPlan } from './ip/validateIp' import { deriveRackLayout, formatPower } from './rackLayout' @@ -102,16 +108,13 @@ function checkSwitchRole( } } -/** 100G leaf ports a rack's servers consume (breakout math for 25G). */ +/** 100G leaf ports a rack's servers consume: 25G ports on 4x25G breakouts, a + * breakout per started four ports of one group on one leaf (nodeSwitchPorts). */ export function leafPortsNeeded(rack: Rack): number { - let breakout25gPorts = 0 - let native100gPorts = 0 - for (const group of rack.servers) { - const uplinkPorts = 2 * group.count - if (group.uplink === '2x25G') breakout25gPorts += uplinkPorts - else native100gPorts += uplinkPorts - } - return Math.ceil(breakout25gPorts / 4) + native100gPorts + return rack.servers.reduce( + (n, group) => n + nodeSwitchPorts(group.count, group.uplink, rack.leafCount), + 0, + ) } /** 100G leaf ports a rack has left for servers after spine uplinks. The From c92e6452e86bfb8123e2f39478120159ec592950 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:30:52 +0200 Subject: [PATCH 07/27] name SONiC ports in the catalog sonicPorts maps a switch model's front-panel ports to SONiC interface names, per port group in the order of `ports`. Taken from sonic-buildimage port_config.ini for the AS7726-32X (Ethernet0..124, step 4) and the AS4630-54TE (Ethernet0..47 1G, 48..51 25G, 52 and 56 100G). Models without an upstream port map (AS4625-54T) have none, so nothing guesses. Co-Authored-By: Claude Opus 5.5 --- src/model/catalog.test.ts | 20 ++++++++++++++++++++ src/model/catalog.ts | 21 +++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/src/model/catalog.test.ts b/src/model/catalog.test.ts index efd7e8f..963236d 100644 --- a/src/model/catalog.test.ts +++ b/src/model/catalog.test.ts @@ -10,6 +10,7 @@ import { nosOptions, portCount, serversForUsage, + sonicPortNames, switchesForRole, type CatalogItem, } from './catalog' @@ -138,6 +139,25 @@ describe('category invariants', () => { } }) + it('maps SONiC port names onto exactly the ports it lists, without duplicates', () => { + for (const [id, item] of entries) { + if (!item.sonicPorts) continue + for (const { speed, count } of item.ports ?? []) { + const names = sonicPortNames(id, speed) + expect(names, `${id} ${speed}`).toHaveLength(count) + expect(new Set(names).size, `${id} ${speed} unique`).toBe(count) + } + const mapped = item.sonicPorts.reduce((n, g) => n + g.count, 0) + const listed = (item.ports ?? []).reduce((n, g) => n + g.count, 0) + expect(mapped, `${id} mapped ports`).toBe(listed) + } + expect(sonicPortNames('switch-as7726', '100G').slice(-2)).toEqual([ + 'Ethernet120', + 'Ethernet124', + ]) + expect(sonicPortNames('switch-as4630', '100G')).toEqual(['Ethernet52', 'Ethernet56']) + }) + it('gives every server height, power, nodes and usages', () => { for (const [id, item] of entries) { if (item.category !== 'server') continue diff --git a/src/model/catalog.ts b/src/model/catalog.ts index 9565a26..cbb154b 100644 --- a/src/model/catalog.ts +++ b/src/model/catalog.ts @@ -105,6 +105,11 @@ export interface CatalogItem { serverUsages?: ServerUsage[] /** Max GPUs a single server node of this model accepts; absent = none. */ gpuCapable?: number + /** SONiC interface names of the front-panel ports, per port group in the + * order of `ports`: port i of a group is `Ethernet${first + i * step}`. + * Absent where no upstream port map exists, which the Ansible export + * reports instead of guessing. */ + sonicPorts?: { speed: PortSpeed; first: number; step: number; count: number }[] /** No plan can select this item, so it never reaches a BOM: it is kept * because the official compatibility list names it. Say why. Every * otherwise-unreachable entry must set this (catalog.test.ts), so that @@ -127,6 +132,8 @@ export const catalog: Record = { heightUnits: 1, powerWatts: 300, ports: [{ speed: '100G', count: 32 }], + // sonic-buildimage device/accton/x86_64-accton_as7726_32x-r0/Accton-AS7726-32X/port_config.ini + sonicPorts: [{ speed: '100G', first: 0, step: 4, count: 32 }], status: 'stable', switchRoles: ['leaf', 'spine', 'superspine', 'exit', 'storage-leaf'], licenseClass: '100g', @@ -161,6 +168,12 @@ export const catalog: Record = { { speed: '25G', count: 4 }, { speed: '100G', count: 2 }, ], + // sonic-buildimage device/accton/x86_64-accton_as4630_54te-r0/Accton-AS4630-54TE/port_config.ini + sonicPorts: [ + { speed: '1G', first: 0, step: 1, count: 48 }, + { speed: '25G', first: 48, step: 1, count: 4 }, + { speed: '100G', first: 52, step: 4, count: 2 }, + ], status: 'stable', switchRoles: ['mgmt-spine', 'mgmt-leaf'], licenseClass: '25g', @@ -669,6 +682,14 @@ export function gpusForServer(serverModelId: string): CatalogItem[] { /** How a BOM line names its item: the model people say, else the ordering * code, else the raw id. */ +/** SONiC names of a switch model's ports of one speed, in front-panel + * order; empty when the catalog has no port map for the model. */ +export function sonicPortNames(modelId: string, speed: PortSpeed): string[] { + return (catalog[modelId]?.sonicPorts ?? []) + .filter((g) => g.speed === speed) + .flatMap((g) => Array.from({ length: g.count }, (_, i) => `Ethernet${g.first + i * g.step}`)) +} + export function itemLabel(id: string): string { const item = catalog[id] return item?.model ?? item?.partNumber ?? id From 4e3b81355b4c6d2c287c2ad9c0da625dae3bc34e Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:30:56 +0200 Subject: [PATCH 08/27] add mgmt loopbacks and mgmt link ranges to the IP plan The routed management network needs addresses the IP plan did not size: a loopback per mgmt spine and mgmt leaf, and four /30 per side of the management network (firewall to mgmt server, to its BMC and to the mgmt spine's mgmt port, mgmt server to mgmt spine), as in the deployment guide's routed out-of-band network. Co-Authored-By: Claude Opus 5.5 --- src/derive/ip/ipPlan.test.ts | 20 ++++++++++++++++++++ src/derive/ip/ipPlan.ts | 29 +++++++++++++++++++++++++++++ 2 files changed, 49 insertions(+) diff --git a/src/derive/ip/ipPlan.test.ts b/src/derive/ip/ipPlan.test.ts index 7f0b9be..ed5a173 100644 --- a/src/derive/ip/ipPlan.test.ts +++ b/src/derive/ip/ipPlan.test.ts @@ -132,6 +132,26 @@ describe('invalid input', () => { }) describe('infrastructure ranges', () => { + it('gives every mgmt spine and mgmt leaf a loopback', () => { + const plan = createEmptyPlan() + const loopbacks = deriveIpPlan(plan).infra.partitions[0].subnets.find( + (s) => s.purpose === 'Mgmt loopbacks', + ) + // 2 mgmt spines + 1 mgmt leaf, with the default headroom, no reserved addresses + expect(loopbacks?.needed).toBe(3) + expect(loopbacks?.detail).toBe('2 mgmt spines + 1 mgmt leaves') + }) + + it('sizes four /30 mgmt links per side of the management network', () => { + const plan = createEmptyPlan() + const links = () => + deriveIpPlan(plan).infra.partitions[0].subnets.find((s) => s.purpose === 'Mgmt links') + // 2 sides × 4 links × 4 addresses = 32 -> /27 + expect(links()).toMatchObject({ needed: 8, sized: 32, prefix: 27 }) + plan.partitions[0].fabric.mgmt.redundant = false + expect(links()).toMatchObject({ needed: 4, sized: 16, prefix: 28 }) + }) + it('sizes underlay, PXE, management and transfer nets for the default plan', () => { const r = deriveIpPlan(createEmptyPlan()) const p = r.infra.partitions[0] diff --git a/src/derive/ip/ipPlan.ts b/src/derive/ip/ipPlan.ts index a864d0d..8609fb9 100644 --- a/src/derive/ip/ipPlan.ts +++ b/src/derive/ip/ipPlan.ts @@ -29,6 +29,8 @@ import { // - Underlay: one loopback per BGP speaker — used as router ID and VTEP // address — for leaves, spines, exits, superspines, storage leaves and // firewalls (EVPN-to-the-host VTEPs). +// - Mgmt loopbacks: one per mgmt spine and mgmt leaf, which run BGP in the +// routed management network and are reached on their loopback. // - PXE (vlan4000): one address per server node plus one per exit switch, // whose SVI runs the PXE DHCP server. // - Management (mgmt VRF / out-of-band): one per switch management @@ -37,6 +39,10 @@ import { // server's own interface. L2 management: one subnet per partition; L3: // a central subnet plus one per compute rack (a rack group once). // - Transfer networks: one per router ↔ exit link (2 × routers × exits). +// - Mgmt links: four /30 per side of the management network, as in the +// deployment guide's routed out-of-band network: firewall ↔ mgmt server, +// firewall ↔ mgmt server BMC, firewall ↔ mgmt spine mgmt port, and mgmt +// server ↔ mgmt spine. // Host subnets reserve 3 addresses (network, broadcast, gateway), the // loopback pool none. Every size except the transfer networks gets the // growth headroom; subnets are packed largest first, so they stay aligned. @@ -461,6 +467,16 @@ export function partitionInfraNeeds(partition: Partition, infra: IpInfra): Infra 0, ), ) + out.push( + hostSubnet( + 'Mgmt loopbacks', + 'Partition', + mgmtCount + mgmtLeaves, + `${mgmtCount} mgmt spines + ${mgmtLeaves} mgmt leaves`, + infra, + 0, + ), + ) out.push( hostSubnet( 'PXE (vlan4000)', @@ -525,9 +541,22 @@ export function partitionInfraNeeds(partition: Partition, infra: IpInfra): Infra detail: `${fabric.routerCount} routers × ${fabric.exitSwitchCount} exits × 2 links × /${infra.transferPrefix}`, }) } + const mgmtLinks = MGMT_LINKS_PER_SIDE * mgmtCount + out.push({ + purpose: 'Mgmt links', + scope: 'Partition', + needed: mgmtLinks, + sized: mgmtLinks * 4, + prefix: fitPrefix(4, mgmtLinks * 4), + cidr: null, + detail: `${mgmtCount} × (firewall to mgmt server, its BMC and the mgmt spine, mgmt server to mgmt spine) × /30`, + }) return out.filter((s): s is InfraSubnet => !!s) } +/** /30 networks per side of the management network (see the header). */ +export const MGMT_LINKS_PER_SIDE = 4 + function deriveInfra(plan: Plan): InfraResult { const input = plan.ipPlan.infra const parsed = parseCidr(input.cidr, 4) From 61bab3f238d928f1e6bb4483879727450222af9d Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:31:01 +0200 Subject: [PATCH 09/27] derive a physical cable plan deriveCabling() names both ends of every cable in a partition: hostnames modelled on a production inventory (-r01leaf01, -spine01, -mgmtspine01, -mgmtserver01, -mgmtfw01), SONiC port names from the catalog, 4x25G breakouts, and the deployment guide's port names for mgmt firewall and mgmt server. Its tests hold the cable counts per type against deriveBom() for the default plan, every template and a plan with every cabled feature. Superspines, an L2 management network, a control-plane rack of its own and switch models without a port map are reported, not cabled. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 1 + src/derive/deploy/cabling.test.ts | 142 +++++++++++ src/derive/deploy/cabling.ts | 402 ++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 src/derive/deploy/cabling.test.ts create mode 100644 src/derive/deploy/cabling.ts diff --git a/CLAUDE.md b/CLAUDE.md index e63579e..70de1d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,6 +29,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. +- **Cable plan.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. diff --git a/src/derive/deploy/cabling.test.ts b/src/derive/deploy/cabling.test.ts new file mode 100644 index 0000000..6fc683e --- /dev/null +++ b/src/derive/deploy/cabling.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it } from 'vitest' +import { catalog, sonicPortNames, type PortSpeed } from '../../model/catalog' +import { createEmptyPlan, withRackKind } from '../../model/defaults' +import type { Plan } from '../../model/plan' +import { templates } from '../../model/templates' +import { deriveBom } from '../bom' +import { leafPortsNeeded } from '../validate' +import { deriveCabling, hostLabel, hostnameProblems, type CableKind } from './cabling' + +const BOM_ID: Record = { + 'mtp-trunk': 'cable-mtp-trunk', + 'mtp-breakout': 'cable-mtp-breakout', + 'lc-duplex': 'cable-lc-duplex', + rj45: 'cable-rj45', +} + +/** Storage leaves, routers, a 100G group, an odd node count and a rack group. */ +function everythingPlan(): Plan { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + partition.fabric.storageLeafCount = 2 + partition.fabric.routerCount = 2 + partition.fabric.leafSpineLinks = 2 + partition.racks[0].servers.push({ + id: 'g100', + role: 'worker', + modelId: 'server-bigtwin-x12', + count: 4, + uplink: '2x100G', + }) + partition.racks[0].servers[0].count = 9 + const group = withRackKind(partition, { ...partition.racks[0], id: 'grp' }, 'rack-group') + partition.racks.push(group) + return plan +} + +const plans: [string, () => Plan][] = [ + ['the default plan', createEmptyPlan], + ...templates.map((t): [string, () => Plan] => [`template ${t.name}`, t.build]), + ['a plan with every cabled feature', everythingPlan], +] + +describe.each(plans)('cabling of %s', (_, build) => { + const plan = build() + plan.sparesPerLine = 0 + const cablings = plan.partitions.map((p) => deriveCabling(plan, p)) + + it('has no problems', () => { + expect(cablings.flatMap((c) => c.problems)).toEqual([]) + }) + + it('orders exactly the cables it lays, per cable type', () => { + const bom = deriveBom(plan) + for (const kind of Object.keys(BOM_ID) as CableKind[]) { + const laid = cablings.flatMap((c) => c.cables).filter((c) => c.kind === kind).length + const ordered = bom.find((l) => l.catalogId === BOM_ID[kind])?.quantity ?? 0 + expect(laid, kind).toBe(ordered) + } + }) + + it('uses every switch port at most once, and only ports the model has', () => { + for (const c of cablings) { + const models = new Map(c.switches.map((s) => [s.name, s.modelId])) + const used = new Set() + for (const cable of c.cables) { + for (const end of [cable.a, cable.b]) { + const model = models.get(end.host) + if (!model) continue + const key = `${end.host}/${end.port}` + expect(used.has(key), key).toBe(false) + used.add(key) + if (end.port === 'eth0') continue + const real = (['1G', '10G', '25G', '100G'] as PortSpeed[]).flatMap((s) => + sonicPortNames(model, s), + ) + expect(real, `${key} on ${catalog[model]?.model}`).toContain(end.port) + } + } + } + }) + + it('takes as many leaf ports for servers as the capacity check counts', () => { + for (const [i, p] of plan.partitions.entries()) { + for (const rack of p.racks) { + const leaves = new Set( + cablings[i].switches + .filter((s) => s.rackId === rack.id && s.role === 'leaf') + .map((s) => s.name), + ) + const serverPorts = cablings[i].cables.filter( + (c) => leaves.has(c.b.host) && /-node\d+$/.test(c.a.host), + ).length + expect(serverPorts, rack.name).toBe(leafPortsNeeded(rack)) + } + } + }) +}) + +describe('deriveCabling', () => { + it('puts the spine uplinks of a 32-port leaf on its top ports', () => { + const plan = createEmptyPlan() + const leaf = deriveCabling(plan, plan.partitions[0]).switches.find((s) => s.role === 'leaf')! + expect(leaf.bgpPorts).toEqual(['Ethernet120', 'Ethernet124']) + }) + + it('chains each mgmt firewall, mgmt server and mgmt spine one to one', () => { + const plan = createEmptyPlan() + const c = deriveCabling(plan, plan.partitions[0]) + const prefix = hostLabel(plan.partitions[0].name) + const ends = (host: string) => + c.cables + .filter((x) => x.a.host === host || x.b.host === host) + .map((x) => (x.a.host === host ? x.b.host : x.a.host)) + .sort() + expect(ends(`${prefix}-mgmtfw01`)).toEqual([ + `${prefix}-mgmtserver01`, + `${prefix}-mgmtserver01`, + `${prefix}-mgmtspine01`, + ]) + expect(ends(`${prefix}-mgmtserver02`)).toEqual([ + `${prefix}-mgmtfw02`, + `${prefix}-mgmtfw02`, + `${prefix}-mgmtspine02`, + ]) + }) + + it('refuses what it cannot cable yet instead of cabling part of it', () => { + const plan = createEmptyPlan() + plan.partitions[0].fabric.fabricType = 'leaf-spine-superspine' + plan.partitions[0].fabric.superspineCount = 2 + plan.partitions[0].fabric.mgmt.spineModelId = 'switch-as4625' + const problems = deriveCabling(plan, plan.partitions[0]).problems + expect(problems).toContain('Superspine fabrics are not cabled yet.') + expect(problems.some((p) => p.startsWith('No SONiC port map for AS4625-54T'))).toBe(true) + }) + + it('reports partitions whose names collide as hostnames', () => { + const plan = createEmptyPlan() + plan.partitions.push({ ...plan.partitions[0], id: 'p2', name: `${plan.partitions[0].name}!` }) + expect(hostnameProblems(plan)).toHaveLength(1) + }) +}) diff --git a/src/derive/deploy/cabling.ts b/src/derive/deploy/cabling.ts new file mode 100644 index 0000000..e75ff53 --- /dev/null +++ b/src/derive/deploy/cabling.ts @@ -0,0 +1,402 @@ +import { catalog, sonicPortNames, type PortSpeed } from '../../model/catalog' +import { mgmtDeviceCount, type Partition, type Plan, type UplinkSpeed } from '../../model/plan' +import { chassisCount, mgmtUplinkSpeed } from '../bom' +import { hasOwnRack, inCentralRack } from '../controlPlane' + +// The physical cable plan of a partition: every cable with both ends named +// (host and port), the hostnames of the devices, and the ports each switch +// uses for what. It follows the cabling model of derive/bom.ts cable for +// cable, and bom-cabling.test.ts holds the two derivations against each +// other. The port assignment is a convention of this planner, modelled on +// a production partition and the deployment guide's routed out-of-band +// network: +// +// - Leaf: spine uplinks on the top ports (Ethernet120/124 on a 32-port +// leaf), servers from Ethernet0 upwards. Node n's NIC port p goes to leaf +// (2n + p) mod leafCount; 25G server ports use 4x25G breakouts, a fresh +// cage per server group and leaf, 100G server ports one port each. +// - Spine: leaves in rack order from Ethernet0 (leafSpineLinks each), then +// exits, then storage leaves. Exits and storage leaves take their spine +// uplinks from their top ports; exits take router links from Ethernet0, +// then on-prem control plane nodes in the central rack, which attach to +// the exits the way servers attach to their leaves. +// - Every switch's eth0: leaves to their rack's mgmt leaf; central-rack +// switches and routers round-robin to the mgmt spines, from Ethernet0. +// - Mgmt spine i: mgmt server i on its last copper port (the guide's swp48), +// mgmt leaf uplinks on its 25G (or 10G) ports, and its own eth0 on mgmt +// firewall i. +// - Mgmt leaf: chassis BMCs from Ethernet0 upwards, leaf eth0s from its last +// copper port downwards, uplinks to the mgmt spines on its 25G ports. +// - Mgmt firewall i (guide port names): eth6 to mgmt server i's eno1, eth5 +// to its ipmi, eth4 to mgmt spine i's eth0; eth7 is the uplink and is not +// cabled here. Mgmt server i's eno3 goes to mgmt spine i. + +export type CableKind = 'mtp-trunk' | 'mtp-breakout' | 'lc-duplex' | 'rj45' + +export interface CableEnd { + host: string + port: string +} + +export interface Cable { + a: CableEnd + b: CableEnd + kind: CableKind + /** A breakout's 25G lanes: which server port sits on which sub-port. */ + lanes?: { server: CableEnd; switchPort: string }[] +} + +export type SwitchRole = 'spine' | 'exit' | 'storage-leaf' | 'leaf' | 'mgmt-spine' | 'mgmt-leaf' + +export interface SwitchHost { + name: string + role: SwitchRole + modelId: string + /** Plan rack a leaf or mgmt leaf belongs to (a rack group once). */ + rackId?: string + /** Fabric-facing ports that run BGP: towards the spines, or from a spine + * towards everything below it; mgmt switches towards each other. */ + bgpPorts: string[] + /** Breakout mode per port, for leaves with 25G servers. */ + breakouts: Record + /** Mgmt spine or mgmt leaf this switch's eth0 is cabled to. */ + mgmtAttachedTo?: string +} + +export interface PartitionCabling { + partitionId: string + /** Hostname prefix: the partition name as a hostname label. */ + prefix: string + switches: SwitchHost[] + mgmtServers: string[] + mgmtFirewalls: string[] + routers: string[] + cables: Cable[] + /** Why the plan cannot be cabled as a whole; empty when it can. */ + problems: string[] +} + +const pad = (n: number) => String(n).padStart(2, '0') + +/** A hostname label from free text: lowercase, a-z 0-9 and single dashes. */ +export function hostLabel(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') +} + +/** Free ports of one switch, handed out from the bottom or the top. */ +class PortPool { + private readonly free: Map + + constructor( + readonly host: string, + modelId: string, + private readonly problems: string[], + ) { + this.free = new Map( + (['1G', '10G', '25G', '100G'] as PortSpeed[]).map((speed) => [ + speed, + sonicPortNames(modelId, speed), + ]), + ) + } + + take(speed: PortSpeed, fromTop = false): string { + const ports = this.free.get(speed) ?? [] + const port = fromTop ? ports.pop() : ports.shift() + if (port) return port + this.problems.push(`${this.host} has no free ${speed} port left.`) + return `${speed}-overflow` + } +} + +function rackPrefix(prefix: string, rackIndex: number): string { + return `${prefix}-r${pad(rackIndex + 1)}` +} + +export function deriveCabling(plan: Plan, partition: Partition): PartitionCabling { + const { fabric } = partition + const { mgmt } = fabric + const prefix = hostLabel(partition.name) + const problems: string[] = [] + const cables: Cable[] = [] + const switches: SwitchHost[] = [] + const pools = new Map() + + if (!prefix) problems.push(`Partition "${partition.name}" gives no usable hostname prefix.`) + if (fabric.fabricType === 'leaf-spine-superspine') { + problems.push('Superspine fabrics are not cabled yet.') + } + if (mgmt.layer === 'l2') problems.push('An L2 management network is not cabled yet.') + if (hasOwnRack(plan, partition)) { + problems.push('A control plane rack of its own is not cabled yet.') + } + + function addSwitch(name: string, role: SwitchRole, modelId: string, rackId?: string): SwitchHost { + if (!catalog[modelId]?.sonicPorts && !problems.some((p) => p.includes(modelId))) { + problems.push( + `No SONiC port map for ${catalog[modelId]?.model ?? modelId} (${modelId}); its ports cannot be named.`, + ) + } + const host: SwitchHost = { name, role, modelId, rackId, bgpPorts: [], breakouts: {} } + switches.push(host) + pools.set(name, new PortPool(name, modelId, problems)) + return host + } + const pool = (host: SwitchHost) => pools.get(host.name)! + const cable = (a: CableEnd, b: CableEnd, kind: CableKind): Cable => { + const c: Cable = { a, b, kind } + cables.push(c) + return c + } + + const spines = Array.from({ length: fabric.spineCount }, (_, i) => + addSwitch(`${prefix}-spine${pad(i + 1)}`, 'spine', fabric.spineModelId), + ) + const exits = Array.from({ length: fabric.exitSwitchCount }, (_, i) => + addSwitch(`${prefix}-exit${pad(i + 1)}`, 'exit', fabric.exitModelId), + ) + const storageLeaves = Array.from({ length: fabric.storageLeafCount }, (_, i) => + addSwitch(`${prefix}-storleaf${pad(i + 1)}`, 'storage-leaf', fabric.storageLeafModelId), + ) + const routers = Array.from( + { length: fabric.routerCount }, + (_, i) => `${prefix}-router${pad(i + 1)}`, + ) + + const mgmtCount = mgmtDeviceCount(mgmt) + const mgmtSpines = Array.from({ length: mgmtCount }, (_, i) => + addSwitch(`${prefix}-mgmtspine${pad(i + 1)}`, 'mgmt-spine', mgmt.spineModelId), + ) + const mgmtServers = Array.from( + { length: mgmtCount }, + (_, i) => `${prefix}-mgmtserver${pad(i + 1)}`, + ) + const mgmtFirewalls = Array.from({ length: mgmtCount }, (_, i) => `${prefix}-mgmtfw${pad(i + 1)}`) + + // Leaves per rack, with their mgmt leaves. + const racks = partition.racks.map((rack, r) => { + const rp = rackPrefix(prefix, r) + const leaves = Array.from({ length: rack.leafCount }, (_, i) => + addSwitch(`${rp}leaf${pad(i + 1)}`, 'leaf', rack.leafModelId, rack.id), + ) + const mgmtLeaves = Array.from({ length: mgmt.leafPerRack }, (_, i) => + addSwitch(`${rp}mgmtleaf${pad(i + 1)}`, 'mgmt-leaf', mgmt.leafModelId, rack.id), + ) + return { rack, rp, leaves, mgmtLeaves } + }) + + // Leaf uplinks: the top ports of each leaf, spine by spine. + const links = fabric.leafSpineLinks + for (const { leaves } of racks) { + for (const leaf of leaves) { + const uplinks = Array.from({ length: spines.length * links }, () => + pool(leaf).take('100G', true), + ).reverse() + spines.forEach((spine, s) => { + for (let l = 0; l < links; l++) { + const leafPort = uplinks[s * links + l] + const spinePort = pool(spine).take('100G') + leaf.bgpPorts.push(leafPort) + spine.bgpPorts.push(spinePort) + cable( + { host: leaf.name, port: leafPort }, + { host: spine.name, port: spinePort }, + 'mtp-trunk', + ) + } + }) + } + } + // Exits and storage leaves: one link to every spine, from their top ports. + for (const upper of [...exits, ...storageLeaves]) { + const uplinks = spines.map(() => pool(upper).take('100G', true)).reverse() + spines.forEach((spine, s) => { + const spinePort = pool(spine).take('100G') + upper.bgpPorts.push(uplinks[s]) + spine.bgpPorts.push(spinePort) + cable( + { host: upper.name, port: uplinks[s] }, + { host: spine.name, port: spinePort }, + 'mtp-trunk', + ) + }) + } + // Routers: two links to every exit, from the exits' bottom ports. + for (const router of routers) { + exits.forEach((exit, e) => { + for (let l = 0; l < 2; l++) { + const nic = `nic${e * 2 + l + 1}` + cable( + { host: router, port: nic }, + { host: exit.name, port: pool(exit).take('100G') }, + 'mtp-trunk', + ) + } + }) + } + + // On-prem control plane nodes in the central rack, on the exits. + const cp = plan.controlPlane + const cpNodes = inCentralRack(plan, partition) + ? Array.from({ length: cp.nodeCount }, (_, n) => `${prefix}-cp-node${pad(n + 1)}`) + : [] + if (cpNodes.length > 0) { + if (exits.length === 0) problems.push('Control plane nodes need exit switches to attach to.') + else cableNodes(cpNodes, cp.uplink, exits) + } + + // Servers on their rack's leaves. + for (const { rack, rp, leaves } of racks) { + if (leaves.length === 0) { + if (rack.servers.some((g) => g.count > 0)) { + problems.push(`${rack.name} has servers but no leaves.`) + } + continue + } + rack.servers.forEach((group, g) => { + const nodes = Array.from( + { length: group.count }, + (_, n) => `${rp}-g${g + 1}-node${pad(n + 1)}`, + ) + cableNodes(nodes, group.uplink, leaves) + }) + } + + /** Dual-attached nodes: node n's port p on switch (2n + p) mod count, 25G + * ports on 4x25G breakouts, a fresh cage per call and switch. */ + function cableNodes(nodes: string[], uplink: UplinkSpeed, switches: SwitchHost[]) { + const breakoutCage = new Map() + nodes.forEach((node, n) => { + for (let p = 0; p < 2; p++) { + const sw = switches[(2 * n + p) % switches.length] + const nodePort = `nic${p + 1}` + if (uplink === '2x100G') { + cable( + { host: node, port: nodePort }, + { host: sw.name, port: pool(sw).take('100G') }, + 'mtp-trunk', + ) + continue + } + let cage = breakoutCage.get(sw.name) + if (!cage || cage.cable.lanes!.length === 4) { + const port = pool(sw).take('100G') + const c = cable({ host: node, port: nodePort }, { host: sw.name, port }, 'mtp-breakout') + c.lanes = [] + cage = { base: Number(port.replace('Ethernet', '')), cable: c } + breakoutCage.set(sw.name, cage) + sw.breakouts[port] = '4x25G' + } + const lanes = cage.cable.lanes! + lanes.push({ + server: { host: node, port: nodePort }, + switchPort: `Ethernet${cage.base + lanes.length}`, + }) + } + }) + } + + // Management: each switch's eth0. + const centralMgmt = [...spines, ...exits, ...storageLeaves] + centralMgmt.forEach((sw, i) => { + const mgmtSpine = mgmtSpines[i % mgmtSpines.length] + if (!mgmtSpine) return + sw.mgmtAttachedTo = mgmtSpine.name + cable( + { host: sw.name, port: 'eth0' }, + { host: mgmtSpine.name, port: pool(mgmtSpine).take('1G') }, + 'rj45', + ) + }) + ;[...routers, ...cpNodes].forEach((device, i) => { + const mgmtSpine = mgmtSpines[(centralMgmt.length + i) % mgmtSpines.length] + if (!mgmtSpine) return + cable( + { host: device, port: routers.includes(device) ? 'bmc' : 'mgmt' }, + { host: mgmtSpine.name, port: pool(mgmtSpine).take('1G') }, + 'rj45', + ) + }) + + // Each side of the management network: firewall, mgmt server, mgmt spine. + mgmtSpines.forEach((mgmtSpine, i) => { + const server = mgmtServers[i] + const firewall = mgmtFirewalls[i] + mgmtSpine.mgmtAttachedTo = firewall + cable({ host: firewall, port: 'eth6' }, { host: server, port: 'eno1' }, 'rj45') + cable({ host: firewall, port: 'eth5' }, { host: server, port: 'ipmi' }, 'rj45') + cable({ host: firewall, port: 'eth4' }, { host: mgmtSpine.name, port: 'eth0' }, 'rj45') + const serverPort = pool(mgmtSpine).take('1G', true) + mgmtSpine.bgpPorts.push(serverPort) + cable({ host: server, port: 'eno3' }, { host: mgmtSpine.name, port: serverPort }, 'rj45') + }) + + // Per rack: BMCs and leaf eth0s on the mgmt leaf, mgmt leaf uplinks. + const uplinkSpeed = mgmtUplinkSpeed(mgmt.leafModelId, mgmt.spineModelId) + for (const { rack, rp, leaves, mgmtLeaves } of racks) { + const mgmtLeaf = mgmtLeaves[0] + if (mgmtLeaf) { + rack.servers.forEach((group, g) => { + for (let c = 0; c < chassisCount(group); c++) { + cable( + { host: `${rp}-g${g + 1}-chassis${pad(c + 1)}`, port: 'bmc' }, + { host: mgmtLeaf.name, port: pool(mgmtLeaf).take('1G') }, + 'rj45', + ) + } + }) + for (const leaf of leaves) { + leaf.mgmtAttachedTo = mgmtLeaf.name + cable( + { host: leaf.name, port: 'eth0' }, + { host: mgmtLeaf.name, port: pool(mgmtLeaf).take('1G', true) }, + 'rj45', + ) + } + } else if (leaves.length > 0) { + problems.push(`${rack.name} has no mgmt leaf for its BMCs and leaf mgmt ports.`) + } + for (const ml of mgmtLeaves) { + for (const mgmtSpine of mgmtSpines) { + const leafPort = pool(ml).take(uplinkSpeed) + const spinePort = pool(mgmtSpine).take(uplinkSpeed) + ml.bgpPorts.push(leafPort) + mgmtSpine.bgpPorts.push(spinePort) + cable( + { host: ml.name, port: leafPort }, + { host: mgmtSpine.name, port: spinePort }, + 'lc-duplex', + ) + } + } + } + + return { + partitionId: partition.id, + prefix, + switches, + mgmtServers, + mgmtFirewalls, + routers, + cables, + problems: [...new Set(problems)], + } +} + +/** Hostname collisions across partitions, after turning names into labels. */ +export function hostnameProblems(plan: Plan): string[] { + const seen = new Map() + const out: string[] = [] + for (const p of plan.partitions) { + const label = hostLabel(p.name) + const other = seen.get(label) + if (other !== undefined) { + out.push(`Partitions "${other}" and "${p.name}" give the same hostname prefix "${label}".`) + } + seen.set(label, p.name) + } + return out +} From bd7c017e32b2dde439f52750cb3f017fd682468b Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:31:18 +0200 Subject: [PATCH 10/27] add a small YAML writer toYaml() writes plain data with every string as a JSON string, which is a valid YAML double-quoted scalar, so "no", "on" and "1.10" stay strings and Jinja expressions survive. Undefined values are left out rather than written as a placeholder. No new dependency. Co-Authored-By: Claude Opus 5.5 --- src/io/yaml.test.ts | 39 +++++++++++++++++++++++++++++++ src/io/yaml.ts | 57 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+) create mode 100644 src/io/yaml.test.ts create mode 100644 src/io/yaml.ts diff --git a/src/io/yaml.test.ts b/src/io/yaml.test.ts new file mode 100644 index 0000000..56b30fe --- /dev/null +++ b/src/io/yaml.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest' +import { toYaml } from './yaml' + +describe('toYaml', () => { + it('quotes every string, so YAML never reinterprets it', () => { + expect(toYaml({ a: 'no', b: '1.10', c: '{{ lo }}', d: 'on' })).toBe( + 'a: "no"\nb: "1.10"\nc: "{{ lo }}"\nd: "on"\n', + ) + }) + + it('writes numbers and booleans bare', () => { + expect(toYaml({ asn: 4200000001, enabled: true })).toBe('asn: 4200000001\nenabled: true\n') + }) + + it('nests maps and lists, and writes empty ones inline', () => { + expect( + toYaml({ vlans: [{ id: 4000, ports: ['Ethernet0', 'Ethernet4'] }], none: [], empty: {} }), + ).toBe( + [ + 'vlans:', + ' - id: 4000', + ' ports:', + ' - "Ethernet0"', + ' - "Ethernet4"', + 'none: []', + 'empty: {}', + '', + ].join('\n'), + ) + }) + + it('quotes keys that are not plain identifiers', () => { + expect(toYaml({ 'p1-spine01': { 'a b': 1 } })).toBe('p1-spine01:\n "a b": 1\n') + }) + + it('leaves out undefined values instead of writing a placeholder', () => { + expect(toYaml({ set: 1, missing: undefined })).toBe('set: 1\n') + }) +}) diff --git a/src/io/yaml.ts b/src/io/yaml.ts new file mode 100644 index 0000000..15c2f61 --- /dev/null +++ b/src/io/yaml.ts @@ -0,0 +1,57 @@ +// A small YAML writer for plain data (maps, lists, strings, numbers, +// booleans). Every string is written as a JSON string, which is a valid +// YAML double-quoted scalar, so nothing is reinterpreted on the way in: +// "no", "on" and "1.10" stay strings, "{{ lo }}" stays a Jinja expression. +// Undefined values are left out; there is no placeholder for them. + +export type YamlValue = string | number | boolean | undefined | YamlValue[] | YamlMap +export interface YamlMap { + [key: string]: YamlValue +} + +const PLAIN_KEY = /^[A-Za-z_][A-Za-z0-9_.-]*$/ + +function key(k: string): string { + return PLAIN_KEY.test(k) ? k : JSON.stringify(k) +} + +function scalar(v: string | number | boolean): string { + return typeof v === 'string' ? JSON.stringify(v) : String(v) +} + +function isEmpty(v: YamlValue): boolean { + return Array.isArray(v) ? v.length === 0 : typeof v === 'object' && Object.keys(v).length === 0 +} + +function block(v: YamlValue, indent: string): string { + if (Array.isArray(v)) { + return v + .filter((item) => item !== undefined) + .map((item) => { + if (item !== null && typeof item === 'object' && !isEmpty(item)) { + const inner = block(item, indent + ' ') + return `${indent}- ${inner.slice(indent.length + 2)}` + } + return `${indent}- ${inline(item)}\n` + }) + .join('') + } + return Object.entries(v as YamlMap) + .filter(([, x]) => x !== undefined) + .map(([k, x]) => + x !== null && typeof x === 'object' && !isEmpty(x) + ? `${indent}${key(k)}:\n${block(x, indent + ' ')}` + : `${indent}${key(k)}: ${inline(x)}\n`, + ) + .join('') +} + +function inline(v: YamlValue): string { + if (Array.isArray(v)) return '[]' + if (typeof v === 'object') return '{}' + return scalar(v as string | number | boolean) +} + +export function toYaml(doc: YamlMap): string { + return block(doc, '') +} From 0ec1e3dcc378faace11f62149fb876b082e1432d Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:31:22 +0200 Subject: [PATCH 11/27] generate an Ansible inventory from the plan deriveInventory() turns the cable plan and the IP plan's infrastructure ranges into an inventory in the layout of a production partition: a group per partition with role child groups (spines, exits, leaves, storage leaves, mgmt spines, mgmt leaves, mgmt servers, mgmt firewalls) and the generic role groups above them. Hosts carry the variables metal-roles v0.35.0 reads (sonic-config, metal-core, mgmt-server, pixiecore, ztp): loopbacks, ASNs, mgmt interfaces, BGP ports, breakouts, PXE subnets and DHCP relays; planner_ports documents the cable plan per device. Inputs a plan cannot know (control plane endpoints, release, DNS/NTP, BMC credentials, upstream peers, keys) stay undefined and are listed in the file header, so the roles' own asserts stop a run instead of running with a placeholder. Checked with ansible-inventory, a variable-name match against metal-roles, and by rendering sonic-config's frr.conf and sonic-config.yaml templates for spine, exit, leaf, mgmt spine and mgmt leaf. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/derive/deploy/inventory.test.ts | 137 +++++++++ src/derive/deploy/inventory.ts | 457 ++++++++++++++++++++++++++++ src/io/ansible.ts | 41 +++ 4 files changed, 636 insertions(+), 1 deletion(-) create mode 100644 src/derive/deploy/inventory.test.ts create mode 100644 src/derive/deploy/inventory.ts create mode 100644 src/io/ansible.ts diff --git a/CLAUDE.md b/CLAUDE.md index 70de1d5..abc4357 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. -- **Cable plan.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). +- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names; addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. diff --git a/src/derive/deploy/inventory.test.ts b/src/derive/deploy/inventory.test.ts new file mode 100644 index 0000000..6f84f8a --- /dev/null +++ b/src/derive/deploy/inventory.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest' +import { createEmptyPlan } from '../../model/defaults' +import type { Plan } from '../../model/plan' +import { templates } from '../../model/templates' +import { exportAnsibleInventory } from '../../io/ansible' +import type { YamlMap } from '../../io/yaml' +import { deriveInventory } from './inventory' + +type Host = { name: string; group: string; vars: YamlMap; groupVars: YamlMap } + +/** Every host with its own and its role group's variables. */ +function hostsOf(doc: YamlMap): Host[] { + const out: Host[] = [] + const walk = (name: string, group: YamlMap) => { + for (const [host, vars] of Object.entries((group.hosts as YamlMap) ?? {})) { + out.push({ + name: host, + group: name, + vars: vars as YamlMap, + groupVars: (group.vars as YamlMap) ?? {}, + }) + } + for (const [child, g] of Object.entries((group.children as YamlMap) ?? {})) + walk(child, g as YamlMap) + } + walk('all', doc.all as YamlMap) + return out +} + +const plans: [string, () => Plan][] = [ + ['the default plan', createEmptyPlan], + ...templates.map((t): [string, () => Plan] => [`template ${t.name}`, t.build]), +] + +describe.each(plans)('inventory of %s', (_, build) => { + const plan = build() + const { doc, problems } = deriveInventory(plan) + const hosts = hostsOf(doc) + const switches = hosts.filter( + (h) => !/mgmt(server|firewalls)/.test(h.group) && !h.group.endsWith('mgmtservers'), + ) + + it('has no problems', () => { + expect(problems).toEqual([]) + }) + + it('gives every switch the variables sonic-config asserts, and an address', () => { + expect(switches.length).toBeGreaterThan(0) + for (const h of switches) { + const asn = h.vars.sonic_config_asn ?? h.groupVars.sonic_config_asn + expect(h.vars.sonic_config_loopback_address, `${h.name} loopback`).toMatch( + /^\d+\.\d+\.\d+\.\d+$/, + ) + expect(asn, `${h.name} asn`).toBeTypeOf('number') + expect(h.vars.ansible_host, `${h.name} ansible_host`).toMatch(/^\d+\.\d+\.\d+\.\d+$/) + } + }) + + it('gives every leaf a PXE subnet, a rack id and its spine uplinks', () => { + const leaves = hosts.filter((h) => h.group.endsWith('_leaves')) + expect(leaves.length).toBeGreaterThan(0) + for (const h of leaves) { + expect(h.vars.metal_core_cidr, h.name).toMatch(/^\d+\.\d+\.\d+\.\d+\/\d+$/) + expect(h.vars.metal_core_rack_id, h.name).toBeTypeOf('string') + expect((h.vars.sonic_config_bgp_ports as string[]).length, h.name).toBeGreaterThan(0) + } + expect(new Set(leaves.map((h) => h.vars.metal_core_cidr)).size).toBe(leaves.length) + }) + + it('never hands out an address twice', () => { + const addresses = hosts.flatMap((h) => + [h.vars.sonic_config_loopback_address, h.vars.ansible_host].filter( + (x): x is string => typeof x === 'string', + ), + ) + const loopbacksAndHosts = new Map() + for (const h of hosts) { + for (const a of new Set([h.vars.sonic_config_loopback_address, h.vars.ansible_host])) { + if (typeof a !== 'string') continue + loopbacksAndHosts.set(a, [...(loopbacksAndHosts.get(a) ?? []), h.name]) + } + } + const shared = [...loopbacksAndHosts].filter(([, owners]) => owners.length > 1) + expect(shared, 'addresses on more than one host').toEqual([]) + expect(addresses.length).toBeGreaterThan(0) + }) + + it('writes the same bytes for the same plan', () => { + expect(exportAnsibleInventory(plan).text).toBe(exportAnsibleInventory(plan).text) + }) + + it('sets none of the inputs a plan cannot know', () => { + const text = exportAnsibleInventory(plan).text.split('---\n')[1] + for (const name of [ + 'sonic_config_ntp', + 'sonic_config_nameservers', + 'metal_bmc_bmc_superuser_pwd', + 'metal_partition_metal_api_addr', + 'CHANGE', + ]) { + expect(text).not.toContain(name) + } + }) +}) + +describe('deriveInventory', () => { + it('refuses a plan it cannot generate in full', () => { + const plan = createEmptyPlan() + plan.partitions[0].fabric.fabricType = 'leaf-spine-superspine' + plan.partitions[0].fabric.superspineCount = 2 + expect(deriveInventory(plan).problems).toContain( + `${plan.partitions[0].name}: Superspine fabrics are not cabled yet.`, + ) + }) + + it('reports a missing IP range instead of leaving addresses out silently', () => { + const plan = createEmptyPlan() + plan.ipPlan.infra.cidr = '' + expect(deriveInventory(plan).problems.some((p) => p.includes('the IP plan has no'))).toBe(true) + }) + + it('chains each mgmt server to its firewall and mgmt spine by address', () => { + const plan = createEmptyPlan() + const hosts = hostsOf(deriveInventory(plan).doc) + const server = hosts.find((h) => h.name.endsWith('-mgmtserver01'))! + const firewall = hosts.find((h) => h.name.endsWith('-mgmtfw01'))! + const spine = hosts.find((h) => h.name.endsWith('-mgmtspine01'))! + expect(server.vars.mgmt_server_firewall_ip).toBe(firewall.vars.ansible_host) + const spinePort = (spine.vars.sonic_config_ports as YamlMap).list as YamlMap[] + const serverEno3 = (server.vars.planner_interfaces as YamlMap).eno3 as string + const spineIp = (spinePort[0].ips as string[])[0] + // Both ends of the same /30. + expect(serverEno3.split('/')[1]).toBe('30') + expect(spineIp.split('/')[1]).toBe('30') + expect(serverEno3.split('.').slice(0, 3)).toEqual(spineIp.split('.').slice(0, 3)) + }) +}) diff --git a/src/derive/deploy/inventory.ts b/src/derive/deploy/inventory.ts new file mode 100644 index 0000000..da59845 --- /dev/null +++ b/src/derive/deploy/inventory.ts @@ -0,0 +1,457 @@ +import type { Partition, Plan } from '../../model/plan' +import type { YamlMap } from '../../io/yaml' +import { formatIp, subnet, type Cidr } from '../ip/cidr' +import { deriveIpPlan, type InfraPartition } from '../ip/ipPlan' +import { + deriveCabling, + hostLabel, + hostnameProblems, + type PartitionCabling, + type SwitchHost, +} from './cabling' + +// The Ansible inventory of a plan, in the layout of a production partition +// inventory: a group per partition with a child group per device role +// (_spines, _exits, _leaves, _storageleaves, _mgmtspines, +// _mgmtleaves, _mgmtservers, _mgmtfirewalls) and the generic role groups +// (spines, leaves, …) above them, so metal-roles plays can target either. +// Variable names are the ones metal-roles reads (sonic-config, metal-core, +// mgmt-server, dhcp, pixiecore, ztp); `planner_*` variables only document +// what the plan decided, such as the cable plan per port. +// +// Addresses come from the IP plan's infrastructure ranges: +// - Loopbacks: spines, exits, storage leaves, then leaves in rack order +// from "Underlay loopbacks"; mgmt spines, then mgmt leaves from "Mgmt +// loopbacks". Mgmt switches are reached on their loopback. +// - Central rack management: the range is split evenly over the mgmt +// spines; mgmt spine i's SVI takes the first address of part i and the +// eth0 of each switch cabled to it the following ones. +// - Rack management: the mgmt leaf's SVI takes the first address, the +// leaves' eth0 the following ones; BMCs lease the rest over DHCP. +// - PXE (vlan4000): the range is split evenly over all leaves; each leaf's +// metal_core_cidr is the first address of its part. +// - Mgmt links: four /30 per side, in the order firewall to mgmt server, +// firewall to mgmt server BMC, firewall to mgmt spine eth0, mgmt server to +// mgmt spine; the firewall (or the mgmt server on the last) takes the +// first address. +// +// ASNs: mgmt servers 4200000001 + i, mgmt spines 4200000010 + i and mgmt +// leaves 4200000021 + i follow the deployment guide's routed out-of-band +// network; in the production network the spines share 4210000000 and the +// exits 4210000001, storage leaves take 4210000100 + i and leaves +// 4210001000 + i (this planner's convention, one ASN per leaf as in the +// production inventory). +// +// What the plan cannot know is left undefined, never filled with a +// placeholder, so the roles' own "is defined" checks still stop a run; the +// list is MISSING_INPUTS. + +export interface Inventory { + doc: YamlMap + /** Why the inventory would be incomplete or wrong; export is refused. */ + problems: string[] +} + +/** Inputs no plan holds, by the role or place that needs them. */ +export const MISSING_INPUTS: { where: string; inputs: string[] }[] = [ + { + where: 'Control plane connection (partition group)', + inputs: [ + 'metal_partition_metal_api_addr, _protocol, _port, _basepath', + 'metal_partition_metal_api_hmac_edit_key, metal_partition_metal_api_hmac_view_key', + 'metal_partition_metal_api_grpc_ca_cert, _client_cert, _client_key', + 'defaults_partition_metal_apiserver_url, defaults_partition_metal_apiserver_admin_token', + ], + }, + { + where: 'Release and images', + inputs: [ + 'metal_stack_release_version and setup_yaml (release vector: metal_core_image_*, metal_bmc_image_*, pixiecore_image_*, ztp_nginx_image_*, image_cache_*_image_*)', + 'sonic_image_name (SONiC image served for ZTP)', + ], + }, + { + where: 'All switches (sonic-config)', + inputs: [ + 'sonic_config_nameservers', + 'sonic_config_ntp.servers', + 'sonic_config_ssh_sourceranges', + ], + }, + { + where: 'Exits (sonic-config)', + inputs: [ + 'sonic_config_interconnects.internet: upstream peers, VRF, VNI, prefix lists', + 'sonic_config_vlan_subinterfaces towards the upstream', + ], + }, + { + where: 'Mgmt servers', + inputs: [ + 'metal_bmc_bmc_superuser, metal_bmc_bmc_superuser_pwd, metal_bmc_nsqd_addr (metal-bmc)', + 'pixiecore_grpc_*, pixiecore_metal_hammer_logging_* (pixiecore)', + 'mgmt_server_metal_ssh_privkey, mgmt_server_metal_ssh_pubkey, mgmt_server_nameservers (mgmt-server)', + 'ztp_authorized_keys (ztp)', + 'dhcp_static_hosts: switch MACs for ZTP (dhcp)', + 'lvm_pvs: the disks (lvm)', + 'CI runner registration and WireGuard keys, if used', + ], + }, + { + where: 'Mgmt firewalls', + inputs: [ + 'uplink (eth7) address, gateway and upstream', + 'firewall rules, destination and hairpin NAT: no metal-roles role exists for a generic firewall', + ], + }, + { + where: 'Ansible access', + inputs: ['ansible_user, ansible_ssh_private_key_file'], + }, +] + +const ASN = { + mgmtServer: 4200000001, + mgmtSpine: 4200000010, + mgmtLeaf: 4200000021, + spines: 4210000000, + exits: 4210000001, + storageLeaf: 4210000100, + leaf: 4210001000, +} as const + +const MGMT_VLAN = 1 +const MGMT_PORTS = new Set(['eth0', 'bmc', 'mgmt']) + +const ip = (c: Cidr, offset: number) => formatIp(c.family, c.addr + BigInt(offset)) +const withPrefix = (c: Cidr, offset: number) => `${ip(c, offset)}/${c.prefix}` + +/** Splits `c` into the smallest power of two of parts that holds `parts`. */ +function split(c: Cidr, parts: number): Cidr[] { + const bits = Math.ceil(Math.log2(Math.max(parts, 1))) + return Array.from({ length: parts }, (_, i) => subnet(c, c.prefix + bits, BigInt(i))) +} + +const groupName = (prefix: string, role: string) => `${prefix.replace(/-/g, '_')}_${role}` + +function partitionInventory( + plan: Plan, + partition: Partition, + infra: InfraPartition | undefined, + problems: string[], +): { group: YamlMap; roleGroups: Record } { + const c: PartitionCabling = deriveCabling(plan, partition) + problems.push(...c.problems.map((p) => `${partition.name}: ${p}`)) + const range = (purpose: string, scope = 'Partition'): Cidr | undefined => { + const s = infra?.subnets.find((x) => x.purpose === purpose && x.scope === scope) + if (!s?.cidr) { + problems.push( + `${partition.name}: the IP plan has no "${purpose}" range for ${scope}; set the infrastructure range in the IPs tab.`, + ) + return undefined + } + return s.cidr + } + + const byRole = (role: SwitchHost['role']) => c.switches.filter((s) => s.role === role) + const spines = byRole('spine') + const exits = byRole('exit') + const storageLeaves = byRole('storage-leaf') + const leaves = byRole('leaf') + const mgmtSpines = byRole('mgmt-spine') + const mgmtLeaves = byRole('mgmt-leaf') + + // Cable plan per host, for planner_ports. + const ports = new Map() + const note = (host: string, port: string, peer: string) => { + const m = ports.get(host) ?? {} + m[port] = peer + ports.set(host, m) + } + for (const cable of c.cables) { + note(cable.a.host, cable.a.port, `${cable.b.host} ${cable.b.port}`) + note(cable.b.host, cable.b.port, `${cable.a.host} ${cable.a.port}`) + for (const lane of cable.lanes ?? []) { + note(cable.b.host, lane.switchPort, `${lane.server.host} ${lane.server.port}`) + } + } + const sortedPorts = (host: string): YamlMap => { + const m = ports.get(host) ?? {} + return Object.fromEntries( + Object.keys(m) + .sort((a, b) => a.localeCompare(b, 'en', { numeric: true })) + .map((k) => [k, m[k]]), + ) + } + + // Loopbacks. + const loopbacks = new Map() + const underlay = range('Underlay loopbacks') + ;[...spines, ...exits, ...storageLeaves, ...leaves].forEach((sw, i) => { + if (underlay) loopbacks.set(sw.name, ip(underlay, i)) + }) + const mgmtLoop = range('Mgmt loopbacks') + ;[...mgmtSpines, ...mgmtLeaves].forEach((sw, i) => { + if (mgmtLoop) loopbacks.set(sw.name, ip(mgmtLoop, i)) + }) + + // Mgmt links: four /30 per side. + const links = range('Mgmt links') + const link = (side: number, n: number) => + links ? subnet(links, 30, BigInt(side * 4 + n)) : undefined + + // Management addresses of switch eth0s and the SVIs they sit behind. + const mgmtIf = new Map() + const svi = new Map() + const sviPorts = new Map() + const central = range('Management', 'Central rack') + if (central && mgmtSpines.length > 0) { + const parts = split(central, mgmtSpines.length) + mgmtSpines.forEach((ms, i) => { + const part = parts[i] + svi.set(ms.name, withPrefix(part, 1)) + const attached = c.switches.filter((s) => s.mgmtAttachedTo === ms.name) + const capacity = Number(2n ** BigInt(32 - part.prefix)) - 3 + if (attached.length > capacity) { + problems.push( + `${partition.name}: ${ms.name}'s management range ${withPrefix(part, 0)} is too small.`, + ) + } + attached.forEach((sw, k) => + mgmtIf.set(sw.name, { ip: withPrefix(part, k + 2), gateway_address: ip(part, 1) }), + ) + }) + } + partition.racks.forEach((rack) => { + const r = range('Management', rack.name) + const ml = mgmtLeaves.find((m) => m.rackId === rack.id) + if (!r || !ml) return + svi.set(ml.name, withPrefix(r, 1)) + leaves + .filter((l) => l.rackId === rack.id) + .forEach((leaf, k) => + mgmtIf.set(leaf.name, { ip: withPrefix(r, k + 2), gateway_address: ip(r, 1) }), + ) + }) + mgmtSpines.forEach((ms, i) => { + const l = link(i, 2) + if (l) mgmtIf.set(ms.name, { ip: withPrefix(l, 2), gateway_address: ip(l, 1) }) + }) + // Access ports of an SVI: every port facing a management interface + // (a switch's eth0, a BMC, a control plane node's mgmt port). + for (const cable of c.cables) { + for (const [end, peer] of [ + [cable.a, cable.b], + [cable.b, cable.a], + ]) { + if (svi.has(end.host) && end.port !== 'eth0' && MGMT_PORTS.has(peer.port)) { + sviPorts.set(end.host, [...(sviPorts.get(end.host) ?? []), end.port]) + } + } + } + + // PXE: one part per leaf. + const pxe = range('PXE (vlan4000)') + const pxeParts = pxe && leaves.length > 0 ? split(pxe, leaves.length) : [] + if (pxeParts[0] && pxeParts[0].prefix > 30) { + problems.push( + `${partition.name}: the PXE range is too small to give each of ${leaves.length} leaves a subnet.`, + ) + } + + // Mgmt servers: their spine-facing address is where DHCP is relayed to. + const serverSpineIp = c.mgmtServers.map((_, i) => { + const l = link(i, 3) + return l ? ip(l, 1) : undefined + }) + const dhcpServers = serverSpineIp.filter((x): x is string => !!x) + + const switchHost = (sw: SwitchHost, extra: YamlMap): YamlMap => ({ + ansible_host: + sw.role === 'mgmt-spine' || sw.role === 'mgmt-leaf' + ? loopbacks.get(sw.name) + : (mgmtIf.get(sw.name)?.ip as string | undefined)?.split('/')[0], + sonic_config_loopback_address: loopbacks.get(sw.name), + sonic_config_mgmt_interface: mgmtIf.get(sw.name), + sonic_config_bgp_ports: sw.bgpPorts.length > 0 ? [...sw.bgpPorts] : undefined, + ...extra, + planner_ports: sortedPorts(sw.name), + }) + const hosts = (list: SwitchHost[], extra: (sw: SwitchHost, i: number) => YamlMap) => + Object.fromEntries(list.map((sw, i) => [sw.name, switchHost(sw, extra(sw, i))])) + + const prefix = c.prefix + const g = (role: string) => groupName(prefix, role) + const pxeByLeaf = new Map(leaves.map((l, i) => [l.name, pxeParts[i]])) + + const children: YamlMap = { + [g('spines')]: { + vars: { sonic_config_asn: ASN.spines, sonic_config_frr_l2vpn_evpn: true }, + hosts: hosts(spines, () => ({})), + }, + [g('exits')]: { + vars: { + sonic_config_asn: ASN.exits, + sonic_config_frr_l2vpn_evpn: true, + sonic_config_vtep: { enabled: true }, + }, + hosts: hosts(exits, () => ({})), + }, + [g('storageleaves')]: { + vars: { sonic_config_frr_l2vpn_evpn: true }, + hosts: hosts(storageLeaves, (_, i) => ({ sonic_config_asn: ASN.storageLeaf + i })), + }, + [g('leaves')]: { + vars: { + sonic_config_frr_render: false, + sonic_config_frr_l2vpn_evpn: true, + sonic_config_vtep: { enabled: true }, + lo: '{{ sonic_config_loopback_address }}', + asn: '{{ sonic_config_asn }}', + dhcp_servers: dhcpServers, + sonic_config_vlans: [ + { id: 4000, ip: '{{ metal_core_cidr }}', dhcp_servers: '{{ dhcp_servers }}' }, + ], + metal_core_spine_uplinks: '{{ sonic_config_bgp_ports }}', + }, + hosts: hosts(leaves, (leaf, i) => { + const part = pxeByLeaf.get(leaf.name) + const rackIndex = partition.racks.findIndex((r) => r.id === leaf.rackId) + return { + sonic_config_asn: ASN.leaf + i, + sonic_config_breakouts: + Object.keys(leaf.breakouts).length > 0 ? { ...leaf.breakouts } : undefined, + metal_core_cidr: part ? withPrefix(part, 1) : undefined, + metal_core_rack_id: `${prefix}-r${String(rackIndex + 1).padStart(2, '0')}`, + } + }), + }, + [g('mgmtspines')]: { + hosts: hosts(mgmtSpines, (ms, i) => { + const l = link(i, 3) + const serverPort = Object.entries(ports.get(ms.name) ?? {}).find(([, peer]) => + String(peer).endsWith(' eno3'), + )?.[0] + return { + sonic_config_asn: ASN.mgmtSpine + i, + sonic_config_ports: + l && serverPort ? { list: [{ name: serverPort, ips: [withPrefix(l, 2)] }] } : undefined, + sonic_config_vlans: svi.has(ms.name) + ? [ + { + id: MGMT_VLAN, + ip: svi.get(ms.name), + dhcp_servers: dhcpServers, + untagged_ports: sviPorts.get(ms.name) ?? [], + }, + ] + : undefined, + } + }), + }, + [g('mgmtleaves')]: { + hosts: hosts(mgmtLeaves, (ml, i) => ({ + sonic_config_asn: ASN.mgmtLeaf + i, + sonic_config_vlans: svi.has(ml.name) + ? [ + { + id: MGMT_VLAN, + ip: svi.get(ml.name), + dhcp_servers: dhcpServers, + untagged_ports: sviPorts.get(ml.name) ?? [], + }, + ] + : undefined, + })), + }, + [g('mgmtservers')]: { + vars: { + mgmt_server_spine_facing_interface: 'eno3', + dhcp_listening_interfaces: ['eno3'], + }, + hosts: Object.fromEntries( + c.mgmtServers.map((name, i) => { + const fw = link(i, 0) + return [ + name, + { + ansible_host: fw ? ip(fw, 2) : undefined, + mgmt_server_asn: ASN.mgmtServer + i, + mgmt_server_router_id: serverSpineIp[i], + mgmt_server_firewall_ip: fw ? ip(fw, 1) : undefined, + ztp_listen_address: serverSpineIp[i], + pixiecore_api_host: serverSpineIp[i] ? `http://${serverSpineIp[i]}` : undefined, + planner_interfaces: { + eno1: fw ? withPrefix(fw, 2) : undefined, + ipmi: link(i, 1) ? withPrefix(link(i, 1)!, 2) : undefined, + eno3: link(i, 3) ? withPrefix(link(i, 3)!, 1) : undefined, + }, + planner_ports: sortedPorts(name), + }, + ] + }), + ), + }, + [g('mgmtfirewalls')]: { + hosts: Object.fromEntries( + c.mgmtFirewalls.map((name, i) => [ + name, + { + ansible_host: link(i, 0) ? ip(link(i, 0)!, 1) : undefined, + planner_interfaces: { + eth6: link(i, 0) ? withPrefix(link(i, 0)!, 1) : undefined, + eth5: link(i, 1) ? withPrefix(link(i, 1)!, 1) : undefined, + eth4: link(i, 2) ? withPrefix(link(i, 2)!, 1) : undefined, + }, + planner_ports: sortedPorts(name), + }, + ]), + ), + }, + } + + return { + group: { + vars: { metal_partition_id: prefix, planner_partition_name: partition.name }, + children, + }, + roleGroups: Object.fromEntries( + [ + 'spines', + 'exits', + 'storageleaves', + 'leaves', + 'mgmtspines', + 'mgmtleaves', + 'mgmtservers', + 'mgmtfirewalls', + ].map((role) => [role, g(role)]), + ), + } +} + +export function deriveInventory(plan: Plan): Inventory { + const problems = [...hostnameProblems(plan)] + const infra = deriveIpPlan(plan).infra + const partitionGroups: YamlMap = {} + const roleChildren: Record = {} + for (const partition of plan.partitions) { + const inf = infra.partitions.find((p) => p.partitionId === partition.id) + const { group, roleGroups } = partitionInventory(plan, partition, inf, problems) + partitionGroups[hostLabel(partition.name).replace(/-/g, '_')] = group + for (const [role, name] of Object.entries(roleGroups)) { + roleChildren[role] = { ...(roleChildren[role] ?? {}), [name]: {} } + } + } + const doc: YamlMap = { + all: { + children: { + partition: { children: partitionGroups }, + ...Object.fromEntries( + Object.entries(roleChildren).map(([role, children]) => [role, { children }]), + ), + }, + }, + } + return { doc, problems } +} diff --git a/src/io/ansible.ts b/src/io/ansible.ts new file mode 100644 index 0000000..9b1b110 --- /dev/null +++ b/src/io/ansible.ts @@ -0,0 +1,41 @@ +import { deriveInventory, MISSING_INPUTS } from '../derive/deploy/inventory' +import { nosLabel } from '../model/catalog' +import type { Plan } from '../model/plan' +import { toYaml } from './yaml' + +// The Ansible inventory export: the derived inventory as YAML, behind a +// comment header that says what it was written against and what it still +// lacks. The same plan always gives the same bytes (no timestamp), so the +// file diffs cleanly in a deployment repository. + +/** metal-roles release the variable names were checked against. */ +export const METAL_ROLES_VERSION = 'v0.35.0' + +export interface AnsibleExport { + text: string + problems: string[] +} + +function header(plan: Plan): string { + const nos = [...new Set(plan.partitions.map((p) => nosLabel(p.fabric.nos)))].join(', ') + const lines = [ + `Ansible inventory for "${plan.name}", generated by metal-stack planner.`, + `Variable names follow metal-roles ${METAL_ROLES_VERSION} (sonic-config, metal-core,`, + 'mgmt-server, dhcp, pixiecore, ztp); planner_* variables only document the plan,', + 'planner_ports being the cable plan of each device.', + '', + `Switch OS in the plan: ${nos}. The sonic-config role is tested against Edgecore`, + 'SONiC 202111.x and 202211.x; metal-core needs sonic_config_docker_routing_config_mode', + '"split" there (the role default).', + '', + 'Still to be set, left undefined on purpose so the roles stop instead of', + 'running with a placeholder:', + ...MISSING_INPUTS.flatMap((m) => [` ${m.where}:`, ...m.inputs.map((i) => ` - ${i}`)]), + ] + return lines.map((l) => (l ? `# ${l}` : '#')).join('\n') + '\n---\n' +} + +export function exportAnsibleInventory(plan: Plan): AnsibleExport { + const { doc, problems } = deriveInventory(plan) + return { text: header(plan) + toYaml(doc), problems } +} From 07c9ce7e5a6f0b1034e94d83711452e6602aeb16 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:32:06 +0200 Subject: [PATCH 12/27] serve DHCP on the mgmt servers The switches relay PXE (vlan4000) and management DHCP to the mgmt servers, but the inventory gave those servers no dhcp_subnets. The dhcp role has no asserts, so a run would have succeeded with no leases for PXE, BMCs or switch ZTP. Each mgmt server now serves every relayed network with the relaying SVI as router and its own share of the dynamic range (two dhcpd without failover must not lease the same address), plus the ONIE and SONiC ZTP options from the dhcp and ztp role examples. Checked by rendering the dhcp role's dhcpd.conf template. A rack with more nodes than its leaves' PXE subnets hold is reported. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/derive/deploy/inventory.test.ts | 53 ++++++++++++++++++ src/derive/deploy/inventory.ts | 87 +++++++++++++++++++++++++++-- 3 files changed, 136 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index abc4357..92a0251 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. -- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names; addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. +- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. diff --git a/src/derive/deploy/inventory.test.ts b/src/derive/deploy/inventory.test.ts index 6f84f8a..4885141 100644 --- a/src/derive/deploy/inventory.test.ts +++ b/src/derive/deploy/inventory.test.ts @@ -85,6 +85,50 @@ describe.each(plans)('inventory of %s', (_, build) => { expect(addresses.length).toBeGreaterThan(0) }) + it('serves DHCP for every network a switch relays to the mgmt servers', () => { + const toNum = (a: string) => a.split('.').reduce((n, o) => n * 256 + Number(o), 0) + const network = (cidr: string) => { + const [a, p] = cidr.split('/') + const size = 2 ** (32 - Number(p)) + return toNum(a) - (toNum(a) % size) + } + const partitionOf = (h: Host) => h.group.replace(/_[a-z]+$/, '') + const relayed = new Map>() + for (const h of hosts) { + const vlans = [ + ...((h.vars.sonic_config_vlans as YamlMap[]) ?? []), + ...((h.groupVars.sonic_config_vlans as YamlMap[]) ?? []), + ] + for (const v of vlans) { + if (!v.dhcp_servers) continue + const ipCidr = v.ip === '{{ metal_core_cidr }}' ? h.vars.metal_core_cidr : v.ip + const set = relayed.get(partitionOf(h)) ?? new Set() + set.add(network(ipCidr as string)) + relayed.set(partitionOf(h), set) + } + } + expect(relayed.size).toBeGreaterThan(0) + const servers = hosts.filter((h) => h.group.endsWith('_mgmtservers')) + const ranges: [number, number][] = [] + for (const server of servers) { + const subnets = server.vars.dhcp_subnets as YamlMap[] + const served = new Set(subnets.map((s) => toNum(s.network as string))) + for (const n of relayed.get(partitionOf(server)) ?? []) { + expect(served.has(n), `${server.name} serves relayed network`).toBe(true) + } + for (const s of subnets) { + const r = s.range as YamlMap | undefined + if (r) ranges.push([toNum(r.begin as string), toNum(r.end as string)]) + } + } + const sorted = ranges.sort((a, b) => a[0] - b[0]) + for (let i = 1; i < sorted.length; i++) { + expect(sorted[i][0], 'DHCP ranges of the mgmt servers overlap').toBeGreaterThan( + sorted[i - 1][1], + ) + } + }) + it('writes the same bytes for the same plan', () => { expect(exportAnsibleInventory(plan).text).toBe(exportAnsibleInventory(plan).text) }) @@ -119,6 +163,15 @@ describe('deriveInventory', () => { expect(deriveInventory(plan).problems.some((p) => p.includes('the IP plan has no'))).toBe(true) }) + it('reports a rack with more nodes than its leaf PXE subnets hold', () => { + const plan = createEmptyPlan() + const [big] = plan.partitions[0].racks + big.servers[0].count = 64 + plan.partitions[0].racks.push({ ...big, id: 'small', name: 'Rack 2', servers: [] }) + plan.ipPlan.infra.headroomPercent = 0 + expect(deriveInventory(plan).problems.some((p) => p.includes('PXE'))).toBe(true) + }) + it('chains each mgmt server to its firewall and mgmt spine by address', () => { const plan = createEmptyPlan() const hosts = hostsOf(deriveInventory(plan).doc) diff --git a/src/derive/deploy/inventory.ts b/src/derive/deploy/inventory.ts index da59845..bd61223 100644 --- a/src/derive/deploy/inventory.ts +++ b/src/derive/deploy/inventory.ts @@ -1,6 +1,7 @@ import type { Partition, Plan } from '../../model/plan' import type { YamlMap } from '../../io/yaml' import { formatIp, subnet, type Cidr } from '../ip/cidr' +import { rackNodes } from '../nodes' import { deriveIpPlan, type InfraPartition } from '../ip/ipPlan' import { deriveCabling, @@ -30,6 +31,10 @@ import { // leaves' eth0 the following ones; BMCs lease the rest over DHCP. // - PXE (vlan4000): the range is split evenly over all leaves; each leaf's // metal_core_cidr is the first address of its part. +// - DHCP: each mgmt server serves every network the switches relay from +// (central and rack management, each leaf's PXE part) with the relaying +// SVI as router, the dynamic range cut in one share per mgmt server, and +// the ONIE / SONiC ZTP options of the dhcp and ztp role examples. // - Mgmt links: four /30 per side, in the order firewall to mgmt server, // firewall to mgmt server BMC, firewall to mgmt spine eth0, mgmt server to // mgmt spine; the firewall (or the mgmt server on the last) takes the @@ -93,6 +98,7 @@ export const MISSING_INPUTS: { where: string; inputs: string[] }[] = [ 'mgmt_server_metal_ssh_privkey, mgmt_server_metal_ssh_pubkey, mgmt_server_nameservers (mgmt-server)', 'ztp_authorized_keys (ztp)', 'dhcp_static_hosts: switch MACs for ZTP (dhcp)', + 'DNS servers handed out over DHCP (a domain-name-servers option per subnet)', 'lvm_pvs: the disks (lvm)', 'CI runner registration and WireGuard keys, if used', ], @@ -121,6 +127,8 @@ const ASN = { } as const const MGMT_VLAN = 1 +/** The ztp role's default port, where ZTP scripts and images are served. */ +const ZTP_PORT = 8080 const MGMT_PORTS = new Set(['eth0', 'bmc', 'mgmt']) const ip = (c: Cidr, offset: number) => formatIp(c.family, c.addr + BigInt(offset)) @@ -132,6 +140,24 @@ function split(c: Cidr, parts: number): Cidr[] { return Array.from({ length: parts }, (_, i) => subnet(c, c.prefix + bits, BigInt(i))) } +/** Dotted netmask of an IPv4 prefix. */ +function netmask(prefix: number): string { + const bits = (0xffffffff << (32 - prefix)) >>> 0 + return [24, 16, 8, 0].map((s) => (bits >>> s) & 255).join('.') +} + +/** Offsets first..last cut into `parts` contiguous shares, one per mgmt + * server, so two DHCP servers without failover never lease the same + * address. A share is missing when there are fewer addresses than parts. */ +function splitRange(first: number, last: number, parts: number): ([number, number] | undefined)[] { + const count = last - first + 1 + return Array.from({ length: parts }, (_, i) => { + const begin = first + Math.floor((count * i) / parts) + const end = first + Math.floor((count * (i + 1)) / parts) - 1 + return end >= begin ? [begin, end] : undefined + }) +} + const groupName = (prefix: string, role: string) => `${prefix.replace(/-/g, '_')}_${role}` function partitionInventory( @@ -204,6 +230,9 @@ function partitionInventory( const mgmtIf = new Map() const svi = new Map() const sviPorts = new Map() + // Every network a switch relays DHCP from: its gateway is the first + // address, dynamic leases start at `firstFree`. + const relayNets: { net: Cidr; firstFree: number; comment: string }[] = [] const central = range('Management', 'Central rack') if (central && mgmtSpines.length > 0) { const parts = split(central, mgmtSpines.length) @@ -220,6 +249,7 @@ function partitionInventory( attached.forEach((sw, k) => mgmtIf.set(sw.name, { ip: withPrefix(part, k + 2), gateway_address: ip(part, 1) }), ) + relayNets.push({ net: part, firstFree: attached.length + 2, comment: `${ms.name} mgmt` }) }) } partition.racks.forEach((rack) => { @@ -227,11 +257,15 @@ function partitionInventory( const ml = mgmtLeaves.find((m) => m.rackId === rack.id) if (!r || !ml) return svi.set(ml.name, withPrefix(r, 1)) - leaves - .filter((l) => l.rackId === rack.id) - .forEach((leaf, k) => - mgmtIf.set(leaf.name, { ip: withPrefix(r, k + 2), gateway_address: ip(r, 1) }), - ) + const rackLeaves = leaves.filter((l) => l.rackId === rack.id) + rackLeaves.forEach((leaf, k) => + mgmtIf.set(leaf.name, { ip: withPrefix(r, k + 2), gateway_address: ip(r, 1) }), + ) + relayNets.push({ + net: r, + firstFree: rackLeaves.length + 2, + comment: `${ml.name} mgmt and BMCs`, + }) }) mgmtSpines.forEach((ms, i) => { const l = link(i, 2) @@ -265,6 +299,43 @@ function partitionInventory( return l ? ip(l, 1) : undefined }) const dhcpServers = serverSpineIp.filter((x): x is string => !!x) + leaves.forEach((leaf, i) => { + if (pxeParts[i]) relayNets.push({ net: pxeParts[i], firstFree: 2, comment: `${leaf.name} PXE` }) + }) + // A node may PXE boot through either of its leaves, so each leaf's part + // must hold every node of its rack. + for (const rack of partition.racks) { + const nodes = rackNodes(rack).total + const i = leaves.findIndex((l) => l.rackId === rack.id) + const part = pxeParts[i] + if (part && nodes > Number(2n ** BigInt(32 - part.prefix)) - 3) { + problems.push( + `${partition.name}: ${rack.name} has ${nodes} nodes, more than its leaves' PXE subnets (${withPrefix(part, 0)}) hold; enlarge the infrastructure range or the headroom in the IPs tab.`, + ) + } + } + const dhcpSubnets = (server: number): YamlMap[] => [ + ...relayNets.map(({ net, firstFree, comment }) => { + const last = Number(2n ** BigInt(32 - net.prefix)) - 2 + const share = splitRange(firstFree, last, c.mgmtServers.length)[server] + return { + comment, + network: ip(net, 0), + netmask: netmask(net.prefix), + range: share ? { begin: ip(net, share[0]), end: ip(net, share[1]) } : undefined, + options: [`routers ${ip(net, 1)}`], + } + }), + ...(link(server, 3) + ? [ + { + comment: 'eno3, to the mgmt spine', + network: ip(link(server, 3)!, 0), + netmask: netmask(30), + }, + ] + : []), + ] const switchHost = (sw: SwitchHost, extra: YamlMap): YamlMap => ({ ansible_host: @@ -368,6 +439,11 @@ function partitionInventory( vars: { mgmt_server_spine_facing_interface: 'eno3', dhcp_listening_interfaces: ['eno3'], + dhcp_global_options: [ + `default-url = "http://{{ ztp_listen_address }}:${ZTP_PORT}/{{ sonic_image_name }}"`, + 'ztp_provisioning_script_url code 239 = text', + `ztp_provisioning_script_url "http://{{ ztp_listen_address }}:${ZTP_PORT}/user.sh"`, + ], }, hosts: Object.fromEntries( c.mgmtServers.map((name, i) => { @@ -380,6 +456,7 @@ function partitionInventory( mgmt_server_router_id: serverSpineIp[i], mgmt_server_firewall_ip: fw ? ip(fw, 1) : undefined, ztp_listen_address: serverSpineIp[i], + dhcp_subnets: dhcpSubnets(i), pixiecore_api_host: serverSpineIp[i] ? `http://${serverSpineIp[i]}` : undefined, planner_interfaces: { eno1: fw ? withPrefix(fw, 2) : undefined, From 5330997ba72f0da97664514db4702aed099efaf9 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:32:10 +0200 Subject: [PATCH 13/27] add the group aliases production playbooks target Production partition playbooks address some roles by other group names: sonic_leaves, mgmt_spine_switches, mgmt_leaf_switches, sonic_mgmtleaves and mgmt_servers. The generic groups now include them, so those plays find their hosts instead of skipping with an unmatched-pattern warning. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/derive/deploy/inventory.ts | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 92a0251..8f642f8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. -- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. +- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names, the group aliases production playbooks target (`ROLE_ALIASES`), and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. diff --git a/src/derive/deploy/inventory.ts b/src/derive/deploy/inventory.ts index bd61223..aa51a45 100644 --- a/src/derive/deploy/inventory.ts +++ b/src/derive/deploy/inventory.ts @@ -15,7 +15,8 @@ import { // inventory: a group per partition with a child group per device role // (_spines, _exits, _leaves, _storageleaves, _mgmtspines, // _mgmtleaves, _mgmtservers, _mgmtfirewalls) and the generic role groups -// (spines, leaves, …) above them, so metal-roles plays can target either. +// (spines, leaves, …, plus the aliases in ROLE_ALIASES) above them, so +// plays can target either. // Variable names are the ones metal-roles reads (sonic-config, metal-core, // mgmt-server, dhcp, pixiecore, ztp); `planner_*` variables only document // what the plan decided, such as the cable plan per port. @@ -126,6 +127,14 @@ const ASN = { leaf: 4210001000, } as const +/** Further generic group names production playbooks target, per role. */ +const ROLE_ALIASES: Record = { + leaves: ['sonic_leaves'], + mgmtspines: ['mgmt_spine_switches'], + mgmtleaves: ['mgmt_leaf_switches', 'sonic_mgmtleaves'], + mgmtservers: ['mgmt_servers'], +} + const MGMT_VLAN = 1 /** The ztp role's default port, where ZTP scripts and images are served. */ const ZTP_PORT = 8080 @@ -517,7 +526,9 @@ export function deriveInventory(plan: Plan): Inventory { const { group, roleGroups } = partitionInventory(plan, partition, inf, problems) partitionGroups[hostLabel(partition.name).replace(/-/g, '_')] = group for (const [role, name] of Object.entries(roleGroups)) { - roleChildren[role] = { ...(roleChildren[role] ?? {}), [name]: {} } + for (const generic of [role, ...(ROLE_ALIASES[role] ?? [])]) { + roleChildren[generic] = { ...(roleChildren[generic] ?? {}), [name]: {} } + } } } const doc: YamlMap = { From 5e3cbc4191d701c9ffbd0ecb24852746712c1b90 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sat, 26 Sep 2026 16:32:14 +0200 Subject: [PATCH 14/27] add an "Export Ansible" button to the plan actions Downloads -inventory.yaml, or explains in a toast why the plan cannot be exported yet. Client-side only, like the other exports. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 2 +- src/views/PlanView.tsx | 22 ++++++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8f642f8..cb1bcfd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -29,7 +29,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Rack defaults and power budget.** `Partition.rackDefaults` (`heightUnits` 42, `maxPowerWatts` 12000 — five 3U eight-node MicroCloud chassis at ~2 kW plus a leaf pair no longer fit in 10 kW) seed racks added to the partition and apply to the central rack; each rack keeps its own `heightUnits` / `maxPowerWatts` in its Advanced section, which also holds the rarely changed leaf model and leaf count and a rack group's member names. The central rack's Advanced section likewise holds the internet router and storage leaf counts, its hardware models (spine, superspine, storage leaf, mgmt spine/leaf/server) and mgmt leaves per rack, grouped with the network OS, fabric links and rack defaults; the section header shows the chosen models read-only. Issues that can only be fixed in either Advanced section carry `target.field: 'advanced'`, and `navigateTo()` unfolds that section (`details[data-advanced]`) when revealing them. Validation reports an error per physical rack whose estimated draw exceeds its budget. - **Info bubbles.** `views/plan/InfoBubble.tsx` renders the "i" bubbles; `SelectField` / `NumberField` take an `info` prop. Texts must match the metal-stack docs, and links come from `views/plan/docs.ts` — only pages verified to exist on docs.metal-stack.io. The partition's central components are edited in `CentralRackSection.tsx` (the UI says "Central rack", the schema still calls it `fabric`). - **IP plan.** Inputs live in `Plan.ipPlan` (`src/model/ipPlan.ts`: per-family schema, infrastructure inputs, presets). `src/derive/ip/cidr.ts` is the BigInt CIDR library (no dependency); `src/derive/ip/ipPlan.ts` derives limits (each with a formula string), the partition allocation (sequential, skipping slots that overlap Kubernetes, reserve, internet or infrastructure ranges — an internet range containing the whole project CIDR is by design), the example cluster and the infrastructure subnets; `src/derive/ip/validateIp.ts` reports issues with `target: { section: 'ips', field }`, which `navigateTo()` opens in the IPs tab. Infrastructure sizing rules are in the header of `ipPlan.ts`. Presets mirror the layouts of the untracked `ip_calc.ods`; their public ranges must be documentation ranges (RFC 5737 / RFC 3849), never real allocations. `ipPlanToCsv()` in `src/io/ipCsv.ts` exports the address plan. -- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names, the group aliases production playbooks target (`ROLE_ALIASES`), and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header. Output is byte-identical for the same plan. +- **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names, the group aliases production playbooks target (`ROLE_ALIASES`), and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header and is behind the "Export Ansible" button, which refuses plans with `problems` (superspines, L2 management, an own control-plane rack, port or IP shortages). Output is byte-identical for the same plan. - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. - **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. diff --git a/src/views/PlanView.tsx b/src/views/PlanView.tsx index d4d073b..4bf9a14 100644 --- a/src/views/PlanView.tsx +++ b/src/views/PlanView.tsx @@ -1,7 +1,9 @@ import { useMemo, useRef } from 'react' import { physicalRackCount } from '../derive/rackLayout' import { validatePlan } from '../derive/validate' +import { exportAnsibleInventory } from '../io/ansible' import { exportPlanJson, importPlanJson } from '../io/json' +import { MISSING_INPUTS } from '../derive/deploy/inventory' import { downloadText } from '../io/download' import { usePlanStore } from '../store/planStore' import { useToastStore } from '../store/toastStore' @@ -34,6 +36,18 @@ export default function PlanView() { const fileInput = useRef(null) const issues = useMemo(() => validatePlan(plan), [plan]) + function onExportAnsible() { + const { text, problems } = exportAnsibleInventory(plan) + if (problems.length > 0) { + const more = problems.length > 1 ? ` (and ${problems.length - 1} more)` : '' + notify(`Ansible export not possible: ${problems[0]}${more}`, { kind: 'error' }) + return + } + downloadText(text, `${plan.name}-inventory.yaml`, 'application/yaml') + const inputs = MISSING_INPUTS.reduce((n, m) => n + m.inputs.length, 0) + notify(`Inventory exported. ${inputs} inputs are still to be set, listed in its header.`) + } + async function onImportFile(file: File | undefined) { if (!file) return try { @@ -64,6 +78,14 @@ export default function PlanView() { Export JSON + + )} + + ))} + + + + ) +} + export default function CentralRackSection({ partition, issues, @@ -128,11 +181,17 @@ export default function CentralRackSection({ } /> patch({ spineCount: n })} /> @@ -149,14 +208,14 @@ export default function CentralRackSection({ patch({ superspineCount: n })} /> )} + {hasSuperspine && }

available const nodes = rackNodes(rack) + const pods = podsOf(partition) return (
setRackKind(partition.id, rack.id, v as Rack['kind'])} /> + {pods.length > 0 && ( +
+ p.racks.some((r) => r.id === rack.id))?.id ?? pods[0].id} + options={pods.map((p) => ({ value: p.id, label: p.name }))} + onChange={(v) => patchRack(partition.id, rack.id, { podId: v })} + /> +
+ )} {rack.memberNames && ( {rack.memberNames.join(' · ')} )} From 9f793672e72fafa8b0e44c7fabd23d31eb167e7f Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sun, 27 Sep 2026 08:23:04 +0200 Subject: [PATCH 24/27] check pods the way the cable plan lays them out Review findings on the pod model: - The spine tier ratio ignored the central rack's spines, which carry every pod's storage traffic (exits, storage leaves and control-plane rack leaves on their way to the superspines). It now takes the busiest spine of all groups; requiredSuperspines follows. - The mgmt spine copper check counted every central device against a single mgmt spine, although the cable plan spreads them over the mgmt spines in turn. With pods that turned into false errors (a plan the cable plan lays out with 28 cables per mgmt spine was refused for 49). It now counts each mgmt spine's share plus its own mgmt server, and includes on-prem control plane nodes, which the cable plan puts there. - New warnings: a superspine partition with only one pod gains nothing over leaf-spine, and a rack whose podId names no pod (a hand-edited or imported file) silently counts to the first pod. --- src/derive/bandwidth.test.ts | 15 ++++++++++ src/derive/bandwidth.ts | 35 ++++++++++++++-------- src/derive/validate.test.ts | 35 ++++++++++++++++++++++ src/derive/validate.ts | 58 ++++++++++++++++++++++++++++-------- 4 files changed, 117 insertions(+), 26 deletions(-) diff --git a/src/derive/bandwidth.test.ts b/src/derive/bandwidth.test.ts index 58ccfeb..69d256f 100644 --- a/src/derive/bandwidth.test.ts +++ b/src/derive/bandwidth.test.ts @@ -102,3 +102,18 @@ describe('spine tier with pods', () => { expect(requiredSuperspines(podPlan().partitions[0])).toBe(4) }) }) + +describe('spine tier with a busy central rack', () => { + it('counts the central rack spines, which carry every pod’s storage traffic', () => { + const plan = podPlan() + plan.partitions[0].fabric.storageLeafCount = 4 + // Central rack spine: 2 exits + 4 storage leaves = 600 down, 2 superspines = 200 up; + // a pod spine is 200 : 200, so the central rack is the worst. + expect(spineBandwidth(plan.partitions[0])).toMatchObject({ + downGbps: 600, + upGbps: 200, + ratio: 3, + }) + expect(requiredSuperspines(plan.partitions[0])).toBe(2 * 6) + }) +}) diff --git a/src/derive/bandwidth.ts b/src/derive/bandwidth.ts index 3cdb9b4..f43b3c7 100644 --- a/src/derive/bandwidth.ts +++ b/src/derive/bandwidth.ts @@ -35,20 +35,29 @@ export function rackBandwidth(rack: Rack, partition: Partition): Oversubscriptio return oversubscription(servers, rack.leafCount * spineCount * leafSpineLinks * FABRIC_LINK_GBPS) } -/** Leaves of the pod with the most, which sets the spine tier's worst - * ratio: every spine of a pod takes all of its leaves. */ -function busiestPodLeaves(partition: Partition): number { - return Math.max(0, ...podsOf(partition).map((p) => p.racks.reduce((n, r) => n + r.leafCount, 0))) +/** Downward links of the busiest spine: a pod spine takes leafSpineLinks + * per leaf of its pod; a central rack spine takes the exits, the storage + * leaves and a control-plane rack's leaves, and with them the storage + * traffic of every pod. */ +function busiestSpineLinks(partition: Partition, controlPlaneLeaves: number): number { + const { exitSwitchCount, storageLeafCount, leafSpineLinks } = partition.fabric + const central = exitSwitchCount + storageLeafCount + controlPlaneLeaves * leafSpineLinks + const pods = podsOf(partition).map( + (p) => p.racks.reduce((n, r) => n + r.leafCount, 0) * leafSpineLinks, + ) + return Math.max(central, ...pods) } -/** Leaf → spine bandwidth of the busiest pod against its spine → superspine - * bandwidth, per spine: a pod spine takes leafSpineLinks per leaf of its - * pod and has one link to each superspine of its plane. Null unless the - * fabric has a superspine tier. */ -export function spineBandwidth(partition: Partition): Oversubscription | null { +/** Downward against upward bandwidth of the busiest spine, which has one + * link to each superspine of its plane. Null unless the fabric has a + * superspine tier. */ +export function spineBandwidth( + partition: Partition, + controlPlaneLeaves = 0, +): Oversubscription | null { if (!hasSuperspineTier(partition)) return null return oversubscription( - busiestPodLeaves(partition) * partition.fabric.leafSpineLinks * FABRIC_LINK_GBPS, + busiestSpineLinks(partition, controlPlaneLeaves) * FABRIC_LINK_GBPS, superspinesPerPlane(partition) * FABRIC_LINK_GBPS, ) } @@ -61,9 +70,9 @@ export function requiredLeafSpineLinks(rack: Rack, partition: Partition): number } /** Superspines a non-blocking spine tier would need: a plane per spine, - * each as wide as the busiest pod's leaf links. */ -export function requiredSuperspines(partition: Partition): number { - return partition.fabric.spineCount * busiestPodLeaves(partition) * partition.fabric.leafSpineLinks + * each as wide as the busiest spine's downward links. */ +export function requiredSuperspines(partition: Partition, controlPlaneLeaves = 0): number { + return partition.fabric.spineCount * busiestSpineLinks(partition, controlPlaneLeaves) } /** "1.0 : 1", "14.4 : 1", or "—" when there is nothing to compare. */ diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index defd85b..c066ab4 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -474,3 +474,38 @@ describe('leaf-spine-superspine validation', () => { expect(messages(plan).some((m) => m.includes('Pod C has no racks'))).toBe(true) }) }) + +describe('review follow-ups', () => { + const messages = (plan: Plan) => validatePlan(plan).map((i) => i.message) + + it('warns that a single compute pod gains nothing over leaf-spine', () => { + const plan = podPlan() + plan.partitions[0].pods = [{ id: 'pa', name: 'Pod A' }] + plan.partitions[0].racks[1].podId = 'pa' + expect(messages(plan).some((m) => m.includes('only one pod'))).toBe(true) + expect(messages(podPlan()).some((m) => m.includes('only one pod'))).toBe(false) + }) + + it('warns about a rack whose pod does not exist', () => { + const plan = podPlan() + plan.partitions[0].racks[1].podId = 'gone' + expect(messages(plan).some((m) => m.includes('Rack 2 names a pod that does not exist'))).toBe( + true, + ) + }) + + it('budgets mgmt spine copper per mgmt spine, as the devices are spread', () => { + const plan = podPlan() + const p = plan.partitions[0] + p.fabric.spineCount = 4 + p.fabric.superspineCount = 8 + p.pods = Array.from({ length: 8 }, (_, i) => ({ id: `p${i}`, name: `Pod ${i + 1}` })) + p.racks.forEach((r, i) => (r.podId = `p${i}`)) + // 36 spines + 8 superspines + 2 exits + 2 routers = 48 eth0s over 2 mgmt + // spines: 24 each plus its mgmt server fits 48 ports; all on one would not. + expect(messages(plan).some((m) => m.startsWith('Mgmt spine capacity exceeded'))).toBe(false) + p.fabric.mgmt.redundant = false + // One mgmt spine: 48 + its mgmt server = 49 > 48 + expect(messages(plan).some((m) => m.startsWith('Mgmt spine capacity exceeded'))).toBe(true) + }) +}) diff --git a/src/derive/validate.ts b/src/derive/validate.ts index b783778..3593be8 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -1,5 +1,11 @@ import { catalog, itemLabel, portCount, type SwitchRole } from '../model/catalog' -import { type Partition, type Plan, type Rack, type ServerGroup } from '../model/plan' +import { + mgmtDeviceCount, + type Partition, + type Plan, + type Rack, + type ServerGroup, +} from '../model/plan' import { formatGbps, formatRatio, @@ -307,11 +313,32 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v `${fabric.spineCount}, e.g. ${perPlane * fabric.spineCount}.`, ) } - for (const pod of podsOf(partition)) { + const pods = podsOf(partition) + for (const pod of pods) { if (pod.racks.length === 0) { report(issues, scope, 'warning', `${pod.name} has no racks; its spines carry nothing.`) } } + if (pods.length === 1) { + report( + issues, + scope, + 'warning', + 'Superspines with only one pod just join it to the central rack: leaf-spine gives the ' + + 'same with half the spines. Add pods, or use leaf-spine.', + ) + } + const known = new Set(partition.pods.map((p) => p.id)) + for (const rack of partition.racks) { + if (rack.podId && !known.has(rack.podId)) { + report( + issues, + { where: rack.name, target: { partitionId: partition.id, rackId: rack.id } }, + 'warning', + `${rack.name} names a pod that does not exist; it counts to ${pods[0]?.name ?? 'the first pod'}. Pick its pod in the rack section.`, + ) + } + } const superspine = catalog[fabric.superspineModelId] const podPorts = 1 + podsOf(partition).length if (superspine && podPorts > portCount(superspine, '100G')) { @@ -359,7 +386,7 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v } // Non-blocking fabric at the spine tier (only with superspines). - const spineTier = spineBandwidth(partition) + const spineTier = spineBandwidth(partition, controlPlaneLeafCount(plan, partition)) if (fabric.nonBlocking && spineTier && spineTier.ratio !== null && spineTier.ratio > 1) { report( issues, @@ -367,7 +394,7 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v 'error', `Spine tier is oversubscribed ${formatRatio(spineTier.ratio)}: ${formatGbps(spineTier.downGbps)} ` + `from the leaves against ${formatGbps(spineTier.upGbps)} towards the superspines. ` + - `A non-blocking fabric needs ${requiredSuperspines(partition)} superspines ` + + `A non-blocking fabric needs ${requiredSuperspines(partition, controlPlaneLeafCount(plan, partition))} superspines ` + `(now ${fabric.superspineCount}).`, ) } @@ -393,28 +420,33 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v } } - // Mgmt spine port budgets, matching the cabling model in bom.ts: copper - // 1G ports for its own mgmt server and the management interface of every - // central-rack switch and router (the mgmt firewall lands on the spine's - // own mgmt port, not a front-panel one); fiber ports (25G, or 10G on - // switches without 25G) for the uplink of every mgmt leaf. + // Mgmt spine port budgets, matching the cable plan (derive/deploy/ + // cabling.ts): the management interfaces of the central rack's switches, + // routers and on-prem control plane nodes are spread over the mgmt spines + // in turn, so each takes its share plus its own mgmt server on 1G copper + // (the mgmt firewall lands on the spine's own mgmt port, not a front-panel + // one); fiber ports (25G, or 10G on switches without 25G) take the uplink + // of every mgmt leaf. const mgmtSpine = catalog[fabric.mgmt.spineModelId] if (mgmtSpine && partition.racks.length > 0) { const superspines = fabric.fabricType === 'leaf-spine-superspine' ? fabric.superspineCount : 0 - const copperNeeded = - 1 + + const devices = spinesTotal(partition) + fabric.exitSwitchCount + superspines + fabric.storageLeafCount + - fabric.routerCount + fabric.routerCount + + (inCentralRack(plan, partition) ? plan.controlPlane.nodeCount : 0) + const mgmtSpines = mgmtDeviceCount(fabric.mgmt) + const copperNeeded = Math.ceil(devices / mgmtSpines) + 1 const copperAvailable = portCount(mgmtSpine, '1G') if (copperNeeded > copperAvailable) { report( issues, scope, 'error', - `Mgmt spine capacity exceeded: ${copperNeeded} 1G ports needed, ` + + `Mgmt spine capacity exceeded: ${copperNeeded} 1G ports needed per mgmt spine ` + + `(${devices} management interfaces over ${mgmtSpines}, plus its mgmt server), ` + `but ${itemLabel(fabric.mgmt.spineModelId)} has ${copperAvailable}.`, ) } From abfbf7309c9f6629efcb999d4ef8f9d75c3e3363 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sun, 27 Sep 2026 08:23:04 +0200 Subject: [PATCH 25/27] keep pods in the central view and fix the RFC citation The pod spines sit in the central rack, so the central view now keeps the pods and their spines instead of dropping them. The pods.ts header cited RFC 7938 section 3.2.3 for "Every Tier 2 device connects to a single group of Tier 1 devices"; that sentence is in section 3.2.4 (3.2.3 holds Figure 3). The border pod is now described as adapted from section 5.2.4, whose Tier 3 are WAN routers. --- src/derive/pods.ts | 18 ++++++++++-------- src/derive/topology.test.ts | 7 +++++-- src/derive/topology.ts | 3 ++- 3 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/derive/pods.ts b/src/derive/pods.ts index 11c8fd5..9b94b0a 100644 --- a/src/derive/pods.ts +++ b/src/derive/pods.ts @@ -1,13 +1,15 @@ import type { Partition, Rack } from '../model/plan' -// Pods of a leaf-spine-superspine partition, as RFC 7938 section 3.2.3 -// builds a 5-stage Clos: a pod's leaves uplink only to that pod's spines -// (Tier 2), and spine j of every pod uplinks to plane j of the superspines -// (Tier 1): "Every Tier 2 device connects to a single group of Tier 1 -// devices". The central rack is the border pod: exits, storage leaves and -// a control-plane rack of its own hang off its spines (`fabric.spineCount` -// of them, like every pod), as the dedicated cluster for external -// connectivity of RFC 7938 section 5.2.4. With fabricType leaf-spine there +// Pods of a leaf-spine-superspine partition, after the 5-stage Clos of +// RFC 7938 (Figure 3, section 3.2.3): a pod's leaves uplink only to that +// pod's spines (Tier 2), and spine j of every pod uplinks to plane j of the +// superspines (Tier 1), since "Every Tier 2 device connects to a single +// group of Tier 1 devices" (section 3.2.4). The central rack is the border +// pod: exits, storage leaves and a control-plane rack of its own hang off +// its spines (`fabric.spineCount` of them, like every pod), adapted from +// the dedicated cluster for external connectivity of section 5.2.4, whose +// Tier 3 are WAN routers; here the exits are Tier 3 and the routers sit +// beyond them. With fabricType leaf-spine there // are no pods: every rack uplinks to the central rack's spines. export interface PodRacks { diff --git a/src/derive/topology.test.ts b/src/derive/topology.test.ts index b099e2d..9329cd1 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -433,13 +433,16 @@ describe('leaf-spine-superspine fabric (RFC 7938 5-stage Clos)', () => { } }) - it('keeps pod spines and their links in production mode only', () => { + it('keeps pod spines in the production and central views, not in management', () => { const graph = deriveTopology(podPlan()) const prod = filterTopology(graph, 'production') const podSpine = prod.partitions[0].pods[0].spines[0].id expect(prod.links.some((l) => l.from === podSpine || l.to === podSpine)).toBe(true) expect(filterTopology(graph, 'management').partitions[0].pods[0].spines).toEqual([]) - expect(filterTopology(graph, 'central').partitions[0].pods).toEqual([]) + // Pod spines sit in the central rack, so the central view keeps them. + const central = filterTopology(graph, 'central').partitions[0] + expect(central.pods.map((pod) => pod.spines.length)).toEqual([2, 2]) + expect(central.racks).toEqual([]) }) it('has no pods in a leaf-spine fabric', () => { diff --git a/src/derive/topology.ts b/src/derive/topology.ts index fd3c348..1580206 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -600,7 +600,8 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo mgmtFirewalls: keep(p.central.mgmtFirewalls), }, storageLeaves: mode === 'central' ? [] : keep(p.storageLeaves), - pods: mode === 'central' ? [] : p.pods.map((pod) => ({ ...pod, spines: keep(pod.spines) })), + // Pod spines sit in the central rack, so the central view keeps them. + pods: p.pods.map((pod) => ({ ...pod, spines: keep(pod.spines) })), racks: mode === 'central' ? [] From 28764d4a5fbc621e99f79efb93b978b3a72b3cbd Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sun, 27 Sep 2026 08:23:04 +0200 Subject: [PATCH 26/27] keep a stored plan the app cannot read before replacing it On rehydrate, a stored plan that does not migrate or parse (for example one written by a newer planner) was replaced by an empty plan and that was persisted: the original was gone without a message. With plan format 2 this becomes reachable as soon as an older build opens storage written by this one. merge now copies the original to metal-stack-planner/plan-unreadable first. The key and helper are defined above the store: rehydration runs while the module is still being evaluated, and a const further down was in its temporal dead zone, which the try/catch swallowed. Checked in Chromium: a stored v1 plan keeps its name and racks; a stored format 3 plan is replaced and kept under the backup key. --- CLAUDE.md | 6 +++--- src/store/planStore.ts | 16 ++++++++++++++++ 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 7040ffa..9c31161 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Cable plan and Ansible export.** `src/derive/deploy/cabling.ts` lays out every cable of a partition with both ends named: hostnames modelled on a production inventory (`-r01leaf01`, `-spine01`, `-mgmtspine01`, `-mgmtserver01`, `-mgmtfw01`), SONiC port names from the catalog's `sonicPorts` (taken from sonic-buildimage `port_config.ini`; a model without one is reported, never guessed) and the port conventions in its header. `cabling.test.ts` holds its cable counts per type against `deriveBom()` for the default plan, every template and an everything plan, so a new BOM rule needs its cable here too. `nodeSwitchPorts()` in `bom.ts` is the single rule for 4x25G breakouts (per switch, not per group). `src/derive/deploy/inventory.ts` turns cable plan and IP plan into an Ansible inventory in the production layout (a group per partition, role child groups, generic role groups) with metal-roles variable names, the group aliases production playbooks target (`ROLE_ALIASES`), and `dhcp_subnets` on the mgmt servers for every network the switches relay (dynamic range split per mgmt server); addresses and the ASN scheme are in its header. What a plan cannot know stays undefined and is listed in `MISSING_INPUTS`, never filled with a placeholder, so the roles' `is defined` asserts still stop a run. `src/io/yaml.ts` writes YAML with every string JSON-quoted; `src/io/ansible.ts` adds the header and is behind the "Export Ansible" button, which refuses plans with `problems` (L2 management, an own control-plane rack, port or IP shortages). Output is byte-identical for the same plan. - **Icons.** Icons come from `src/views/icons/` only: `index.tsx` is the registry (Lucide icons plus the custom `NetworkSwitch`, `Rack`, `RackGroup` glyphs from `custom.ts`, drawn on Lucide's 24 px grid) with typed maps per tab, node kind, BOM category, rack slot kind and severity, and an `` helper (decorative, `aria-hidden`). An ESLint rule forbids importing `lucide-react` anywhere else. Sizes: 16 px in tabs and buttons, 14 px inline, in topology nodes and in rack slots; color follows the text. - **Templates.** `src/model/templates.ts` holds the starting plans offered by the Templates menu. They must order only hardware that is current at the vendor — `templates.test.ts` fails on any `availability` of `eol` / `withdrawn`, for the templates and for the default plan. Each `build()` returns a fresh Plan with new ids; `templates.test.ts` asserts every template parses and has no validation errors, so keep them valid when capacity rules change. -- **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message. +- **Persistence and format version.** Zustand store (`src/store/planStore.ts`) with `persist` middleware → localStorage key `metal-stack-planner/plan`. JSON export/import in `src/io/json.ts`; every import runs through `migrateRawPlan()` (`src/model/migrate.ts`) and is then validated with `PlanSchema.parse`, and the same path runs on rehydrate. `SCHEMA_VERSION` in `migrate.ts` is the plan file format version — currently 1 with no migrations. Purely additive fields only need a Zod `.default()`; anything that breaks older files bumps `SCHEMA_VERSION` and adds a function to `MIGRATIONS`, keyed by the version it upgrades _from_. Files from a newer version are refused with a readable message; a stored plan the app cannot read on rehydrate is replaced by a fresh one, and `merge` first keeps the original under `metal-stack-planner/plan-unreadable` (`UNREADABLE_PLAN_KEY`, defined above the store because rehydration runs while the module is evaluated). - **Client-only invariant.** No network calls, no backend. Keep it that way. - **Styling.** No long dashes (U+2014) in any UI text: labels, info texts, issue messages and placeholders use a colon, comma or parentheses in prose, a middle dot `·` between a heading and its gray subtitle, and an en dash `–` for an empty cell. `src/uiText.test.ts` fails on any outside comments. Tailwind v4. The look follows metal-stack.io: brand tokens (`--color-brand` amber, `--color-ink`, `--color-page`) and the `btn-*` / `card` utilities live in `src/index.css` — use them instead of ad-hoc color classes. Inline SVG (topology, rack elevations, IP range bars) takes its colors from `src/views/colors.ts`, which points at the theme's CSS variables (network colors `--color-production` / `--color-mgmt` in `index.css`), never hex literals; spell each variable name out there, since Tailwind only emits the variables it finds in the source. Text sizes stay on Tailwind's `text-xs` / `text-sm` / `text-base` steps (no arbitrary `text-[Npx]`), with no all-caps labels. Inter is bundled via `@fontsource-variable/inter` (imported in `main.tsx`), and the official picture mark (`src/assets/metal-stack-logo.svg`, from the metal-stack docs repo) is bundled locally; never load either from the network. @@ -46,11 +46,11 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack - **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines, mgmt servers and mgmt firewalls (2 vs 1, `mgmtDeviceCount()`). Each side is a chain as in the deployment guide's routed out-of-band network: mgmt firewall i links to mgmt server i (interface and BMC) and to mgmt spine i's own mgmt port, and mgmt server i uplinks to mgmt spine i only, never across. The firewall (`MgmtNetwork.firewallModelId`, catalog `firewall-mgmt`) is a vendor-neutral 1U server with 6× 1G RJ45; the Teltonika RUTXR1 of earlier setups is end-of-life. 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 (the topology spreads the spines over the mgmt spines in turn) — 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. - `ServerGroup.role` is `worker` | `storage` only. Management servers are never a compute-rack group — they are configured on the fabric's management network and live in the central rack. Legacy `management` groups are dropped on import in `model/normalize.ts`. -- **Fabric bandwidth.** `src/derive/bandwidth.ts` is the single source of the oversubscription ratio: per rack `Σ nodes × (50 Gbit/s for 2x25G, 200 for 2x100G)` against `leafCount × spineCount × leafSpineLinks × 100 Gbit/s`, and per spine the busiest pod's `leaves × leafSpineLinks × 100` against `superspinesPerPlane × 100` when a superspine tier exists. `FabricConfig.nonBlocking` (default `false`, so templates stay valid) turns a ratio above 1:1 into a validation error naming the links per leaf ↔ spine pair (or superspines) that would fix it; the ratio chips in the plan editor show it either way. North-south bandwidth (spines → exits → routers) is deliberately not part of this. +- **Fabric bandwidth.** `src/derive/bandwidth.ts` is the single source of the oversubscription ratio: per rack `Σ nodes × (50 Gbit/s for 2x25G, 200 for 2x100G)` against `leafCount × spineCount × leafSpineLinks × 100 Gbit/s`, and per spine the busiest spine's downward links × 100 (a pod spine's leaves × leafSpineLinks, or a central rack spine's exits, storage leaves and control-plane rack leaves, which carry every pod's storage traffic) against `superspinesPerPlane × 100` when a superspine tier exists. `FabricConfig.nonBlocking` (default `false`, so templates stay valid) turns a ratio above 1:1 into a validation error naming the links per leaf ↔ spine pair (or superspines) that would fix it; the ratio chips in the plan editor show it either way. North-south bandwidth (spines → exits → routers) is deliberately not part of this. - **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router and one link per mgmt server to its own mgmt spine; three per mgmt firewall (mgmt server, its BMC, the mgmt spine's mgmt port). Management fiber: every mgmt leaf × every mgmt spine over LC duplex with SR transceivers — 25G when both switch models have 25G ports, else 10G (`mgmtUplinkSpeed()`). One NOS support license per switch, resolved by `nosLicenseId()` from the partition's `FabricConfig.nos` (`broadcom-sonic` by default, `edgecore-sonic` the alternative, chosen in the partition's Advanced section) and the switch's catalog `licenseClass` (`100g` / `25g`) — the vendors tier their licenses differently, so `nosOptions` in `catalog.ts` maps tier to SKU per NOS. Internet routers (`FabricConfig.routerCount`, default 2 for new plans) sit at the top of the central rack, link twice to every exit (validated against the exit's 100G ports) and take the internet and company networks in the topology. - Server uplinks are configured per `ServerGroup`: `2x25G` (Intel E810-XXVDA2) or `2x100G` (Intel E810-CQDA2). 25G server ports terminate on 100G leaf ports via 4x25G breakout — this breakout math drives transceiver and cable quantities in the BOM. - `ServerGroup.count` is server _nodes_; chassis counts are derived via `nodesPerChassis` (MicroCloud = 8, BigTwin = 4). -- The topology view shows devices and links only — no metal-stack component placements (the metal-core, metal-api, PXE and BMC chips were removed on purpose) and no link-count badges. Server groups have no edges at all — neither uplinks to the leaves nor BMC links to the mgmt leaf are drawn (too many edges); the BOM still counts them. With pods, each pod is a box above its racks holding the pod's spines, so leaf uplinks stay inside the box and only spine j of each pod rises to its plane of superspines (the pod spines are drawn with their pod, though they sit in the central rack in the Racks view). The central rack is drawn in two columns (production left, management right) with spines and mgmt spines in the bottom row so rack uplinks don't cross other device rows. +- The topology view shows devices and links only — no metal-stack component placements (the metal-core, metal-api, PXE and BMC chips were removed on purpose) and no link-count badges. Server groups have no edges at all — neither uplinks to the leaves nor BMC links to the mgmt leaf are drawn (too many edges); the BOM still counts them. With pods, each pod is a box above its racks holding the pod's spines, so leaf uplinks stay inside the box and only spine j of each pod rises to its plane of superspines (the central view keeps the pod boxes, since the pod spines sit in the central rack). The central rack is drawn in two columns (production left, management right) with spines and mgmt spines in the bottom row so rack uplinks don't cross other device rows. ## sample-boms/ diff --git a/src/store/planStore.ts b/src/store/planStore.ts index 58ff1c9..c343fb4 100644 --- a/src/store/planStore.ts +++ b/src/store/planStore.ts @@ -68,6 +68,18 @@ interface PlannerState { resetPlan: () => void } +/** Where merge keeps a stored plan it could not read. Defined before the + * store: rehydration runs while this module is still being evaluated. */ +export const UNREADABLE_PLAN_KEY = 'metal-stack-planner/plan-unreadable' + +function keepUnreadablePlan(plan: unknown): void { + try { + localStorage.setItem(UNREADABLE_PLAN_KEY, JSON.stringify(plan)) + } catch { + // Storage unavailable (private mode, tests): nothing to keep it in. + } +} + function touched(plan: Plan, changes: Partial): Plan { return { ...plan, ...changes, updatedAt: new Date().toISOString() } } @@ -326,6 +338,10 @@ export const usePlanStore = create()( const s = persisted as { plan?: unknown; activeView?: View } | undefined const migrated = migrateRawPlan(s?.plan) const parsed = migrated.ok ? PlanSchema.safeParse(migrated.plan) : null + // A plan this build cannot read (e.g. written by a newer planner) is + // replaced by a fresh one, which is persisted right after; keep the + // stored original under its own key first so it is not lost. + if (s?.plan !== undefined && !parsed?.success) keepUnreadablePlan(s.plan) return { ...current, plan: parsed?.success ? normalizePlan(parsed.data) : createEmptyPlan(), From 8802e79d3d5aac11d751b4d194530620e157ae50 Mon Sep 17 00:00:00 2001 From: Muhittin Kesikli Date: Sun, 27 Sep 2026 08:23:04 +0200 Subject: [PATCH 27/27] mark superspine exports as untested and widen the pod cabling test An exported inventory with a leaf-spine-superspine partition says in its header that the design follows RFC 7938, is not a metal-stack reference architecture, and is untested in a lab. The cabling test gains a plan that combines pods with a rack group, storage leaves, two links per leaf and spine pair and an odd node count; mutating the plane mapping in the cable plan makes the cable-vs-BOM comparison fail (44 vs 32 trunks). --- src/derive/deploy/cabling.test.ts | 13 +++++++++++++ src/derive/deploy/inventory.test.ts | 5 +++++ src/io/ansible.ts | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/src/derive/deploy/cabling.test.ts b/src/derive/deploy/cabling.test.ts index 773604e..97eedae 100644 --- a/src/derive/deploy/cabling.test.ts +++ b/src/derive/deploy/cabling.test.ts @@ -35,11 +35,24 @@ function everythingPlan(): Plan { return plan } +/** Pods combined with the other cabled features. */ +function podsEverything(): Plan { + const plan = podPlan() + const p = plan.partitions[0] + p.fabric.storageLeafCount = 2 + p.fabric.leafSpineLinks = 2 + p.racks[0].servers[0].count = 9 + const group = withRackKind(p, { ...p.racks[1], id: 'grp' }, 'rack-group') + p.racks.push({ ...group, podId: 'pb' }) + return plan +} + const plans: [string, () => Plan][] = [ ['the default plan', createEmptyPlan], ...templates.map((t): [string, () => Plan] => [`template ${t.name}`, t.build]), ['a plan with every cabled feature', everythingPlan], ['a leaf-spine-superspine plan with two pods', podPlan], + ['pods with a rack group, storage leaves, 2 links per pair and 9 nodes', podsEverything], ] describe.each(plans)('cabling of %s', (_, build) => { diff --git a/src/derive/deploy/inventory.test.ts b/src/derive/deploy/inventory.test.ts index 6f59fe2..1f0404c 100644 --- a/src/derive/deploy/inventory.test.ts +++ b/src/derive/deploy/inventory.test.ts @@ -160,6 +160,11 @@ describe.each(plans)('inventory of %s', (_, build) => { }) describe('deriveInventory', () => { + it('marks a superspine export as untested in a lab, and only that one', () => { + expect(exportAnsibleInventory(podPlan()).text).toContain('UNTESTED in a lab') + expect(exportAnsibleInventory(createEmptyPlan()).text).not.toContain('UNTESTED') + }) + it('gives superspines one ASN and the spines of each pod their own (RFC 7938 5.2.1)', () => { const hosts = hostsOf(deriveInventory(podPlan()).doc) const asn = (h: Host) => h.vars.sonic_config_asn ?? h.groupVars.sonic_config_asn diff --git a/src/io/ansible.ts b/src/io/ansible.ts index 9b1b110..480779d 100644 --- a/src/io/ansible.ts +++ b/src/io/ansible.ts @@ -28,6 +28,14 @@ function header(plan: Plan): string { 'SONiC 202111.x and 202211.x; metal-core needs sonic_config_docker_routing_config_mode', '"split" there (the role default).', '', + ...(plan.partitions.some((p) => p.fabric.fabricType === 'leaf-spine-superspine') + ? [ + 'Leaf-spine-superspine partitions follow RFC 7938 (pods and planes), a planning', + 'design and not a metal-stack reference architecture. Their variables are', + 'UNTESTED in a lab: run them in containerlab or mini-lab before any rollout.', + '', + ] + : []), 'Still to be set, left undefined on purpose so the roles stop instead of', 'running with a placeholder:', ...MISSING_INPUTS.flatMap((m) => [` ${m.where}:`, ...m.inputs.map((i) => ` - ${i}`)]),