# Rack Network (Phase 3) Design **Date:** 2026-06-24 **Status:** Approved **Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 3) ## Goal Add network-cabling data to the rack documentation pipeline and render it as a mermaid network graph on the generated rack page, so the published page at `docs.makerfloss.eu` shows which interface/port connects each device to its switch or patch panel. This reuses every Phase 1/2 mechanism: the same `scripts/gen_rack.py` generator, the same generated files under `docs/infrastructure/racks/`, and the same CI drift guard. Mermaid is already enabled (pulled forward in Phase 2), so no `mkdocs.yml` change is needed. ## Context - Phase 1 (elevation) and Phase 2 (power) are merged. `scripts/gen_rack.py` reads `docs/hardware/*.md` files carrying a `rack:` field, validates placement and power, and writes `-elevation.svg` + `.md` per rack. The page already has `## Elevation`, `## Power`, `## Occupancy` sections. - The `switch`, `patch-panel`, and `ap` values are already in the `hardware` `kind` enum (`scripts/overview_config.yml`), so peer files already validate and already appear in the hardware index. **No `overview_config.yml` change.** - Phase 1 already renders placed (`rack_u`/`u_height`) items as colored boxes in the elevation, with `switch` green and `patch-panel` brown in `KIND_COLORS`, so the new peer files need **no new SVG code** to appear. - The Makefile `docs-check` and CI drift step already diff the entire `docs/infrastructure/racks/` directory — **no Makefile/CI edits required**. - `_node_id` (Phase 2) sanitizes hostnames into mermaid-safe node ids and is reused here. - The `mfNN` rack positions and the Phase 2 power data are fictional placeholders proving the pipeline. The network data added here is **similarly provisional** until real values are given. ## Data model ### Devices/peers — `links:` frontmatter Each cable end is declared **once**, on the originating end: ```yaml links: - { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 } ``` `links` is optional — an item with no `links` contributes no edges. Any rack item may declare links, not just servers (e.g. `pp01` declares its uplink). Fields per entry: - `local` — the local interface/port label (non-empty string), the cable end on this item. - `peer` — the hostname of the device at the other end (non-empty string). - `peer_port` — the port number on the peer (integer). - `speed_gbps` — link speed in Gbps (optional integer; shown on the edge when present). **Declare-once convention:** a physical cable is declared on exactly one end to avoid duplicate edges. The generator renders whatever is declared; it does not infer the reverse direction. ### New peer files ```yaml # docs/hardware/sw01.md hostname: sw01 kind: switch status: in-use rack: rack01 rack_u: 10 u_height: 1 rack_face: front ports: 24 ``` ```yaml # docs/hardware/pp01.md hostname: pp01 kind: patch-panel status: in-use rack: rack01 rack_u: 24 u_height: 1 rack_face: front ports: 24 ``` `ports` is a new field, validated by `gen_rack.py` only as the bound for `peer_port`. `gen_overview.py` ignores `ports` and `links`. ### Provisional data populated by this phase (full pass-through) - `sw01` — `kind: switch`, U10 front, `ports: 24`. - `pp01` — `kind: patch-panel`, U24 front, `ports: 24`, with one uplink link `{ local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }`. - `mf01..mf04` — `eth0 → pp01` ports 1..4 respectively, `speed_gbps: 1`. - `mf00` — `eth0 → sw01` port 1, `speed_gbps: 1`. sw01 and pp01 deliberately get **no** `power:` feeds in this phase, to keep the Phase 3 diff network-focused; the power graph is unchanged. ## Validation (rule 4 from the parent spec) A new `load_hardware_index(hardware_dir) -> dict[str, dict]` returns `{hostname: frontmatter}` for **every** `docs/hardware/*.md` (excluding `index.md`), giving global peer resolution. A new `validate_links(items, hw_index) -> None` in `gen_rack.py`, called from `generate()` per rack after placement/power validation, raises `SchemaError` (→ stderr + exit 1, nothing written) when: 1. A `links` value is not a list, or an entry is not a mapping. 2. An entry lacks a non-empty string `local`, a non-empty string `peer`, or an integer `peer_port`. 3. An entry's `peer` does not resolve to a hostname in `hw_index` (rule 4: "resolves to a real file" — global, not per-rack). 4. The peer declares an integer `ports` and `peer_port` is outside `1..ports`. (Peers without a declared `ports` skip the range check.) Peer resolution is intentionally **global** (against all hardware files), diverging from power's per-rack PDU resolution, so a link may target non-racked gear (e.g. an upstream router) in future. ## Rendering ### `render_network(rack, items) -> str` Returns a fenced mermaid block, or `""` when no item in the rack has any `links` entry (so the `## Network` section is omitted for link-less racks). - ` ```mermaid ` + `flowchart LR`. - One node per host that appears as a link source or peer. A node whose kind (resolved from the rack's items) is `switch` or `patch-panel` gets a `
kind` subtitle (e.g. `sw01
switch`); all other nodes render as the bare hostname. Peers not present in the rack's items render as the bare hostname. - One edge per link: `sourceNode -->|{local} → p{peer_port} · {speed}G| peerNode`. The ` · {speed}G` suffix is omitted when `speed_gbps` is absent. The unicode arrow `→` avoids any clash with mermaid's `-->` edge syntax. - Node ids via `_node_id` (Phase 2). Deterministic: nodes sorted by hostname, edges sorted by `(source, local, peer, peer_port)`. ### `render_page` change Insert a `## Network` section containing `render_network(...)` **between** the Power and Occupancy sections — only when `render_network` returns non-empty. ## Integration | File | Change | |------|--------| | `scripts/gen_rack.py` | Add `load_hardware_index`, `validate_links`; call `validate_links` in `generate`; add `render_network`; insert `## Network` in `render_page` | | `tests/test_gen_rack.py` | Add `validate_links` + `render_network` + `generate` network cases | | `docs/hardware/sw01.md`, `pp01.md` | New peer files (`switch`/`patch-panel`, `ports: 24`); `pp01` carries the uplink `links` entry | | `docs/hardware/mf00.md`..`mf04.md` | Add `links:` lists | | `docs/hardware/index.md` | Regenerated (switch + patch panel now listed) | | `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | Regenerated (network section + sw01/pp01 boxes in the elevation) | No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `overview_config.yml` changes. ## Test plan - **Unit — `validate_links`:** accept a valid link; reject an unknown `peer`, a `peer_port` above the peer's `ports`, a malformed entry (non-mapping / missing `local`/`peer`/`peer_port`). Accept a link whose peer declares no `ports` (range check skipped). - **Unit — `render_network`:** source/peer nodes present; peer kind subtitle for switch/patch-panel; edge label carries local interface, peer port, and speed; returns `""` when no item has links; deterministic for reordered input. - **Integration — `generate`:** with valid fixtures the page contains the `## Network` section and the mermaid fence; with a dangling `peer` it returns `1` and writes nothing. - **Drift:** `make docs-check` exits 0 after regeneration (existing guard). - **Visual:** `mkdocs build --strict` succeeds and the rack page shows the network graph as a rendered diagram, with the pass-through chain mf01..mf04 → pp01 → sw01 and mf00 → sw01 visible.