179 lines
7.5 KiB
Markdown
179 lines
7.5 KiB
Markdown
|
|
# 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 `<rack>-elevation.svg` + `<rack>.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
|
||
|
|
`<br/>kind` subtitle (e.g. `sw01<br/>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.
|