diff --git a/CLAUDE.md b/CLAUDE.md index d0a2bbd..9c31161 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,31 +25,32 @@ 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`. 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. A server group can be dragged onto another rack of its partition: `moveGroupArgs()` decides whether the drop is valid and `moveChassis()` (`model/moveChassis.ts`) moves all its nodes, merging into a matching group there; a press becomes a drag after `DRAG_THRESHOLD`, so a plain click still selects. Editor sections are CSS containers (`@container`) and size their grids with `@2xl:` container breakpoints, so the same section works in the Plan tab and in the narrow inspector; use container breakpoints, not window ones, inside them. `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. +- **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. ## Domain - 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. +- Fabric types per partition: `leaf-spine` | `leaf-spine-superspine` (`FabricConfig.fabricType`); superspines and storage leaves only enter the BOM/validation when configured. **Leaf-spine-superspine is a 5-stage Clos after RFC 7938 with pods and planes**, a planning design and not a metal-stack reference architecture (the docs only describe 2-tier Clos; validation says so on every such partition): `Partition.pods` / `Rack.podId` (plan format 2); a pod's racks uplink only to the pod's own `spineCount` spines, and spine j of every pod, including the central rack's spines as the border pod, connects to plane j of the superspines (`superspineCount / spineCount` per plane, a remainder is an error). Exits, storage leaves and a control-plane rack stay on the central rack's spines. Every pod's spines sit physically in the central rack. `src/derive/pods.ts` is the single source (`podsOf()`: racks without a known pod go to the first, no pods means an implicit Pod 1; `spinesTotal()`, `superspinesPerPlane()`), and `spinePorts()` in `bom.ts` the single rule for the links each spine group terminates, used by the BOM and the spine port budget. ASNs follow RFC 7938 5.2.1 (superspines share one, each pod's spines one). Pods are edited in the central rack section (`PodList`: the first "Add pod" turns the implicit Pod 1 into a real one via `withPodAdded()`, `withPodRemoved()` moves a pod's racks back to the first pod) and each rack picks its pod in its own section. +- **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 (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. -- **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. +- **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. 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/derive/bandwidth.test.ts b/src/derive/bandwidth.test.ts index fadcf96..69d256f 100644 --- a/src/derive/bandwidth.test.ts +++ b/src/derive/bandwidth.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan } from '../model/defaults' +import { podPlan } from './podPlan.fixture' import { templates } from '../model/templates' import { formatBandwidth, @@ -70,13 +71,15 @@ describe('spineBandwidth', () => { const partition = plan.partitions[0] partition.fabric.fabricType = 'leaf-spine-superspine' partition.fabric.superspineCount = 2 - // 2 leaves x 1 link x 100 down, 2 superspines x 100 up - expect(spineBandwidth(partition)).toEqual({ downGbps: 200, upGbps: 200, ratio: 1 }) - expect(requiredSuperspines(partition)).toBe(2) + // One pod: 2 leaves x 1 link x 100 down per spine; 2 superspines on 2 + // spines is 1 per plane, so 1 x 100 up + expect(spineBandwidth(partition)).toEqual({ downGbps: 200, upGbps: 100, ratio: 2 }) + // 1:1 needs planes as wide as the pod's leaf links: 2 spines x 2 + expect(requiredSuperspines(partition)).toBe(4) partition.fabric.leafSpineLinks = 2 - expect(spineBandwidth(partition)!.ratio).toBe(2) - expect(requiredSuperspines(partition)).toBe(4) + expect(spineBandwidth(partition)!.ratio).toBe(4) + expect(requiredSuperspines(partition)).toBe(8) }) }) @@ -86,3 +89,31 @@ describe('formatBandwidth', () => { expect(formatBandwidth(11_500)).toBe('11.5 Tbit/s') }) }) + +describe('spine tier with pods', () => { + it('compares a pod spine against its plane, not the whole partition', () => { + // Per pod spine: 2 leaves x 1 link x 100 down, 2 superspines x 100 up + expect(spineBandwidth(podPlan().partitions[0])).toMatchObject({ + downGbps: 200, + upGbps: 200, + ratio: 1, + }) + // 1:1 needs 2 superspines per plane, 4 in all + 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 847d132..f43b3c7 100644 --- a/src/derive/bandwidth.ts +++ b/src/derive/bandwidth.ts @@ -1,4 +1,5 @@ import { type Partition, type Rack, type UplinkSpeed } from '../model/plan' +import { hasSuperspineTier, podsOf, superspinesPerPlane } from './pods' // Oversubscription of the fabric: how much machine bandwidth sits behind // the uplinks of a tier. A fabric is non-blocking at 1:1 or better. @@ -34,15 +35,30 @@ export function rackBandwidth(rack: Rack, partition: Partition): Oversubscriptio return oversubscription(servers, rack.leafCount * spineCount * leafSpineLinks * FABRIC_LINK_GBPS) } -/** Leaf → spine bandwidth of a partition against spine → superspine, per - * spine. Null unless the fabric has a superspine tier. */ -export function spineBandwidth(partition: Partition): Oversubscription | null { - const { fabricType, superspineCount, leafSpineLinks } = partition.fabric - if (fabricType !== 'leaf-spine-superspine') return null - const leaves = partition.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) +} + +/** 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( - leaves * leafSpineLinks * FABRIC_LINK_GBPS, - superspineCount * FABRIC_LINK_GBPS, + busiestSpineLinks(partition, controlPlaneLeaves) * FABRIC_LINK_GBPS, + superspinesPerPlane(partition) * FABRIC_LINK_GBPS, ) } @@ -53,10 +69,10 @@ export function requiredLeafSpineLinks(rack: Rack, partition: Partition): number return Math.ceil(rackBandwidth(rack, partition).downGbps / perLink) } -/** Superspines a non-blocking spine tier would need. */ -export function requiredSuperspines(partition: Partition): number { - const leaves = partition.racks.reduce((n, r) => n + r.leafCount, 0) - return leaves * partition.fabric.leafSpineLinks +/** Superspines a non-blocking spine tier would need: a plane per spine, + * 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/bom.test.ts b/src/derive/bom.test.ts index b25d475..d57eba1 100644 --- a/src/derive/bom.test.ts +++ b/src/derive/bom.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan, defaultPartition, defaultRack } from '../model/defaults' +import { podPlan } from './podPlan.fixture' import type { Plan, ServerGroup } from '../model/plan' import { chassisCount, @@ -63,16 +64,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) + // 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', () => { - // 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) + 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) }) }) @@ -95,6 +100,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) }) }) @@ -107,8 +114,9 @@ describe('deriveBom fabric extensions', () => { // default plan already has 6x AS7726 (2 spines + 2 exits + 2 leaves) expect(quantity(plan, 'switch-as7726')).toBe(8) + // + 4 superspines + the implicit Pod 1's own 2 spines fabric.fabricType = 'leaf-spine-superspine' - expect(quantity(plan, 'switch-as7726')).toBe(12) + expect(quantity(plan, 'switch-as7726')).toBe(14) }) }) @@ -140,18 +148,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 +232,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 +249,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', @@ -509,3 +520,17 @@ describe('control plane', () => { expect(line(plan, 'cable-mtp-trunk')!.quantity).toBeGreaterThan(central) }) }) + +describe('deriveBom leaf-spine-superspine (pods and planes)', () => { + it('orders spineCount spines per pod plus the border pod', () => { + // 6 spines (2 border + 2 per pod x 2 pods) + 2 exits + 4 leaves + 4 superspines + expect(quantity(podPlan(), 'switch-as7726')).toBe(6 + 2 + 4 + 4) + }) + + it('counts fabric links per pod and plane', () => { + // Border spines: 2 exits + 2 superspines of their plane = 4 links each, x 2 + // Pod spines: 2 leaves + 2 superspines = 4 links each, x 2 spines x 2 pods + // Routers: 2 routers x 2 exits x 2 links + expect(quantity(podPlan(), 'cable-mtp-trunk')).toBe(8 + 16 + 8) + }) +}) diff --git a/src/derive/bom.ts b/src/derive/bom.ts index 4f0e924..cc1b3d6 100644 --- a/src/derive/bom.ts +++ b/src/derive/bom.ts @@ -9,6 +9,7 @@ import { type UplinkSpeed, } from '../model/plan' import { controlPlaneLeafCount, hasOwnRack, inCentralRack } from './controlPlane' +import { hasSuperspineTier, podsOf, spinesTotal, superspinesPerPlane } from './pods' // The BOM is always derived from the Plan, never stored. Every quantity rule // lives here and gets a unit test in bom.test.ts. Every rule also records a @@ -28,8 +29,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. @@ -189,6 +192,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' { @@ -220,7 +239,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. @@ -237,33 +256,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 @@ -273,16 +292,58 @@ function addNodeUplinks(bom: BomBuilder, nodes: number, uplink: UplinkSpeed, wha } } -/** Links each spine terminates: `leafSpineLinks` per leaf, one per exit, - * superspine and storage leaf. `controlPlaneLeaves` are the leaves of a +/** One group of spines that terminate the same links: the central rack's + * spines, and with superspines each pod's. */ +export interface SpineGroup { + /** "Spines", "Central rack spines" or the pod's name. */ + name: string + /** 100G links each spine of the group terminates. */ + perSpine: number + /** What those links are, for reasons and issue messages. */ + detail: string +} + +/** Links each spine terminates, per group of spines (derive/pods.ts). A + * leaf-spine partition has one group: `leafSpineLinks` per leaf, one per + * exit and storage leaf. With superspines the central rack's spines are + * the border pod (exits, storage leaves, control-plane rack leaves) and + * every pod's spines take their pod's leaves; each spine adds one link per + * superspine of its plane. `controlPlaneLeaves` are the leaves of a * separate control-plane rack, which uplink like any other leaves. */ -export function spinePortsPerSpine(partition: Partition, controlPlaneLeaves = 0): number { +export function spinePorts(partition: Partition, controlPlaneLeaves = 0): SpineGroup[] { const { fabric } = partition - const leaves = partition.racks.reduce((n, r) => n + r.leafCount, 0) + controlPlaneLeaves - const superspines = fabric.fabricType === 'leaf-spine-superspine' ? fabric.superspineCount : 0 - return ( - leaves * fabric.leafSpineLinks + fabric.exitSwitchCount + superspines + fabric.storageLeafCount - ) + const links = fabric.leafSpineLinks + const leavesOf = (racks: Rack[]) => racks.reduce((n, r) => n + r.leafCount, 0) + const border = fabric.exitSwitchCount + fabric.storageLeafCount + controlPlaneLeaves * links + const borderDetail = + `${fabric.exitSwitchCount} exits, ${fabric.storageLeafCount} storage leaves` + + (controlPlaneLeaves ? `, ${controlPlaneLeaves} control plane leaves × ${links}` : '') + if (!hasSuperspineTier(partition)) { + const leaves = leavesOf(partition.racks) + return [ + { + name: 'Spines', + perSpine: leaves * links + border, + detail: `${leaves} leaves × ${links}, ${borderDetail}`, + }, + ] + } + const up = superspinesPerPlane(partition) + return [ + { + name: 'Central rack spines', + perSpine: border + up, + detail: `${borderDetail}, ${up} superspines of its plane`, + }, + ...podsOf(partition).map((pod) => { + const leaves = leavesOf(pod.racks) + return { + name: pod.name, + perSpine: leaves * links + up, + detail: `${leaves} leaves × ${links}, ${up} superspines of its plane`, + } + }), + ] } /** 100G links between the internet routers and the exit switches. */ @@ -291,11 +352,14 @@ export function routerLinks(partition: Partition): number { } function addFabricLinks(bom: BomBuilder, plan: Plan, partition: Partition): void { - const perSpine = spinePortsPerSpine(partition, controlPlaneLeafCount(plan, partition)) - const links = perSpine * partition.fabric.spineCount - const why = `${perSpine} links per spine × ${partition.fabric.spineCount} spines` - bom.add('sfp-100g-sr4', 2 * links, `${links} fabric links × 2 ends (${why})`) - bom.add('cable-mtp-trunk', links, `${links} fabric links (${why})`) + const { spineCount } = partition.fabric + for (const group of spinePorts(partition, controlPlaneLeafCount(plan, partition))) { + const links = group.perSpine * spineCount + if (links === 0) continue + const why = `${group.name}: ${group.perSpine} links per spine × ${spineCount} spines` + bom.add('sfp-100g-sr4', 2 * links, `${links} fabric links × 2 ends (${why})`) + bom.add('cable-mtp-trunk', links, `${links} fabric links (${why})`) + } const { routerCount, exitSwitchCount } = partition.fabric const rlinks = routerLinks(partition) @@ -315,10 +379,11 @@ 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 + + spinesTotal(partition) + fabric.exitSwitchCount + superspines + fabric.storageLeafCount + @@ -328,10 +393,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 @@ -378,7 +444,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 @@ -431,12 +503,15 @@ function addPartition(bom: BomBuilder, plan: Plan, partition: Partition): void { const prodCentral = bom.on('production').at(CENTRAL_RACK) const mgmtCentral = bom.on('management').at(CENTRAL_RACK) + const pods = podsOf(partition).length addSwitch( prodCentral, fabric.nos, fabric.spineModelId, - fabric.spineCount, - `${fabric.spineCount} spines`, + spinesTotal(partition), + pods > 0 + ? `${spinesTotal(partition)} spines (${fabric.spineCount} per pod × ${pods} pods + ${fabric.spineCount} central)` + : `${fabric.spineCount} spines`, ) if (fabric.fabricType === 'leaf-spine-superspine') { addSwitch( @@ -462,8 +537,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 +549,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 @@ -499,7 +579,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/deploy/cabling.test.ts b/src/derive/deploy/cabling.test.ts new file mode 100644 index 0000000..97eedae --- /dev/null +++ b/src/derive/deploy/cabling.test.ts @@ -0,0 +1,169 @@ +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 { podPlan } from '../podPlan.fixture' +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 +} + +/** 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) => { + 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.mgmt.spineModelId = 'switch-as4625' + const problems = deriveCabling(plan, plan.partitions[0]).problems + expect(problems.some((p) => p.startsWith('No SONiC port map for AS4625-54T'))).toBe(true) + }) + + it('cables a pod leaf only to its pod spines, and spine j only to plane j', () => { + const plan = podPlan() + const c = deriveCabling(plan, plan.partitions[0]) + const peers = (host: string) => + c.cables + .filter((x) => x.kind === 'mtp-trunk' && (x.a.host === host || x.b.host === host)) + .map((x) => (x.a.host === host ? x.b.host : x.a.host)) + .sort() + const prefix = c.prefix + expect(peers(`${prefix}-r02leaf01`)).toEqual([`${prefix}-p02spine01`, `${prefix}-p02spine02`]) + const up = (spine: string) => peers(spine).filter((h) => h.includes('superspine')) + expect(up(`${prefix}-p01spine01`)).toEqual([`${prefix}-superspine01`, `${prefix}-superspine02`]) + expect(up(`${prefix}-spine02`)).toEqual([`${prefix}-superspine03`, `${prefix}-superspine04`]) + }) + + 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..4d13c02 --- /dev/null +++ b/src/derive/deploy/cabling.ts @@ -0,0 +1,455 @@ +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' +import { hasSuperspineTier, podsOf, superspinesPerPlane } from '../pods' + +// 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. +// - Pods (leaf-spine-superspine): a pod's racks uplink to that pod's +// spines (-p01spine01 …) instead; spine j of the central rack +// and of every pod uplinks to plane j of the superspines from its top +// ports, and a superspine takes one port per pod from Ethernet0, the +// central rack's spines first. Pod spines and superspines sit in the +// central rack and hand their eth0 to the mgmt spines like the others. +// - 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 = + 'superspine' | '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 + /** Pod a spine belongs to; absent for the central rack's spines. */ + podId?: 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 (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. + // Pods (derive/pods.ts): each pod's own spines, and the superspines by + // plane. Pod spines are named after their pod's position (p01spine01). + const pods = podsOf(partition).map((pod, k) => ({ + ...pod, + spines: Array.from({ length: fabric.spineCount }, (_, i) => { + const sw = addSwitch( + `${prefix}-p${pad(k + 1)}spine${pad(i + 1)}`, + 'spine', + fabric.spineModelId, + ) + sw.podId = pod.id + return sw + }), + })) + const superspines = hasSuperspineTier(partition) + ? Array.from({ length: fabric.superspineCount }, (_, i) => + addSwitch(`${prefix}-superspine${pad(i + 1)}`, 'superspine', fabric.superspineModelId), + ) + : [] + const podSpinesOf = new Map(pods.flatMap((pod) => pod.racks.map((r) => [r.id, pod.spines]))) + + 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, uplinkSpines: podSpinesOf.get(rack.id) ?? spines } + }) + + // Leaf uplinks: the top ports of each leaf, spine by spine. + const links = fabric.leafSpineLinks + for (const { leaves, uplinkSpines } of racks) { + for (const leaf of leaves) { + const uplinks = Array.from({ length: uplinkSpines.length * links }, () => + pool(leaf).take('100G', true), + ).reverse() + uplinkSpines.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', + ) + }) + } + // Spine j of the central rack and of every pod to plane j of the + // superspines, from the spines' top ports; a superspine takes one port + // per pod, the central rack's spines first. + const perPlane = superspinesPerPlane(partition) + for (const group of [spines, ...pods.map((pod) => pod.spines)]) { + group.forEach((spine, j) => { + for (const superspine of superspines.slice(j * perPlane, (j + 1) * perPlane)) { + const spinePort = pool(spine).take('100G', true) + const superPort = pool(superspine).take('100G') + spine.bgpPorts.push(spinePort) + superspine.bgpPorts.push(superPort) + cable( + { host: spine.name, port: spinePort }, + { host: superspine.name, port: superPort }, + '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, + ...pods.flatMap((pod) => pod.spines), + ...superspines, + ] + 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 +} diff --git a/src/derive/deploy/inventory.test.ts b/src/derive/deploy/inventory.test.ts new file mode 100644 index 0000000..1f0404c --- /dev/null +++ b/src/derive/deploy/inventory.test.ts @@ -0,0 +1,218 @@ +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 { deriveCabling } from './cabling' +import { deriveInventory } from './inventory' +import { podPlan } from '../podPlan.fixture' + +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]), + ['a leaf-spine-superspine plan with two pods', podPlan], +] + +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('lists every switch of the cable plan as a host', () => { + const names = new Set(hosts.map((h) => h.name)) + const cabled = plan.partitions.flatMap((p) => + deriveCabling(plan, p).switches.map((s) => s.name), + ) + expect(cabled.length).toBeGreaterThan(0) + expect(cabled.filter((n) => !names.has(n))).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('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) + }) + + 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('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 + const of = (re: RegExp) => [...new Set(hosts.filter((h) => re.test(h.name)).map(asn))] + expect(of(/-superspine\d+$/)).toHaveLength(1) + const central = of(/-spine\d+$/) + const podA = of(/-p01spine\d+$/) + const podB = of(/-p02spine\d+$/) + expect([central, podA, podB].map((a) => a.length)).toEqual([1, 1, 1]) + expect(new Set([central[0], podA[0], podB[0], of(/-superspine\d+$/)[0]]).size).toBe(4) + }) + + it('refuses a plan it cannot generate in full', () => { + const plan = createEmptyPlan() + plan.partitions[0].fabric.mgmt.layer = 'l2' + expect(deriveInventory(plan).problems).toContain( + `${plan.partitions[0].name}: An L2 management network is 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('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) + 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..8b3cdf5 --- /dev/null +++ b/src/derive/deploy/inventory.ts @@ -0,0 +1,559 @@ +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 { podsOf } from '../pods' +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, …, 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. +// +// 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. +// - 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 +// 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). With pods, RFC 7938 section 5.2.1: the superspines +// share 4210000002, the spines of pod k share 4210000010 + k, and the +// central rack's spines keep 4210000000. +// +// 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)', + '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', + ], + }, + { + 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, + superspines: 4210000002, + podSpines: 4210000010, + storageLeaf: 4210000100, + 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 +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))) +} + +/** 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( + 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 superspines = byRole('superspine') + const podIndex = new Map(podsOf(partition).map((pod, k) => [pod.id, k])) + 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, ...superspines, ...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() + // 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) + 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) }), + ) + relayNets.push({ net: part, firstFree: attached.length + 2, comment: `${ms.name} mgmt` }) + }) + } + 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)) + 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) + 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) + 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: + 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, (sw) => + sw.podId ? { sonic_config_asn: ASN.podSpines + (podIndex.get(sw.podId) ?? 0) } : {}, + ), + }, + [g('superspines')]: { + vars: { sonic_config_asn: ASN.superspines, sonic_config_frr_l2vpn_evpn: true }, + hosts: hosts(superspines, () => ({})), + }, + [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'], + 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) => { + 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], + dhcp_subnets: dhcpSubnets(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( + [ + 'superspines', + '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)) { + for (const generic of [role, ...(ROLE_ALIASES[role] ?? [])]) { + roleChildren[generic] = { ...(roleChildren[generic] ?? {}), [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/derive/ip/ipPlan.test.ts b/src/derive/ip/ipPlan.test.ts index 7f0b9be..9075c00 100644 --- a/src/derive/ip/ipPlan.test.ts +++ b/src/derive/ip/ipPlan.test.ts @@ -5,6 +5,7 @@ import type { Plan } from '../../model/plan' import { templates } from '../../model/templates' import { formatCidr, formatIp } from './cidr' import { deriveIpPlan, largestCluster, type FamilyResult, type MetricId } from './ipPlan' +import { podPlan } from '../podPlan.fixture' // The expectations below are the numbers of the address-planning // spreadsheet (ip_calc.ods) for the three layouts the presets mirror. @@ -132,6 +133,35 @@ describe('invalid input', () => { }) describe('infrastructure ranges', () => { + it('gives the spines of every pod a loopback and a mgmt address', () => { + const subnets = deriveIpPlan(podPlan()).infra.partitions[0].subnets + const loopbacks = subnets.find((s) => s.purpose === 'Underlay loopbacks')! + // 4 leaves + 6 spines + 2 exits + 4 superspines + 4 firewalls + expect(loopbacks.needed).toBe(4 + 6 + 2 + 4 + 4) + const central = subnets.find((s) => s.purpose === 'Management' && s.scope === 'Central rack')! + expect(central.detail).toContain('14 central switch mgmt interfaces') + }) + + 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..e02237e 100644 --- a/src/derive/ip/ipPlan.ts +++ b/src/derive/ip/ipPlan.ts @@ -1,6 +1,7 @@ import type { IpFamily, IpFamilyKey, IpInfra } from '../../model/ipPlan' import { mgmtDeviceCount, type Partition, type Plan } from '../../model/plan' import { partitionNodes, rackNodes } from '../nodes' +import { spinesTotal } from '../pods' import { contains, fitPrefix, @@ -29,6 +30,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 +40,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. @@ -446,14 +453,14 @@ export function partitionInfraNeeds(partition: Partition, infra: IpInfra): Infra const nodes = partitionNodes(partition).total const out: (InfraSubnet | null)[] = [] - const speakers = - leaves + fabric.spineCount + fabric.exitSwitchCount + superspines + fabric.storageLeafCount + const spines = spinesTotal(partition) + const speakers = leaves + spines + fabric.exitSwitchCount + superspines + fabric.storageLeafCount out.push( hostSubnet( 'Underlay loopbacks', 'Partition', speakers + infra.firewallsPerPartition, - `${leaves} leaves + ${fabric.spineCount} spines + ${fabric.exitSwitchCount} exits` + + `${leaves} leaves + ${spines} spines + ${fabric.exitSwitchCount} exits` + (superspines ? ` + ${superspines} superspines` : '') + (fabric.storageLeafCount ? ` + ${fabric.storageLeafCount} storage leaves` : '') + ` + ${infra.firewallsPerPartition} firewalls`, @@ -461,6 +468,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)', @@ -472,7 +489,7 @@ export function partitionInfraNeeds(partition: Partition, infra: IpInfra): Infra ) const centralSwitches = - fabric.spineCount + fabric.exitSwitchCount + superspines + fabric.storageLeafCount + mgmtCount + spines + fabric.exitSwitchCount + superspines + fabric.storageLeafCount + mgmtCount const centralServers = 2 * mgmtCount + fabric.routerCount const centralDetail = `${centralSwitches} central switch mgmt interfaces + ${mgmtCount} mgmt servers × 2 (BMC, interface)` + @@ -525,9 +542,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) diff --git a/src/derive/podPlan.fixture.ts b/src/derive/podPlan.fixture.ts new file mode 100644 index 0000000..84d3218 --- /dev/null +++ b/src/derive/podPlan.fixture.ts @@ -0,0 +1,21 @@ +import { createEmptyPlan, defaultRack } from '../model/defaults' +import type { Plan } from '../model/plan' + +/** A leaf-spine-superspine plan for tests: two pods of one rack each + * (2 leaves, 8 workers on 2x25G), 2 spines per pod, 4 superspines (2 per + * plane), 2 exits, 2 routers. */ +export function podPlan(): Plan { + const plan = createEmptyPlan() + const p = plan.partitions[0] + p.fabric.fabricType = 'leaf-spine-superspine' + p.fabric.spineCount = 2 + p.fabric.superspineCount = 4 + p.fabric.routerCount = 2 + p.pods = [ + { id: 'pa', name: 'Pod A' }, + { id: 'pb', name: 'Pod B' }, + ] + p.racks[0].podId = 'pa' + p.racks.push({ ...defaultRack('Rack 2'), podId: 'pb' }) + return plan +} diff --git a/src/derive/pods.test.ts b/src/derive/pods.test.ts new file mode 100644 index 0000000..2c88f92 --- /dev/null +++ b/src/derive/pods.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vitest' +import { createEmptyPlan, defaultRack } from '../model/defaults' +import type { Partition } from '../model/plan' +import { podsOf, spinesTotal, superspinesPerPlane } from './pods' + +function superspinePartition(): Partition { + const p = createEmptyPlan().partitions[0] + p.fabric.fabricType = 'leaf-spine-superspine' + p.fabric.spineCount = 2 + p.fabric.superspineCount = 4 + return p +} + +describe('podsOf', () => { + it('has no pods in a leaf-spine fabric', () => { + const p = createEmptyPlan().partitions[0] + p.pods = [{ id: 'a', name: 'A' }] + expect(podsOf(p)).toEqual([]) + }) + + it('puts every rack into an implicit Pod 1 when none is defined', () => { + const p = superspinePartition() + const pods = podsOf(p) + expect(pods.map((x) => x.name)).toEqual(['Pod 1']) + expect(pods[0].racks.map((r) => r.id)).toEqual(p.racks.map((r) => r.id)) + }) + + it('assigns racks by podId, and racks with an unknown pod to the first', () => { + const p = superspinePartition() + p.pods = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ] + const r2 = { ...defaultRack('Rack 2'), podId: 'b' } + const r3 = { ...defaultRack('Rack 3'), podId: 'gone' } + p.racks.push(r2, r3) + const pods = podsOf(p) + expect(pods.map((x) => x.racks.map((r) => r.name))).toEqual([['Rack 1', 'Rack 3'], ['Rack 2']]) + }) +}) + +describe('spine counts', () => { + it('counts the border spines plus spineCount per pod', () => { + const p = superspinePartition() + p.pods = [ + { id: 'a', name: 'A' }, + { id: 'b', name: 'B' }, + ] + expect(spinesTotal(p)).toBe(2 * 3) + const leafSpine = createEmptyPlan().partitions[0] + expect(spinesTotal(leafSpine)).toBe(leafSpine.fabric.spineCount) + }) + + it('gives each plane superspineCount / spineCount superspines', () => { + const p = superspinePartition() + expect(superspinesPerPlane(p)).toBe(2) + p.fabric.superspineCount = 5 + expect(superspinesPerPlane(p)).toBe(2) + expect(superspinesPerPlane(createEmptyPlan().partitions[0])).toBe(0) + }) +}) diff --git a/src/derive/pods.ts b/src/derive/pods.ts new file mode 100644 index 0000000..9b94b0a --- /dev/null +++ b/src/derive/pods.ts @@ -0,0 +1,56 @@ +import type { Partition, Rack } from '../model/plan' + +// 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 { + id: string + name: string + racks: Rack[] +} + +/** Stands in for the pod list of a superspine partition that has none. */ +export const IMPLICIT_POD = { id: 'pod-1', name: 'Pod 1' } as const + +export function hasSuperspineTier(partition: Partition): boolean { + return partition.fabric.fabricType === 'leaf-spine-superspine' +} + +/** The compute pods with their racks: racks whose podId names no pod go to + * the first. Empty for a leaf-spine partition. */ +export function podsOf(partition: Partition): PodRacks[] { + if (!hasSuperspineTier(partition)) return [] + const pods = partition.pods.length > 0 ? partition.pods : [IMPLICIT_POD] + const known = new Set(pods.map((p) => p.id)) + return pods.map((pod, i) => ({ + id: pod.id, + name: pod.name, + racks: partition.racks.filter((r) => + r.podId && known.has(r.podId) ? r.podId === pod.id : i === 0, + ), + })) +} + +/** Every spine of the partition: the border pod's plus spineCount per + * compute pod with superspines, else just the central rack's. */ +export function spinesTotal(partition: Partition): number { + return partition.fabric.spineCount * (1 + podsOf(partition).length) +} + +/** Superspines each plane holds: every pod's spine j connects to all of + * plane j. A superspine count that does not divide by the spine count + * leaves the remainder unconnected (validation reports it). */ +export function superspinesPerPlane(partition: Partition): number { + const { spineCount, superspineCount } = partition.fabric + if (!hasSuperspineTier(partition) || spineCount === 0) return 0 + return Math.floor(superspineCount / spineCount) +} diff --git a/src/derive/rackLayout.test.ts b/src/derive/rackLayout.test.ts index b44305e..1b3a643 100644 --- a/src/derive/rackLayout.test.ts +++ b/src/derive/rackLayout.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan, withRackKind } from '../model/defaults' +import { podPlan } from './podPlan.fixture' import { deriveRackLayout, physicalRackCount } from './rackLayout' import { validatePlan } from './validate' @@ -10,12 +11,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,9 +126,19 @@ 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) }) }) + +describe('central rack with pods', () => { + it('holds the spines of every pod, not only the central ones', () => { + const central = deriveRackLayout(podPlan())[0].racks[0] + // 2 central spines + 2 per pod x 2 pods + expect(central.slots.filter((s) => s.label.startsWith('Spine'))).toHaveLength(6) + expect(central.slots.filter((s) => s.label.startsWith('Superspine'))).toHaveLength(4) + }) +}) diff --git a/src/derive/rackLayout.ts b/src/derive/rackLayout.ts index 82e9613..1d849cb 100644 --- a/src/derive/rackLayout.ts +++ b/src/derive/rackLayout.ts @@ -8,6 +8,7 @@ import { } from '../model/plan' import { chassisCount } from './bom' import { hasOwnRack, inCentralRack } from './controlPlane' +import { spinesTotal } from './pods' // Derives physical rack elevations (which device sits in which height // units) from the Plan. Like the BOM, the layout is always computed, never @@ -196,10 +197,12 @@ export function deriveRackLayout(plan: Plan): PartitionRackLayout[] { ...(fabric.fabricType === 'leaf-spine-superspine' ? device('Superspine', fabric.superspineModelId, fabric.superspineCount, 'network') : []), - ...device('Spine', fabric.spineModelId, fabric.spineCount, 'network'), + // Every pod's spines sit in the central rack too (derive/pods.ts). + ...device('Spine', fabric.spineModelId, spinesTotal(partition), 'network'), ...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..9329cd1 100644 --- a/src/derive/topology.test.ts +++ b/src/derive/topology.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { createEmptyPlan, defaultPartition, withRackKind } from '../model/defaults' +import { createEmptyPlan, defaultPartition, defaultRack, withRackKind } from '../model/defaults' import type { Plan } from '../model/plan' import { CONTROL_PLANE_RACK_ID, deriveTopology, filterTopology } from './topology' @@ -28,6 +28,75 @@ 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('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('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) + 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', () => { @@ -67,7 +136,9 @@ describe('deriveTopology', () => { const spineSuper = graph.links.filter((l) => partition.central.superspines.some((n) => n.id === l.to), ) - expect(spineSuper).toHaveLength(4) // 2 spines x 2 superspines + // 2 spines per pod, 2 superspines = 1 per plane: the border pod and the + // implicit Pod 1 each link 2 spines x 1 superspine + expect(spineSuper).toHaveLength(4) }) it('attaches the internet network at the routers, or the exits without routers', () => { @@ -295,3 +366,86 @@ describe('control plane in the topology', () => { expect(filterTopology(graph, 'production').partitions[0].controlPlane).toBeDefined() }) }) + +describe('leaf-spine-superspine fabric (RFC 7938 5-stage Clos)', () => { + /** Two pods of one rack each, 2 spines per pod, 4 superspines (2 per plane). */ + function podPlan(): Plan { + const plan = createEmptyPlan() + const p = plan.partitions[0] + p.fabric.fabricType = 'leaf-spine-superspine' + p.fabric.spineCount = 2 + p.fabric.superspineCount = 4 + p.pods = [ + { id: 'pa', name: 'Pod A' }, + { id: 'pb', name: 'Pod B' }, + ] + p.racks[0].podId = 'pa' + p.racks.push({ ...defaultRack('Rack 2'), podId: 'pb' }) + return plan + } + const ends = (graph: ReturnType, id: string) => + graph.links + .filter((l) => l.network === 'production' && (l.from === id || l.to === id)) + .map((l) => (l.from === id ? l.to : l.from)) + .sort() + + it('gives every pod its own spines and marks each rack with its pod', () => { + const p = deriveTopology(podPlan()).partitions[0] + expect(p.pods.map((pod) => [pod.name, pod.spines.length])).toEqual([ + ['Pod A', 2], + ['Pod B', 2], + ]) + expect(p.racks.map((r) => r.podId)).toEqual(['pa', 'pb']) + expect(p.central.spines).toHaveLength(2) + }) + + it('uplinks a leaf only to the spines of its own pod', () => { + const graph = deriveTopology(podPlan()) + const p = graph.partitions[0] + const podB = p.pods[1].spines.map((s) => s.id).sort() + for (const leaf of p.racks[1].leaves) expect(ends(graph, leaf.id)).toEqual(podB) + }) + + it('connects spine j of every pod, and of the border pod, to plane j only', () => { + const graph = deriveTopology(podPlan()) + const p = graph.partitions[0] + const ss = p.central.superspines.map((s) => s.id) + const plane = (j: number) => ss.slice(2 * j, 2 * j + 2).sort() + for (const spines of [p.central.spines, ...p.pods.map((pod) => pod.spines)]) { + spines.forEach((spine, j) => { + const up = ends(graph, spine.id).filter((id) => ss.includes(id)) + expect(up, spine.id).toEqual(plane(j)) + }) + } + }) + + it('attaches exits and storage leaves to the border spines only', () => { + const plan = podPlan() + plan.partitions[0].fabric.storageLeafCount = 1 + const graph = deriveTopology(plan) + const p = graph.partitions[0] + const border = p.central.spines.map((s) => s.id).sort() + for (const n of [...p.central.exits, ...p.storageLeaves]) { + expect( + ends(graph, n.id).filter((id) => id.includes('spine')), + n.id, + ).toEqual(border) + } + }) + + 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([]) + // 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', () => { + expect(deriveTopology(createEmptyPlan()).partitions[0].pods).toEqual([]) + }) +}) diff --git a/src/derive/topology.ts b/src/derive/topology.ts index 73e91e1..1580206 100644 --- a/src/derive/topology.ts +++ b/src/derive/topology.ts @@ -1,5 +1,6 @@ import { catalog } from '../model/catalog' import { controlPlaneHost } from './controlPlane' +import { podsOf, superspinesPerPlane } from './pods' import { physicalRacks, type RackPosition } from './rackLayout' import { mgmtDeviceCount, @@ -25,6 +26,7 @@ export type TopoNodeKind = | 'mgmt-spine' | 'mgmt-leaf' | 'mgmt-server' + | 'mgmt-firewall' | 'server-group' | 'control-plane' | 'external-network' @@ -36,6 +38,8 @@ export interface TopoNode { sublabel?: string /** For external networks: what kind of network it is. */ networkKind?: ExternalNetwork['kind'] + /** For server groups: the plan ServerGroup it draws. */ + groupId?: string } export type LinkNetwork = 'production' | 'management' | 'external' @@ -46,6 +50,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 @@ -59,10 +67,20 @@ export interface TopoRack { leaves: TopoNode[] mgmtLeaves: TopoNode[] serverGroups: TopoNode[] + /** Pod of a leaf-spine-superspine partition the rack belongs to. */ + podId?: string } -/** Spines, exits, superspines, mgmt spines and mgmt servers all live in the - * partition's central rack. */ +/** A compute pod of a leaf-spine-superspine partition: its spines, which + * its racks' leaves uplink to (derive/pods.ts). */ +export interface TopoPod { + id: string + name: string + spines: TopoNode[] +} + +/** 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 +90,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 { @@ -80,6 +101,9 @@ export interface TopoPartition { central: TopoCentralRack storageLeaves: TopoNode[] racks: TopoRack[] + /** Compute pods; empty for a leaf-spine partition, whose racks uplink to + * the central rack's spines. */ + pods: TopoPod[] /** The control plane, when this partition carries it: a KaaS cluster * (`managed`, drawn as a capsule like an external network) or on-prem * nodes in the central rack. On-prem nodes in a rack of their own are a @@ -139,7 +163,14 @@ 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', + mgmtPort: true, + }) } } @@ -169,6 +200,7 @@ function deriveRack(partition: Partition, rack: Rack, links: TopoLink[]): TopoRa kind: 'server-group', label: `${count} × ${group.role}`, sublabel: `${part(group.modelId)}, ${group.uplink}`, + groupId: group.id, }) // Server groups have no edges on purpose: their uplinks to the leaves // and their BMC links to the mgmt leaf are one edge per group and @@ -225,6 +257,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( @@ -242,16 +281,29 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition } } + // Pods (RFC 7938 5-stage Clos): spine j of the border pod and of every + // compute pod connects to plane j of the superspines only. + const pods: TopoPod[] = podsOf(partition).map((pod) => ({ + id: pod.id, + name: pod.name, + spines: tier(`${p}${pod.id}/spine`, 'spine', 'Spine', fabric.spineModelId, fabric.spineCount), + })) + const perPlane = superspinesPerPlane(partition) + for (const spines of [central.spines, ...pods.map((pod) => pod.spines)]) { + spines.forEach((spine, j) => { + for (const superspine of central.superspines.slice(j * perPlane, (j + 1) * perPlane)) { + links.push({ + from: spine.id, + to: superspine.id, + count: 1, + speed: '100G', + network: 'production', + }) + } + }) + } + for (const spine of central.spines) { - for (const superspine of central.superspines) { - links.push({ - from: spine.id, - to: superspine.id, - count: 1, - speed: '100G', - network: 'production', - }) - } for (const exit of central.exits) { links.push({ from: exit.id, to: spine.id, count: 1, speed: '100G', network: 'production' }) } @@ -264,12 +316,28 @@ 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' }) - } } - for (const mgmtServer of central.mgmtServers) { - for (const mgmtSpine of central.mgmtSpines) { + // 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, ...pods.flatMap((pod) => pod.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', + mgmtPort: true, + }) + }) + central.mgmtServers.forEach((mgmtServer, i) => { + const mgmtSpine = mgmtSpines[i] + if (mgmtSpine) { links.push({ from: mgmtServer.id, to: mgmtSpine.id, @@ -278,13 +346,40 @@ 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', + mgmtPort: true, + }) + } + }) - const racks = partition.racks.flatMap((rack) => deriveRack(partition, rack, links)) + const podOf = new Map(podsOf(partition).flatMap((pod) => pod.racks.map((r) => [r.id, pod.id]))) + const racks = partition.racks.flatMap((rack) => + deriveRack(partition, rack, links).map((r) => ({ ...r, podId: podOf.get(rack.id) })), + ) for (const rack of racks) { + const uplinkSpines = pods.find((pod) => pod.id === rack.podId)?.spines ?? central.spines for (const leaf of rack.leaves) { - for (const spine of central.spines) { + for (const spine of uplinkSpines) { links.push({ from: leaf.id, to: spine.id, @@ -313,6 +408,7 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition central, storageLeaves, racks, + pods, externalNetworks: [], } } @@ -469,7 +565,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,8 +597,11 @@ 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), + // 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' ? [] @@ -522,7 +626,9 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo ...p.central.exits, ...p.central.mgmtSpines, ...p.central.mgmtServers, + ...p.central.mgmtFirewalls, ...p.storageLeaves, + ...p.pods.flatMap((pod) => pod.spines), ...(p.controlPlane ? [p.controlPlane.node] : []), ...p.racks.flatMap((r) => [...r.leaves, ...r.mgmtLeaves, ...r.serverGroups]), ]) { diff --git a/src/derive/validate.test.ts b/src/derive/validate.test.ts index e02de5a..c066ab4 100644 --- a/src/derive/validate.test.ts +++ b/src/derive/validate.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan, defaultRack, withRackKind } from '../model/defaults' +import { podPlan } from './podPlan.fixture' import type { Plan } from '../model/plan' import { countIssues, @@ -37,8 +38,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', () => { @@ -225,10 +227,11 @@ describe('non-blocking fabric', () => { const partition = plan.partitions[0] partition.fabric.nonBlocking = true partition.fabric.fabricType = 'leaf-spine-superspine' - partition.fabric.superspineCount = 1 // 2 leaves x 100 down, 100 up - const issue = validatePlan(plan).find((i) => i.message.includes('Spine tier is oversubscribed')) - expect(issue?.message).toContain('needs 2 superspines (now 1)') + // 2 superspines on 2 spines: 1 per plane, so 2 leaves x 100 down, 100 up partition.fabric.superspineCount = 2 + const issue = validatePlan(plan).find((i) => i.message.includes('Spine tier is oversubscribed')) + expect(issue?.message).toContain('needs 4 superspines (now 2)') + partition.fabric.superspineCount = 4 expect(errors(plan).some((m) => m.includes('Spine tier'))).toBe(false) }) }) @@ -434,8 +437,75 @@ describe('control plane validation', () => { const plan = onPrem({ placement: 'own-rack' }) plan.partitions[0].fabric.leafSpineLinks = 10 plan.controlPlane.rack.leafCount = 3 - // The rack's own two leaves plus the control plane rack's three. + // The rack's own two leaves plus the control plane rack's three, x 10, + // plus 2 exits: 52 ports. const spine = validatePlan(plan).find((i) => i.message.includes('Spine capacity')) - expect(spine?.message).toContain('5 leaves × 10') + expect(spine?.message).toContain('needs 52 100G ports') + expect(spine?.message).toContain('3 control plane leaves × 10') + }) +}) + +describe('leaf-spine-superspine validation', () => { + const messages = (plan: ReturnType) => validatePlan(plan).map((i) => i.message) + + it('accepts the pod plan apart from the design note', () => { + const errors = validatePlan(podPlan()).filter((i) => i.severity === 'error') + expect(errors).toEqual([]) + expect(messages(podPlan()).some((m) => m.includes('RFC 7938'))).toBe(true) + }) + + it('needs superspines that split evenly into one plane per spine', () => { + const plan = podPlan() + plan.partitions[0].fabric.superspineCount = 5 + expect(messages(plan).some((m) => m.includes('one plane per spine'))).toBe(true) + }) + + it('checks each superspine has a port for every pod', () => { + const plan = podPlan() + const p = plan.partitions[0] + p.pods = Array.from({ length: 32 }, (_, i) => ({ id: `p${i}`, name: `Pod ${i + 1}` })) + // 32 compute pods + the border pod = 33 ports per superspine > 32 + expect(messages(plan).some((m) => m.startsWith('Superspine capacity exceeded'))).toBe(true) + }) + + it('warns about a pod without racks', () => { + const plan = podPlan() + plan.partitions[0].pods.push({ id: 'pc', name: 'Pod C' }) + 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 d6ac61c..3593be8 100644 --- a/src/derive/validate.ts +++ b/src/derive/validate.ts @@ -14,8 +14,9 @@ import { requiredSuperspines, spineBandwidth, } from './bandwidth' -import { deriveBom, mgmtUplinkSpeed, rackBmcPorts, spinePortsPerSpine } from './bom' +import { deriveBom, mgmtUplinkSpeed, nodeSwitchPorts, rackBmcPorts, spinePorts } from './bom' import { controlPlaneLeafCount, controlPlaneSwitchPorts, inCentralRack } from './controlPlane' +import { podsOf, spinesTotal } from './pods' import { validateIpPlan } from './ip/validateIp' import { deriveRackLayout, formatPower } from './rackLayout' @@ -108,16 +109,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 @@ -289,6 +287,13 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v if (fabric.fabricType === 'leaf-spine-superspine') { checkSwitchRole(issues, inAdvanced, fabric.superspineModelId, 'superspine', 'Superspine') + report( + issues, + scope, + 'warning', + 'Leaf-spine-superspine is a planning design after RFC 7938 (pods and planes), not a ' + + 'metal-stack reference architecture: validate it in a lab before ordering.', + ) if (fabric.superspineCount === 0) { report( issues, @@ -296,6 +301,55 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v 'error', 'Fabric type is leaf-spine-superspine but the superspine count is 0.', ) + } else if (fabric.spineCount > 0 && fabric.superspineCount % fabric.spineCount !== 0) { + const perPlane = Math.max(1, Math.round(fabric.superspineCount / fabric.spineCount)) + report( + issues, + inAdvanced, + 'error', + `Superspines must split evenly into one plane per spine: ${fabric.superspineCount} ` + + `superspines on ${fabric.spineCount} spines per pod leave ` + + `${fabric.superspineCount % fabric.spineCount} unconnected. Use a multiple of ` + + `${fabric.spineCount}, e.g. ${perPlane * fabric.spineCount}.`, + ) + } + 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')) { + report( + issues, + inAdvanced, + 'error', + `Superspine capacity exceeded: each superspine needs one 100G port per pod ` + + `(${podPorts} with the central rack), but ${itemLabel(fabric.superspineModelId)} ` + + `has ${portCount(superspine, '100G')}.`, + ) } } else if (fabric.superspineCount > 0) { report( @@ -313,30 +367,26 @@ function validatePartition(issues: Issue[], plan: Plan, partition: Partition): v report(issues, scope, 'error', 'Partition has racks but no spines.') } - // Spine port budget: leafSpineLinks 100G ports per leaf, one per storage - // leaf, exit switch and (if present) superspine. + // Spine port budget, per group of spines (spinePorts in bom.ts): the + // central rack's, and with superspines each pod's. const spine = catalog[fabric.spineModelId] if (spine && fabric.spineCount > 0) { - const cpLeaves = controlPlaneLeafCount(plan, partition) - const leaves = partition.racks.reduce((n, r) => n + r.leafCount, 0) + cpLeaves - const superspines = fabric.fabricType === 'leaf-spine-superspine' ? fabric.superspineCount : 0 - const needed = spinePortsPerSpine(partition, cpLeaves) const available = portCount(spine, '100G') - if (needed > available) { - report( - issues, - scope, - 'error', - `Spine capacity exceeded: each spine needs ${needed} 100G ports ` + - `(${leaves} leaves × ${fabric.leafSpineLinks}, ${fabric.storageLeafCount} storage leaves, ` + - `${fabric.exitSwitchCount} exits, ${superspines} superspines), ` + - `but ${itemLabel(fabric.spineModelId)} has ${available}.`, - ) + for (const group of spinePorts(partition, controlPlaneLeafCount(plan, partition))) { + if (group.perSpine > available) { + report( + issues, + scope, + 'error', + `Spine capacity exceeded (${group.name}): each spine needs ${group.perSpine} 100G ports ` + + `(${group.detail}), but ${itemLabel(fabric.spineModelId)} has ${available}.`, + ) + } } } // 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, @@ -344,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}).`, ) } @@ -370,27 +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 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. + // 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 = - mgmtDeviceCount(fabric.mgmt) + - fabric.spineCount + + 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}.`, ) } diff --git a/src/io/ansible.ts b/src/io/ansible.ts new file mode 100644 index 0000000..480779d --- /dev/null +++ b/src/io/ansible.ts @@ -0,0 +1,49 @@ +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).', + '', + ...(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}`)]), + ] + 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 } +} diff --git a/src/io/json.test.ts b/src/io/json.test.ts index eb86608..49f8971 100644 --- a/src/io/json.test.ts +++ b/src/io/json.test.ts @@ -36,6 +36,29 @@ describe('plan JSON round-trip', () => { }) }) +describe('format version 2 (pods)', () => { + it('reads a version 1 file with superspines as one pod holding every rack', () => { + const plan = createEmptyPlan() + plan.partitions[0].fabric.fabricType = 'leaf-spine-superspine' + plan.partitions[0].fabric.superspineCount = 2 + const raw = JSON.parse(exportPlanJson(plan)) as Record + raw.schemaVersion = 1 + const partitions = raw.partitions as Record[] + delete partitions[0].pods + const imported = importPlanJson(JSON.stringify(raw)) + expect(imported.schemaVersion).toBe(2) + expect(imported.partitions[0].pods).toEqual([]) + expect(imported.partitions[0].racks[0].podId).toBeUndefined() + }) + + it('round-trips pods and the racks assigned to them', () => { + const plan = createEmptyPlan() + plan.partitions[0].pods = [{ id: 'p1', name: 'Pod A' }] + plan.partitions[0].racks[0].podId = 'p1' + expect(importPlanJson(exportPlanJson(plan))).toEqual(plan) + }) +}) + describe('files with shapes from before the format version', () => { /** A plan as an early build wrote it: zones instead of partitions, a * management server group, and none of the later fields. */ 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, '') +} 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 c3e7a7a..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', @@ -353,6 +366,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. @@ -656,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 diff --git a/src/model/defaults.test.ts b/src/model/defaults.test.ts index 0df7bc3..f98a14e 100644 --- a/src/model/defaults.test.ts +++ b/src/model/defaults.test.ts @@ -1,10 +1,13 @@ import { describe, expect, it } from 'vitest' import { createEmptyPlan, + defaultPartition, newRack, nextGroupName, nextRackNames, physicalRackNames, + withPodAdded, + withPodRemoved, withRackKind, } from './defaults' @@ -58,3 +61,32 @@ describe('withRackKind', () => { expect(withRackKind(p, p.racks[0], 'single')).toBe(p.racks[0]) }) }) + +describe('pods', () => { + it('turns the implicit Pod 1 into a real one when the first pod is added', () => { + const p = defaultPartition('Partition 1') + const added = withPodAdded(p) + expect(added.pods.map((pod) => pod.name)).toEqual(['Pod 1', 'Pod 2']) + // Racks without a pod stay in the first one. + expect(added.racks[0].podId).toBeUndefined() + }) + + it('numbers a new pod above the highest in use', () => { + const p = { ...defaultPartition('P'), pods: [{ id: 'a', name: 'Pod 3' }] } + expect(withPodAdded(p).pods.map((pod) => pod.name)).toEqual(['Pod 3', 'Pod 4']) + }) + + it('moves the racks of a removed pod back to the first pod', () => { + const p = { + ...defaultPartition('P'), + pods: [ + { id: 'a', name: 'Pod 1' }, + { id: 'b', name: 'Pod 2' }, + ], + } + p.racks[0].podId = 'b' + const removed = withPodRemoved(p, 'b') + expect(removed.pods.map((pod) => pod.id)).toEqual(['a']) + expect(removed.racks[0].podId).toBeUndefined() + }) +}) diff --git a/src/model/defaults.ts b/src/model/defaults.ts index f20d63f..28b5965 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, @@ -130,6 +131,7 @@ export function defaultPartition(name: string): Partition { name, fabric: defaultFabric(), racks: [defaultRack('Rack 1')], + pods: [], rackDefaults: { ...DEFAULT_RACK_DEFAULTS }, } } @@ -156,3 +158,21 @@ export function createEmptyPlan(): Plan { ], } } + +/** Adds a pod, numbered above the highest "Pod " in use. Without pods a + * superspine partition has an implicit Pod 1 holding every rack; the first + * add makes that one real, so the racks stay where they are. */ +export function withPodAdded(partition: Partition): Partition { + const pods = partition.pods.length > 0 ? partition.pods : [{ id: id(), name: 'Pod 1' }] + const highest = Math.max(0, ...pods.map((p) => Number(/^Pod (\d+)$/.exec(p.name)?.[1] ?? 0))) + return { ...partition, pods: [...pods, { id: id(), name: `Pod ${highest + 1}` }] } +} + +/** Removes a pod; its racks fall back to the first pod. */ +export function withPodRemoved(partition: Partition, podId: string): Partition { + return { + ...partition, + pods: partition.pods.filter((p) => p.id !== podId), + racks: partition.racks.map((r) => (r.podId === podId ? { ...r, podId: undefined } : r)), + } +} diff --git a/src/model/migrate.ts b/src/model/migrate.ts index 0e70449..83db1ec 100644 --- a/src/model/migrate.ts +++ b/src/model/migrate.ts @@ -6,19 +6,23 @@ // A file from a newer version is refused with a readable message rather // than a schema error. // -// The current format is version 1 and needs no migration. Fields added -// with a Zod `.default()` stay compatible on their own; only a change that -// breaks older files bumps SCHEMA_VERSION and adds a function to -// MIGRATIONS, keyed by the version it upgrades *from*: +// Fields added with a Zod `.default()` stay compatible on their own; a +// change that breaks older files, or that an older planner would silently +// drop, bumps SCHEMA_VERSION and adds a function to MIGRATIONS, keyed by +// the version it upgrades *from*. // -// export const SCHEMA_VERSION = 2 -// const MIGRATIONS = { 1: (raw) => ({ ...raw, /* reshape here */ }) } +// Version 2 adds pods (Partition.pods, Rack.podId) for leaf-spine-superspine +// fabrics. A version 1 file needs no reshaping: without pods, every rack of +// a superspine partition is in one implicit pod. The version is bumped so +// that a version 1 planner refuses a file with pods instead of dropping them. -export const SCHEMA_VERSION = 1 +export const SCHEMA_VERSION = 2 type RawPlan = Record -const MIGRATIONS: Record RawPlan> = {} +const MIGRATIONS: Record RawPlan> = { + 1: (raw) => raw, +} /** Shapes from before the format version was enforced: `zones` became * `partitions`, `attachedZoneId` became `attachedPartitionId`, and server diff --git a/src/model/moveChassis.test.ts b/src/model/moveChassis.test.ts new file mode 100644 index 0000000..2346c0e --- /dev/null +++ b/src/model/moveChassis.test.ts @@ -0,0 +1,160 @@ +import { describe, expect, it } from 'vitest' +import { createEmptyPlan, defaultRack } from './defaults' +import { moveChassis, type MoveChassisArgs } from './moveChassis' +import type { Plan, ServerGroup } from './plan' + +function fixture(): { + plan: Plan + args: MoveChassisArgs + source: ServerGroup + target: Plan['partitions'][number]['racks'][number] +} { + const plan = createEmptyPlan() + const partition = plan.partitions[0] + const sourceRack = partition.racks[0] + const target = defaultRack('Rack 2', 'single', partition.rackDefaults) + target.servers = [] + partition.racks.push(target) + const source = sourceRack.servers[0] + return { + plan, + source, + target, + args: { + partitionId: partition.id, + fromRackId: sourceRack.id, + groupId: source.id, + nodes: 3, + toRackId: target.id, + }, + } +} + +describe('moveChassis', () => { + it('creates a fresh group with the source attributes and reduces the source', () => { + const { plan, args, source, target } = fixture() + source.count = 8 + source.gpu = { modelId: 'gpu-l40s', perNode: 2 } + + const moved = moveChassis(plan, args)! + const movedSource = moved.partitions[0].racks[0].servers[0] + const movedTarget = moved.partitions[0].racks.find((rack) => rack.id === target.id)!.servers[0] + + expect(movedSource.count).toBe(5) + expect(movedTarget).toMatchObject({ + modelId: source.modelId, + role: source.role, + uplink: source.uplink, + gpu: source.gpu, + count: 3, + }) + expect(movedTarget.id).not.toBe(source.id) + }) + + it('merges into the first group with identical attributes', () => { + const { plan, args, source, target } = fixture() + const targetGroup = { ...source, id: crypto.randomUUID(), count: 6 } + target.servers.push(targetGroup) + + const moved = moveChassis(plan, args)! + const targetServers = moved.partitions[0].racks[1].servers + + expect(targetServers).toHaveLength(1) + expect(targetServers[0]).toMatchObject({ id: targetGroup.id, count: 9 }) + }) + + it('does not merge groups with different GPU attributes and copies GPU values', () => { + const withoutGpu = fixture() + withoutGpu.source.gpu = { modelId: 'gpu-l40s', perNode: 2 } + withoutGpu.target.servers.push({ + ...withoutGpu.source, + id: crypto.randomUUID(), + count: 4, + gpu: undefined, + }) + + const firstMove = moveChassis(withoutGpu.plan, withoutGpu.args)! + const created = firstMove.partitions[0].racks[1].servers[1] + expect(firstMove.partitions[0].racks[1].servers).toHaveLength(2) + expect(created.gpu).toEqual(withoutGpu.source.gpu) + expect(created.gpu).not.toBe(withoutGpu.source.gpu) + + const differentCount = fixture() + differentCount.source.gpu = { modelId: 'gpu-l40s', perNode: 2 } + differentCount.target.servers.push({ + ...differentCount.source, + id: crypto.randomUUID(), + count: 4, + gpu: { modelId: 'gpu-l40s', perNode: 1 }, + }) + + const secondMove = moveChassis(differentCount.plan, differentCount.args)! + expect(secondMove.partitions[0].racks[1].servers).toHaveLength(2) + }) + + it('removes a source group when all of its nodes move', () => { + const { plan, args, source } = fixture() + source.count = 8 + + const moved = moveChassis(plan, { ...args, nodes: 8 })! + + expect(moved.partitions[0].racks[0].servers).toHaveLength(0) + expect(moved.partitions[0].racks[1].servers[0].count).toBe(8) + }) + + it('keeps other zero-node groups in the source rack', () => { + const { plan, args, source } = fixture() + source.count = 8 + const empty: ServerGroup = { ...source, id: crypto.randomUUID(), count: 0, uplink: '2x100G' } + plan.partitions[0].racks[0].servers.push(empty) + + const moved = moveChassis(plan, { ...args, nodes: 8 })! + const sourceServers = moved.partitions[0].racks[0].servers + + expect(sourceServers).toHaveLength(1) + expect(sourceServers[0].id).toBe(empty.id) + }) + + it('returns null for a same-rack move', () => { + const { plan, args } = fixture() + expect(moveChassis(plan, { ...args, toRackId: args.fromRackId })).toBeNull() + }) + + it('moves the actual node count of a partial chassis', () => { + const { plan, args, source } = fixture() + source.count = 11 + + const moved = moveChassis(plan, { ...args, nodes: 3 })! + + expect(moved.partitions[0].racks[0].servers[0].count).toBe(8) + expect(moved.partitions[0].racks[1].servers[0].count).toBe(3) + }) + + it('returns null for unknown ids and invalid node counts', () => { + const { plan, args, source } = fixture() + const invalidArgs: MoveChassisArgs[] = [ + { ...args, partitionId: 'unknown' }, + { ...args, fromRackId: 'unknown' }, + { ...args, toRackId: 'unknown' }, + { ...args, groupId: 'unknown' }, + { ...args, nodes: 0 }, + { ...args, nodes: source.count + 1 }, + ] + + for (const invalid of invalidArgs) expect(moveChassis(plan, invalid)).toBeNull() + }) + + it('does not mutate its input and preserves uninvolved rack references', () => { + const { plan, args } = fixture() + const partition = plan.partitions[0] + const uninvolved = defaultRack('Rack 3', 'single', partition.rackDefaults) + partition.racks.push(uninvolved) + const snapshot = structuredClone(plan) + + const moved = moveChassis(plan, args)! + + expect(plan).toEqual(snapshot) + expect(moved).not.toBe(plan) + expect(moved.partitions[0].racks[2]).toBe(uninvolved) + }) +}) diff --git a/src/model/moveChassis.ts b/src/model/moveChassis.ts new file mode 100644 index 0000000..e5fb40c --- /dev/null +++ b/src/model/moveChassis.ts @@ -0,0 +1,86 @@ +import type { Plan, ServerGroup } from './plan' + +// Server group counts represent nodes, not chassis. Moving a partial chassis +// can therefore leave either rack with a count that is not a multiple of the +// model's nodes-per-chassis value; the rack layout derives chassis again. + +export interface MoveChassisArgs { + partitionId: string + fromRackId: string + groupId: string + /** Node count of the dragged chassis (a partial last chassis moves fewer). */ + nodes: number + toRackId: string +} + +function sameAttributes(a: ServerGroup, b: ServerGroup): boolean { + return ( + a.modelId === b.modelId && + a.role === b.role && + a.uplink === b.uplink && + a.gpu?.modelId === b.gpu?.modelId && + a.gpu?.perNode === b.gpu?.perNode + ) +} + +/** Moves one chassis's nodes between racks of a partition. Returns the new + * plan, or null when the move is a no-op so callers can skip recording + * history. */ +export function moveChassis(plan: Plan, args: MoveChassisArgs): Plan | null { + if (args.nodes <= 0 || args.fromRackId === args.toRackId) return null + + const partition = plan.partitions.find((candidate) => candidate.id === args.partitionId) + const sourceRack = partition?.racks.find((rack) => rack.id === args.fromRackId) + const targetRack = partition?.racks.find((rack) => rack.id === args.toRackId) + const sourceGroup = sourceRack?.servers.find((group) => group.id === args.groupId) + + if (!partition || !sourceRack || !targetRack || !sourceGroup) return null + if (args.nodes > sourceGroup.count) return null + + const matchingTarget = targetRack.servers.find((group) => sameAttributes(group, sourceGroup)) + const targetServers = matchingTarget + ? targetRack.servers.map((group) => + group.id === matchingTarget.id ? { ...group, count: group.count + args.nodes } : group, + ) + : [ + ...targetRack.servers, + { + id: crypto.randomUUID(), + role: sourceGroup.role, + modelId: sourceGroup.modelId, + count: args.nodes, + uplink: sourceGroup.uplink, + ...(sourceGroup.gpu ? { gpu: { ...sourceGroup.gpu } } : {}), + }, + ] + + return { + ...plan, + partitions: plan.partitions.map((candidate) => + candidate.id === partition.id + ? { + ...candidate, + racks: candidate.racks.map((rack) => { + if (rack.id === sourceRack.id) { + // Remove only the moved group when it empties; other groups + // may legitimately sit at zero nodes. + return { + ...rack, + servers: + sourceGroup.count === args.nodes + ? rack.servers.filter((group) => group.id !== sourceGroup.id) + : rack.servers.map((group) => + group.id === sourceGroup.id + ? { ...group, count: group.count - args.nodes } + : group, + ), + } + } + if (rack.id === targetRack.id) return { ...rack, servers: targetServers } + return rack + }), + } + : candidate, + ), + } +} diff --git a/src/model/plan.ts b/src/model/plan.ts index f65856d..f7bb1d4 100644 --- a/src/model/plan.ts +++ b/src/model/plan.ts @@ -73,11 +73,23 @@ export const RackSchema = z.object({ leafModelId: z.string(), leafCount: z.number().int().min(0), servers: z.array(ServerGroupSchema), + /** Pod of a leaf-spine-superspine partition this rack belongs to; racks + * without a known pod go to the first (derive/pods.ts). */ + podId: z.string().optional(), }) export type Rack = z.infer +/** A pod of a leaf-spine-superspine partition: its racks' leaves uplink + * to the pod's own spines, which uplink to the superspines by plane. */ +export const PodSchema = z.object({ + id: z.string(), + name: z.string(), +}) +export type Pod = 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 +97,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 +129,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 @@ -135,6 +150,8 @@ export const PartitionSchema = z.object({ name: z.string(), fabric: FabricConfigSchema, racks: z.array(RackSchema), + /** Pods, only used with fabricType leaf-spine-superspine. */ + pods: z.array(PodSchema).default([]), rackDefaults: RackDefaultsSchema.default({ heightUnits: 42, maxPowerWatts: 10000 }), }) export type Partition = z.infer diff --git a/src/model/templates.ts b/src/model/templates.ts index 7842ed2..d19c5dc 100644 --- a/src/model/templates.ts +++ b/src/model/templates.ts @@ -51,6 +51,7 @@ function productionPartition(name: string): Partition { rackGroup('Rack group 1', ['Rack 1', 'Rack 2', 'Rack 3'], 3), rackGroup('Rack group 2', ['Rack 4', 'Rack 5', 'Rack 6']), ], + pods: [], rackDefaults: { ...DEFAULT_RACK_DEFAULTS }, } } @@ -77,6 +78,7 @@ export const templates: PlanTemplate[] = [ name: 'Partition 1', fabric, racks: [rack], + pods: [], rackDefaults: { ...DEFAULT_RACK_DEFAULTS }, }, ]) diff --git a/src/store/planStore.ts b/src/store/planStore.ts index f417327..c343fb4 100644 --- a/src/store/planStore.ts +++ b/src/store/planStore.ts @@ -2,9 +2,17 @@ import { create, useStore } from 'zustand' import { useShallow } from 'zustand/react/shallow' import { persist } from 'zustand/middleware' import { temporal } from 'zundo' -import { createEmptyPlan, defaultPartition, newRack, withRackKind } from '../model/defaults' +import { + createEmptyPlan, + defaultPartition, + newRack, + withPodAdded, + withPodRemoved, + withRackKind, +} from '../model/defaults' import { ipPresets, type IpFamily, type IpFamilyKey, type IpInfra } from '../model/ipPlan' import { migrateRawPlan, SCHEMA_VERSION } from '../model/migrate' +import { moveChassis as moveChassisPlan, type MoveChassisArgs } from '../model/moveChassis' import { normalizePlan } from '../model/normalize' import { PlanSchema, @@ -37,6 +45,9 @@ interface PlannerState { renamePartition: (partitionId: string, name: string) => void patchFabric: (partitionId: string, patch: Partial) => void patchRackDefaults: (partitionId: string, patch: Partial) => void + addPod: (partitionId: string) => void + renamePod: (partitionId: string, podId: string, name: string) => void + removePod: (partitionId: string, podId: string) => void addRack: (partitionId: string, kind?: Rack['kind']) => void removeRack: (partitionId: string, rackId: string) => void patchRack: (partitionId: string, rackId: string, patch: Partial) => void @@ -49,6 +60,7 @@ interface PlannerState { groupId: string, patch: Partial, ) => void + moveChassis: (args: MoveChassisArgs) => void addExternalNetwork: () => void patchExternalNetwork: (id: string, patch: Partial) => void removeExternalNetwork: (id: string) => void @@ -56,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() } } @@ -177,6 +201,19 @@ export const usePlanStore = create()( fabric: { ...p.fabric, ...patch }, })), })), + addPod: (partitionId) => + set((s) => ({ plan: mapPartition(s.plan, partitionId, withPodAdded) })), + renamePod: (partitionId, podId, name) => + set((s) => ({ + plan: mapPartition(s.plan, partitionId, (p) => ({ + ...p, + pods: p.pods.map((pod) => (pod.id === podId ? { ...pod, name } : pod)), + })), + })), + removePod: (partitionId, podId) => + set((s) => ({ + plan: mapPartition(s.plan, partitionId, (p) => withPodRemoved(p, podId)), + })), addRack: (partitionId, kind = 'single') => set((s) => ({ plan: mapPartition(s.plan, partitionId, (p) => ({ @@ -232,6 +269,11 @@ export const usePlanStore = create()( servers: r.servers.map((g) => (g.id === groupId ? { ...g, ...patch } : g)), })), })), + moveChassis: (args) => + set((s) => { + const moved = moveChassisPlan(s.plan, args) + return moved ? { plan: touched(moved, {}) } : {} + }), addExternalNetwork: () => set((s) => ({ plan: touched(s.plan, { @@ -296,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(), 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 + + ) + })} + + + ) +} + +/** 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)) + } + + function moveGroup(from: Selection, groupId: string, to: Selection) { + const args = moveGroupArgs(usePlanStore.getState().plan, from, groupId, to) + if (!args) return + usePlanStore.getState().moveChassis(args) + setPicked({ partitionId: args.partitionId, rackId: args.toRackId }) } return ( @@ -85,31 +235,55 @@ 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/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/icons/index.tsx b/src/views/icons/index.tsx index 273b4ef..749f868 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, @@ -48,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 @@ -120,11 +122,24 @@ 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, } +/** 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/plan/CentralRackSection.tsx b/src/views/plan/CentralRackSection.tsx index e72fbe4..6360a0a 100644 --- a/src/views/plan/CentralRackSection.tsx +++ b/src/views/plan/CentralRackSection.tsx @@ -7,6 +7,7 @@ import { } from '../../model/catalog' import type { FabricConfig, MgmtNetwork, Partition } from '../../model/plan' import { formatTally, partitionNodes } from '../../derive/nodes' +import { podsOf } from '../../derive/pods' import { formatGbps, formatRatio, spineBandwidth } from '../../derive/bandwidth' import { issuesFor, type Issue } from '../../derive/validate' import HoverHint from '../HoverHint' @@ -16,7 +17,7 @@ import { NumberField, SelectField } from './fields' import InfoBubble from './InfoBubble' import IssueBadges from './IssueBadges' import { fabricAnchor } from './navigate' -import { Icon, SECTION_ICON } from '../icons' +import { ACTION_ICON, Icon, SECTION_ICON } from '../icons' import { nosOptionLabel, optionLabel } from './options' function switchOptions(role: SwitchRole) { @@ -42,6 +43,58 @@ function modelSummary(fabric: FabricConfig): string { ].join(' · ') } +/** The pods of a leaf-spine-superspine partition: named, added and removed + * here; racks pick theirs in their own section. */ +function PodList({ partition }: { partition: Partition }) { + const addPod = usePlanStore((s) => s.addPod) + const renamePod = usePlanStore((s) => s.renamePod) + const removePod = usePlanStore((s) => s.removePod) + const pods = podsOf(partition) + return ( +
+ + Pods + + +
+ {pods.map((pod) => ( + + renamePod(partition.id, pod.id, e.target.value)} + className="w-28 rounded-md border border-gray-300 bg-white px-2 py-1.5 text-sm disabled:bg-gray-50" + /> + {pod.racks.length} racks + {partition.pods.length > 1 && ( + + )} + + ))} + +
+
+ ) +} + export default function CentralRackSection({ partition, issues, @@ -63,7 +116,7 @@ export default function CentralRackSection({ return (
@@ -104,7 +157,7 @@ export default function CentralRackSection({

{modelSummary(fabric)}

-
+
patch({ spineCount: n })} /> @@ -149,14 +208,14 @@ export default function CentralRackSection({ patch({ superspineCount: n })} /> )}
+ {hasSuperspine && }

-
+
Devices and models
-
+
Fabric links
-
+
patch({ leafSpineLinks: n })} /> -