Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
bc7e50e
add mgmt firewall and wire the mgmt network one to one
muhittink Sep 26, 2026
14d8612
draw one mgmt interface per spine
muhittink Sep 26, 2026
ae55a99
color mgmt links by the port they land on
muhittink Sep 26, 2026
0e4894c
capture the pointer only once a press becomes a pan
muhittink Sep 26, 2026
9e2d97b
add a click-together builder to the topology tab
muhittink Sep 26, 2026
c37c24a
count 25G breakouts per leaf, not per server group
muhittink Sep 26, 2026
c92e645
name SONiC ports in the catalog
muhittink Sep 26, 2026
4e3b813
add mgmt loopbacks and mgmt link ranges to the IP plan
muhittink Sep 26, 2026
61bab3f
derive a physical cable plan
muhittink Sep 26, 2026
bd7c017
add a small YAML writer
muhittink Sep 26, 2026
0ec1e3d
generate an Ansible inventory from the plan
muhittink Sep 26, 2026
07c9ce7
serve DHCP on the mgmt servers
muhittink Sep 26, 2026
5330997
add the group aliases production playbooks target
muhittink Sep 26, 2026
5e3cbc4
add an "Export Ansible" button to the plan actions
muhittink Sep 26, 2026
da30e1a
size editor section grids by the section, not the window
muhittink Sep 26, 2026
597eae4
add moveChassis: move a server group's nodes between racks
Sven-Ric Sep 26, 2026
ea587ed
drag server groups between racks in the topology
muhittink Sep 26, 2026
ac8d608
add pods to the plan format (version 2)
muhittink Sep 26, 2026
f2d814d
wire superspine fabrics as pods and planes
muhittink Sep 26, 2026
6213048
count spines, links and ports per pod
muhittink Sep 26, 2026
d072f0b
cable and export superspine fabrics
muhittink Sep 26, 2026
e5d3d58
draw pods as boxes with their spines above their racks
muhittink Sep 26, 2026
4bf0696
edit pods in the plan editor
muhittink Sep 27, 2026
9f79367
check pods the way the cable plan lays them out
muhittink Sep 27, 2026
abfbf73
keep pods in the central view and fix the RFC citation
muhittink Sep 27, 2026
28764d4
keep a stored plan the app cannot read before replacing it
muhittink Sep 27, 2026
8802e79
mark superspine exports as untested and widen the pod cabling test
muhittink Sep 27, 2026
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
17 changes: 9 additions & 8 deletions CLAUDE.md

Large diffs are not rendered by default.

41 changes: 36 additions & 5 deletions src/derive/bandwidth.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
})
})

Expand All @@ -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)
})
})
40 changes: 28 additions & 12 deletions src/derive/bandwidth.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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,
)
}

Expand All @@ -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. */
Expand Down
57 changes: 41 additions & 16 deletions src/derive/bom.test.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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)
})
})

Expand All @@ -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)
})
})

Expand All @@ -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)
})
})

Expand Down Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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',
Expand All @@ -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',
Expand Down Expand Up @@ -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)
})
})
Loading
Loading