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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,15 @@ A client-only single-page app (React + Vite + TypeScript) to plan a [metal-stack

## Domain

- Topologies: `single-zone` | `metro` | `multisite`. A `Partition` is a metal-stack failure domain (one site / room) — the term is always "partition", never "zone".
- 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.
- **Rack kinds** (`Rack.kind`): `single` (one physical rack) or `rack-group` (three physical racks sharing the middle rack's leaf pair and mgmt leaf; chassis are distributed evenly per server group — each goes to the physical rack holding the fewest chassis of its group, the least-used by height units among those, ties mid → left → right). `physicalRacks()` in `derive/rackLayout.ts` is the single source of that spread; the topology graph and the elevations both consume it, so a rack group always shows as three physical racks — in the racks view inside one dashed group box (`RackElevation.group`). A group's `name` names the group ("Rack group 1"); its physical racks are named by `memberNames` (left, middle, right). Names are editable, but defaults are unique per partition: `newRack()` / `withRackKind()` in `model/defaults.ts` number every physical rack above the highest `Rack <N>` in use (a group takes three numbers, switching kinds keeps names unique), and validation warns on duplicate physical rack names. BOM sections, IP-plan subnets and rack-level validation use the group name; elevations and the topology use the physical names. In both views the mgmt leaf sits at the top of a compute rack, above the leaves. Leaf port capacity counts only spine uplinks against front-panel ports — leaf↔mgmt connectivity uses the switches' dedicated OOB mgmt port.
- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines and mgmt servers (2 vs 1, `mgmtDeviceCount()`). Production tiers use Edgecore AS7726 (the AS7712 is vendor end-of-life), management tiers AS4630/AS4625. Every switch has exactly one management interface: leaves connect it to the rack's mgmt leaf, central-rack switches and routers to the mgmt spines — derived, never configured. `FabricConfig.leafSpineLinks` (default 1) is the number of 100G links from each leaf to each spine. External networks (internet, company networks, storage) attach at exit switches.
- **Management network** (`FabricConfig.mgmt`): has its own topology — `layer: 'l2' | 'l3'` and `redundant: boolean`; redundancy drives the count of mgmt spines and mgmt servers (2 vs 1, `mgmtDeviceCount()`). Production tiers use Edgecore AS7726 (the AS7712 is vendor end-of-life), management tiers AS4630/AS4625. Every switch has exactly one management interface: leaves connect it to the rack's mgmt leaf, central-rack switches and routers to the mgmt spines — derived, never configured. `FabricConfig.leafSpineLinks` (default 1) is the number of 100G links from each leaf to each spine. External networks (internet, company networks, storage) attach in the central rack: internet and company networks at the internet routers (at the exits when a partition has no routers), a storage network at the partition's storage leaves, or at the exits when it has none (`attachesAtStorageLeaves()` in `derive/topology.ts` is the single source of that rule, used by the derivation and by `Diagram` to draw storage capsules over the storage box). `filterTopology()` drops an external network whose attachment points all fell away.
- **GPUs.** `ServerGroup.gpu` (optional `{ modelId, perNode }`) fits every node of a group alike: a BOM line at `nodes × perNode`, watts folded into the chassis slot so the rack power estimate picks them up, and a validation error when `perNode` exceeds the server model's catalog `gpuCapable` (absent = takes no GPUs). `gpusForServer()` populates the dropdown, so the field only appears for GPU-capable models.
- `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 external networks in the topology.
- **Cabling model** (header comment in `src/derive/bom.ts`): `leafSpineLinks` 100G links per leaf ↔ spine pair, one per exit/superspine/storage leaf ↔ spine (MTP trunk + 2x SR4; `spinePortsPerSpine()`). Management copper (RJ45 1G, no transceivers): one BMC/OOB port per server chassis (`rackBmcPorts()`, matches the sample BOMs) and one mgmt interface per leaf to the mgmt leaf (validated against the mgmt leaf's 1G ports); one mgmt interface per central-rack switch and router, and 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.
- 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.
Expand Down
8 changes: 4 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ consistent — you edit the plan, everything else follows from it.

## Screenshots

The screenshots show the **Redundant** template: one partition with a redundant management
The screenshots show the **Production** template: one partition with a redundant management
network, two rack groups with 112 workers each and three storage servers.

### Plan editor with live side panel
Expand Down Expand Up @@ -150,9 +150,9 @@ default plan. Use **Export JSON** to save a plan file and **Import JSON** to loa

| Template | Contents |
| ---------------- | --------------------------------------------------------------------------------------------------------- |
| Starter | One partition, non-redundant management network, one rack with 8 workers on a leaf pair. |
| Redundant | One partition, redundant management network, two rack groups with 112 workers each and 3 storage servers. |
| Three partitions | Multisite topology with three partitions, each like _Redundant_. |
| PoC | One partition, non-redundant management network, one rack with 8 workers on a leaf pair. |
| Production | One partition, redundant management network, two rack groups with 112 workers each and 3 storage servers. |
| Three partitions | Three partitions, each like _Production_. |

## How it works

Expand Down
4 changes: 2 additions & 2 deletions src/derive/bandwidth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,8 @@ describe('rackBandwidth', () => {
expect(rackBandwidth(partition.racks[0], partition).ratio).toBeNull()
})

it('reports the oversubscription of the Redundant template', () => {
const plan = templates.find((t) => t.id === 'redundant')!.build()
it('reports the oversubscription of the Production template', () => {
const plan = templates.find((t) => t.id === 'production')!.build()
const partition = plan.partitions[0]
// 112 workers + 3 storage nodes x 50 Gbit/s = 5750 against 400
const b = rackBandwidth(partition.racks[0], partition)
Expand Down
4 changes: 2 additions & 2 deletions src/derive/ip/ipPlan.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -165,8 +165,8 @@ describe('infrastructure ranges', () => {
])
})

it('sizes the Redundant template per rack group and overflows a small block', () => {
const plan = templates.find((t) => t.id === 'redundant')!.build()
it('sizes the Production template per rack group and overflows a small block', () => {
const plan = templates.find((t) => t.id === 'production')!.build()
const p = deriveIpPlan(plan).infra.partitions[0]
const pxe = p.subnets.find((s) => s.purpose === 'PXE (vlan4000)')!
expect(pxe).toMatchObject({ needed: 229, prefix: 23 })
Expand Down
2 changes: 1 addition & 1 deletion src/derive/ip/validateIp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ describe('validateIpPlan', () => {
})

it('warns when a partition plans more workers than one cluster can hold', () => {
const plan = templates.find((t) => t.id === 'redundant')!.build()
const plan = templates.find((t) => t.id === 'production')!.build()
// compact preset: 16 workers per shoot cluster, the template plans 224
const w = messages(plan, 'warning').find((m) => m.includes('at most 16 workers'))
expect(w).toContain('Partition 1 plans 224')
Expand Down
40 changes: 39 additions & 1 deletion src/derive/topology.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest'
import { createEmptyPlan, withRackKind } from '../model/defaults'
import type { Plan } from '../model/plan'
import { deriveTopology, filterTopology } from './topology'

describe('deriveTopology', () => {
Expand Down Expand Up @@ -69,7 +70,7 @@ describe('deriveTopology', () => {
expect(spineSuper).toHaveLength(4) // 2 spines x 2 superspines
})

it('attaches external networks at the routers, or the exits without routers', () => {
it('attaches the internet network at the routers, or the exits without routers', () => {
const graph = deriveTopology(createEmptyPlan())
const partition = graph.partitions[0]
expect(partition.externalNetworks).toHaveLength(1)
Expand Down Expand Up @@ -109,6 +110,43 @@ describe('deriveTopology', () => {
expect(links).toHaveLength(4) // 2 storage leaves x 2 spines
})

it('attaches a storage network at the storage leaves, or the exits without them', () => {
const plan = createEmptyPlan()
plan.externalNetworks = [
{ id: 'storage-net', name: 'Ceph', kind: 'storage', attachedPartitionId: '' },
]
const storageTargets = (p: Plan) => {
const graph = deriveTopology(p)
const partition = graph.partitions[0]
const net = partition.externalNetworks[0]
return {
graph,
partition,
net,
targets: graph.links.filter((l) => l.from === net.id).map((l) => l.to),
}
}

// Without storage leaves the storage network lands on the exits, not on
// the internet routers.
const noLeaves = storageTargets(plan)
expect(noLeaves.partition.central.routers).toHaveLength(2)
expect(noLeaves.targets.sort()).toEqual(
noLeaves.partition.central.exits.map((n) => n.id).sort(),
)

plan.partitions[0].fabric.storageLeafCount = 2
const withLeaves = storageTargets(plan)
expect(withLeaves.targets.sort()).toEqual(
withLeaves.partition.storageLeaves.map((n) => n.id).sort(),
)

// Central mode drops the storage leaves, so the capsule goes with them.
const central = filterTopology(withLeaves.graph, 'central')
expect(central.partitions[0].externalNetworks).toHaveLength(0)
expect(central.links.some((l) => l.from === withLeaves.net.id)).toBe(false)
})

it('draws a rack group as three physical racks sharing the middle switches', () => {
const plan = createEmptyPlan()
const partition = plan.partitions[0]
Expand Down
46 changes: 37 additions & 9 deletions src/derive/topology.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ export interface TopoRack {
/** Spines, exits, superspines, mgmt spines and mgmt servers all live in the
* partition's central rack. */
export interface TopoCentralRack {
/** Internet routers; external networks attach here when present. */
/** Internet routers; internet and company networks attach here when
* present. */
routers: TopoNode[]
superspines: TopoNode[]
spines: TopoNode[]
Expand All @@ -77,9 +78,10 @@ export interface TopoPartition {
central: TopoCentralRack
storageLeaves: TopoNode[]
racks: TopoRack[]
/** External networks attached at this partition's exits. A network that
/** External networks attached to this partition, at the routers, the
* exits or the storage leaves (see deriveTopology). A network that
* attaches to every partition appears once per partition, so each is
* drawn next to the exits it connects to. */
* drawn next to the devices it connects to. */
externalNetworks: TopoNode[]
}

Expand Down Expand Up @@ -301,12 +303,19 @@ function derivePartition(partition: Partition, links: TopoLink[]): TopoPartition
}
}

/** Whether an external network node hangs off the partition's storage
* leaves rather than off its routers or exits. The single source of that
* rule: the derivation links it there, the diagram draws it there. */
export function attachesAtStorageLeaves(partition: TopoPartition, node: TopoNode): boolean {
return node.networkKind === 'storage' && partition.storageLeaves.length > 0
}

export function deriveTopology(plan: Plan): TopologyGraph {
const links: TopoLink[] = []
const partitions = plan.partitions.map((partition) => derivePartition(partition, links))

// External networks attach at the exit switches of their partition (or
// every partition when unset), as one node per partition they attach to.
// External networks attach in their partition (or in every partition when
// unset), as one node per partition they attach to.
for (const net of plan.externalNetworks) {
const targets = partitions.filter(
(p) => !net.attachedPartitionId || p.id === net.attachedPartitionId,
Expand All @@ -320,9 +329,18 @@ export function deriveTopology(plan: Plan): TopologyGraph {
networkKind: net.kind,
}
partition.externalNetworks.push(node)
// Attach at the routers, or directly at the exits when there are none.
// Storage backends land on the dedicated storage leaves, or on the
// exit switches when the partition has none: they never enter through
// the internet routers. Every other network attaches at the routers,
// or directly at the exits when there are none.
const attach =
partition.central.routers.length > 0 ? partition.central.routers : partition.central.exits
net.kind === 'storage'
? attachesAtStorageLeaves(partition, node)
? partition.storageLeaves
: partition.central.exits
: partition.central.routers.length > 0
? partition.central.routers
: partition.central.exits
for (const device of attach) {
links.push({ from: node.id, to: device.id, count: 1, network: 'external' })
}
Expand All @@ -348,7 +366,9 @@ function nodeVisible(node: TopoNode, mode: TopologyMode): boolean {
/** The subgraph for a view mode: nodes of the other network are dropped,
* compute racks and storage are dropped in central mode, external
* networks in management mode, and links are kept only when both ends
* remain and belong to the shown network. */
* remain and belong to the shown network. An external network whose
* attachment points all fell away (a storage network on storage leaves,
* in central mode) goes with them instead of floating unconnected. */
export function filterTopology(graph: TopologyGraph, mode: TopologyMode): TopologyGraph {
const keep = (nodes: TopoNode[]) => nodes.filter((n) => nodeVisible(n, mode))
const partitions = graph.partitions.map((p): TopoPartition => ({
Expand Down Expand Up @@ -383,12 +403,20 @@ export function filterTopology(graph: TopologyGraph, mode: TopologyMode): Topolo
...p.central.mgmtSpines,
...p.central.mgmtServers,
...p.storageLeaves,
...p.externalNetworks,
...p.racks.flatMap((r) => [...r.leaves, ...r.mgmtLeaves, ...r.serverGroups]),
]) {
ids.add(n.id)
}
}
// External networks only survive with a device left to attach to.
for (const p of partitions) {
p.externalNetworks = p.externalNetworks.filter((n) =>
graph.links.some(
(l) => (l.from === n.id && ids.has(l.to)) || (l.to === n.id && ids.has(l.from)),
),
)
for (const n of p.externalNetworks) ids.add(n.id)
}
const links = graph.links.filter((l) => {
if (!ids.has(l.from) || !ids.has(l.to)) return false
if (mode === 'production') return l.network !== 'management'
Expand Down
8 changes: 0 additions & 8 deletions src/derive/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -476,14 +476,6 @@ export function validatePlan(plan: Plan): Issue[] {
}
}

if (plan.topology !== 'single-zone' && plan.partitions.length < 2) {
report(
issues,
{ where: 'Plan', target: {} },
'warning',
`Topology is ${plan.topology} but the plan has only ${plan.partitions.length} partition(s).`,
)
}
checkAvailability(issues, plan)
issues.push(...validateIpPlan(plan))
return issues
Expand Down
1 change: 0 additions & 1 deletion src/model/defaults.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,6 @@ export function createEmptyPlan(): Plan {
name: 'New metal-stack plan',
createdAt: now,
updatedAt: now,
topology: 'single-zone',
partitions: [defaultPartition('Partition 1')],
sparesPerLine: 2,
ipPlan: defaultIpPlan(),
Expand Down
Loading
Loading