Compare commits
No commits in common. "773fec952f780073eb369c04eaf8542272153a00" and "1b5e8316eaeb409dc41489f408954eff1e416423" have entirely different histories.
773fec952f
...
1b5e8316ea
14 changed files with 0 additions and 1125 deletions
|
|
@ -2,12 +2,6 @@
|
||||||
|
|
||||||
_Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
|
_Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
|
||||||
|
|
||||||
## Patch panels
|
|
||||||
|
|
||||||
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| [pp01](pp01.md) | | | | | | in-use |
|
|
||||||
|
|
||||||
## PDUs
|
## PDUs
|
||||||
|
|
||||||
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
||||||
|
|
@ -25,9 +19,3 @@ _Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make doc
|
||||||
| [mf02](mf02.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 16 GB | 40 GB NVME | 1 GbE | staging |
|
| [mf02](mf02.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 16 GB | 40 GB NVME | 1 GbE | staging |
|
||||||
| [mf03](mf03.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
| [mf03](mf03.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
||||||
| [mf04](mf04.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
| [mf04](mf04.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
||||||
|
|
||||||
## Switches
|
|
||||||
|
|
||||||
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| [sw01](sw01.md) | | | | | | in-use |
|
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 1 }
|
- { pdu: pdu01, outlet: 1 }
|
||||||
- { pdu: pdu02, outlet: 1 }
|
- { pdu: pdu02, outlet: 1 }
|
||||||
links:
|
|
||||||
- { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ u_height: 1
|
||||||
rack_face: front
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 2 }
|
- { pdu: pdu01, outlet: 2 }
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@ u_height: 1
|
||||||
rack_face: front
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 3 }
|
- { pdu: pdu01, outlet: 3 }
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 2, speed_gbps: 1 }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ u_height: 2
|
||||||
rack_face: front
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 4 }
|
- { pdu: pdu01, outlet: 4 }
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 3, speed_gbps: 1 }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,6 @@ u_height: 2
|
||||||
rack_face: rear
|
rack_face: rear
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 5 }
|
- { pdu: pdu01, outlet: 5 }
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 4, speed_gbps: 1 }
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -1,16 +0,0 @@
|
||||||
---
|
|
||||||
hostname: pp01
|
|
||||||
kind: patch-panel
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 24
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
ports: 24
|
|
||||||
links:
|
|
||||||
- { local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder patch panel. Devices patch in here; rear uplink to sw01.
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
---
|
|
||||||
hostname: sw01
|
|
||||||
kind: switch
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 10
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
ports: 24
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder switch. Port assignments are not yet real.
|
|
||||||
|
|
@ -157,10 +157,6 @@
|
||||||
<text x="178" y="144" text-anchor="middle" fill="#ffffff">mf03 (U5–U6)</text>
|
<text x="178" y="144" text-anchor="middle" fill="#ffffff">mf03 (U5–U6)</text>
|
||||||
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
<text x="468" y="144" text-anchor="middle" fill="#ffffff">mf04 (U5–U6)</text>
|
<text x="468" y="144" text-anchor="middle" fill="#ffffff">mf04 (U5–U6)</text>
|
||||||
<rect x="59" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333"/>
|
|
||||||
<text x="178" y="234" text-anchor="middle" fill="#ffffff">sw01 (U10)</text>
|
|
||||||
<rect x="59" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333"/>
|
|
||||||
<text x="178" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
|
|
||||||
<rect x="12" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
<rect x="12" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
||||||
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
|
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
|
||||||
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -25,25 +25,6 @@ flowchart LR
|
||||||
pdu02 -->|outlet 1| mf00
|
pdu02 -->|outlet 1| mf00
|
||||||
```
|
```
|
||||||
|
|
||||||
## Network
|
|
||||||
|
|
||||||
```mermaid
|
|
||||||
flowchart LR
|
|
||||||
mf00["mf00"]
|
|
||||||
mf01["mf01"]
|
|
||||||
mf02["mf02"]
|
|
||||||
mf03["mf03"]
|
|
||||||
mf04["mf04"]
|
|
||||||
pp01["pp01<br/>patch-panel"]
|
|
||||||
sw01["sw01<br/>switch"]
|
|
||||||
mf00 -->|eth0 → p1 · 1G| sw01
|
|
||||||
mf01 -->|eth0 → p1 · 1G| pp01
|
|
||||||
mf02 -->|eth0 → p2 · 1G| pp01
|
|
||||||
mf03 -->|eth0 → p3 · 1G| pp01
|
|
||||||
mf04 -->|eth0 → p4 · 1G| pp01
|
|
||||||
pp01 -->|uplink → p24 · 1G| sw01
|
|
||||||
```
|
|
||||||
|
|
||||||
## Occupancy
|
## Occupancy
|
||||||
|
|
||||||
| U | Device | Kind | Face | Status |
|
| U | Device | Kind | Face | Status |
|
||||||
|
|
@ -53,7 +34,5 @@ flowchart LR
|
||||||
| U3 | [mf02](../../hardware/mf02.md) | server | front | staging |
|
| U3 | [mf02](../../hardware/mf02.md) | server | front | staging |
|
||||||
| U5–U6 | [mf03](../../hardware/mf03.md) | server | front | staging |
|
| U5–U6 | [mf03](../../hardware/mf03.md) | server | front | staging |
|
||||||
| U5–U6 | [mf04](../../hardware/mf04.md) | server | rear | staging |
|
| U5–U6 | [mf04](../../hardware/mf04.md) | server | rear | staging |
|
||||||
| U10 | [sw01](../../hardware/sw01.md) | switch | front | in-use |
|
|
||||||
| U24 | [pp01](../../hardware/pp01.md) | patch-panel | front | in-use |
|
|
||||||
| 0U | [pdu01](../../hardware/pdu01.md) | pdu | left | in-use |
|
| 0U | [pdu01](../../hardware/pdu01.md) | pdu | left | in-use |
|
||||||
| 0U | [pdu02](../../hardware/pdu02.md) | pdu | right | in-use |
|
| 0U | [pdu02](../../hardware/pdu02.md) | pdu | right | in-use |
|
||||||
|
|
|
||||||
|
|
@ -1,593 +0,0 @@
|
||||||
# Rack Network (Phase 3) Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Add network-cabling data (`links:` feeds + switch/patch-panel peer files) to the rack pipeline, validate it (rule 4), and render a mermaid network graph on the generated rack page — reusing every Phase 1/2 mechanism.
|
|
||||||
|
|
||||||
**Architecture:** Extend the existing `scripts/gen_rack.py` with `load_hardware_index` (global hostname→frontmatter map for peer resolution), `validate_links` (rule 4), and `render_network` (a `flowchart LR` with local interface, peer port, and speed on each edge label); insert a `## Network` section into `render_page` between Power and Occupancy. Switch/patch-panel files are normal placed items that Phase 1 already draws and `gen_overview.py` already lists. Mermaid is already enabled.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
|
|
||||||
|
|
||||||
**Spec:** `notes/dev/specs/2026-06-24-rack-network-design.md`.
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy existing `gen_rack.py` style). No randomness/time in generated output.
|
|
||||||
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
|
|
||||||
- `_node_id` (Phase 2) is reused for mermaid node ids — do not redefine it.
|
|
||||||
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing behaviour).
|
|
||||||
- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner (already emitted by `render_page`).
|
|
||||||
- **Peer resolution is global** (against all `docs/hardware/*.md` hostnames), not per-rack — rule 4 says "resolves to a real file".
|
|
||||||
- `peer_port` range is checked **only when the peer declares an integer `ports`**.
|
|
||||||
- Edge label format: `{local} → p{peer_port} · {speed}G`, with the ` · {speed}G` suffix omitted when `speed_gbps` is absent. Use the unicode arrow `→` (not `->`) to avoid clashing with mermaid's `-->` syntax.
|
|
||||||
- A node whose kind is `switch` or `patch-panel` renders as `{name}<br/>{kind}`; all other nodes render as the bare hostname.
|
|
||||||
- Network data added here is **provisional placeholder data** (like the mfNN positions and the Phase 2 power data), not real values.
|
|
||||||
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`switch`/`patch-panel`/`ap` already in the enum; drift already covers `racks/`).
|
|
||||||
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: `load_hardware_index` + `validate_links` — rule 4 (TDD)
|
|
||||||
|
|
||||||
Add the global peer index and link validation, and wire `validate_links` into `generate`. Testable on validation alone.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `scripts/gen_rack.py` (add `load_hardware_index`, `validate_links`; build the index and call `validate_links` in `generate`)
|
|
||||||
- Modify: `tests/test_gen_rack.py` (append tests)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `SchemaError`, `parse_frontmatter`, the `item()`/`_write_item` test helpers, `generate`.
|
|
||||||
- Produces:
|
|
||||||
- `load_hardware_index(hardware_dir: Path) -> dict[str, dict]` — `{hostname: frontmatter}` for every `*.md` (excluding `index.md`).
|
|
||||||
- `validate_links(items: list[dict], hw_index: dict[str, dict]) -> None` — raises `SchemaError` on a malformed/dangling link.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_load_hardware_index_maps_all_hostnames(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "sw01",
|
|
||||||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
|
|
||||||
)
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
|
||||||
)
|
|
||||||
idx = gen_rack.load_hardware_index(hw)
|
|
||||||
assert set(idx) == {"sw01", "mf00"}
|
|
||||||
assert idx["sw01"]["ports"] == 24
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_accepts_valid_link():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_unknown_peer():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, {})
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_peer_port_over_count():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_accepts_peer_without_ports():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
|
|
||||||
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
|
|
||||||
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_missing_local():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"peer": "sw01", "peer_port": 1}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_malformed_entry():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=["sw01"])]
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, {})
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_returns_1_on_bad_link_peer(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
out = tmp_path / "out"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
|
||||||
"links:\n - { local: eth0, peer: ghost, peer_port: 1 }\n---\n",
|
|
||||||
)
|
|
||||||
rc = gen_rack.generate(hw, out)
|
|
||||||
assert rc == 1
|
|
||||||
assert not (out / "rack01.md").exists()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'load_hardware_index'`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `load_hardware_index` and `validate_links` after `check_overlaps` in `scripts/gen_rack.py`**
|
|
||||||
|
|
||||||
Add these two functions (place them just after `check_overlaps`, before `_pdu_index`):
|
|
||||||
|
|
||||||
```python
|
|
||||||
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
|
|
||||||
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
|
|
||||||
index: dict[str, dict] = {}
|
|
||||||
for path in sorted(hardware_dir.glob("*.md")):
|
|
||||||
if path.name == "index.md":
|
|
||||||
continue
|
|
||||||
fm = parse_frontmatter(path)
|
|
||||||
if fm is None:
|
|
||||||
continue
|
|
||||||
name = fm.get("hostname")
|
|
||||||
if isinstance(name, str) and name:
|
|
||||||
index[name] = fm
|
|
||||||
return index
|
|
||||||
|
|
||||||
|
|
||||||
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
|
|
||||||
"""Validate `links` cable declarations (rule 4).
|
|
||||||
|
|
||||||
Every links[].peer must resolve to a real hardware file (global lookup via
|
|
||||||
hw_index); peer_port must fall within the peer's declared `ports` when it
|
|
||||||
declares an integer count.
|
|
||||||
"""
|
|
||||||
for fm in items:
|
|
||||||
links = fm.get("links")
|
|
||||||
if links is None:
|
|
||||||
continue
|
|
||||||
name = fm.get("hostname", "?")
|
|
||||||
if not isinstance(links, list):
|
|
||||||
raise SchemaError(f"{name}: links must be a list")
|
|
||||||
for link in links:
|
|
||||||
if not isinstance(link, dict):
|
|
||||||
raise SchemaError(f"{name}: links entry must be a mapping")
|
|
||||||
local = link.get("local")
|
|
||||||
peer = link.get("peer")
|
|
||||||
peer_port = link.get("peer_port")
|
|
||||||
if not isinstance(local, str) or not local:
|
|
||||||
raise SchemaError(f"{name}: links entry needs a non-empty 'local'")
|
|
||||||
if not isinstance(peer, str) or not peer:
|
|
||||||
raise SchemaError(f"{name}: links entry needs a non-empty 'peer'")
|
|
||||||
if not isinstance(peer_port, int):
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: links entry for {peer} needs an integer 'peer_port'"
|
|
||||||
)
|
|
||||||
target = hw_index.get(peer)
|
|
||||||
if target is None:
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: links peer={peer!r} is not a known hardware file"
|
|
||||||
)
|
|
||||||
ports = target.get("ports")
|
|
||||||
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: peer_port {peer_port} out of range 1..{ports} on {peer}"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Wire `validate_links` into `generate` in `scripts/gen_rack.py`**
|
|
||||||
|
|
||||||
`generate` currently begins:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
|
||||||
items = load_rack_items(hardware_dir)
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
```
|
|
||||||
|
|
||||||
Add the global index right after `items` is loaded:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
|
||||||
items = load_rack_items(hardware_dir)
|
|
||||||
hw_index = load_hardware_index(hardware_dir)
|
|
||||||
|
|
||||||
errors: list[str] = []
|
|
||||||
```
|
|
||||||
|
|
||||||
Then extend the per-rack validation loop. Replace:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not errors: # only check overlaps once placements are individually valid
|
|
||||||
for rack, ritems in racks.items():
|
|
||||||
try:
|
|
||||||
check_overlaps(ritems)
|
|
||||||
validate_power(ritems)
|
|
||||||
except SchemaError as e:
|
|
||||||
errors.append(f"{rack}: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```python
|
|
||||||
if not errors: # only check overlaps once placements are individually valid
|
|
||||||
for rack, ritems in racks.items():
|
|
||||||
try:
|
|
||||||
check_overlaps(ritems)
|
|
||||||
validate_power(ritems)
|
|
||||||
validate_links(ritems, hw_index)
|
|
||||||
except SchemaError as e:
|
|
||||||
errors.append(f"{rack}: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run to verify pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: PASS (all prior tests + 8 new).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
|
||||||
git commit -m "feat(rack): validate network links against peer files and ports"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: `render_network` + page section (TDD)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `scripts/gen_rack.py` (add `render_network`; edit `render_page`)
|
|
||||||
- Modify: `tests/test_gen_rack.py` (append tests)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `_node_id` (Phase 2), `render_page`, `generate`.
|
|
||||||
- Produces: `render_network(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `links` feed.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_render_network_has_nodes_and_edge_labels():
|
|
||||||
items = [
|
|
||||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "```mermaid" in out
|
|
||||||
assert "flowchart LR" in out
|
|
||||||
assert "sw01<br/>switch" in out
|
|
||||||
assert "mf00" in out
|
|
||||||
assert "eth0" in out
|
|
||||||
assert "p1" in out
|
|
||||||
assert "1G" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_patch_panel_subtitle():
|
|
||||||
items = [
|
|
||||||
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "pp01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "pp01<br/>patch-panel" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_empty_when_no_links():
|
|
||||||
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
|
|
||||||
assert gen_rack.render_network("rack01", items) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_omits_speed_when_absent():
|
|
||||||
items = [
|
|
||||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "eth0" in out and "p1" in out
|
|
||||||
assert "·" not in out # no speed suffix rendered
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_is_deterministic():
|
|
||||||
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24)
|
|
||||||
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 2, "speed_gbps": 1}])
|
|
||||||
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}])
|
|
||||||
assert gen_rack.render_network("rack01", [a, b, c]) == \
|
|
||||||
gen_rack.render_network("rack01", [c, b, a])
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_includes_network_section(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
out = tmp_path / "out"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "sw01",
|
|
||||||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
|
|
||||||
)
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
|
||||||
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
|
|
||||||
)
|
|
||||||
rc = gen_rack.generate(hw, out)
|
|
||||||
assert rc == 0
|
|
||||||
page = (out / "rack01.md").read_text()
|
|
||||||
assert "## Network" in page
|
|
||||||
assert "```mermaid" in page
|
|
||||||
assert "eth0" in page
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_network'`.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add `render_network` after `render_power` in `scripts/gen_rack.py`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def render_network(rack: str, items: list[dict]) -> str:
|
|
||||||
"""Return a mermaid network-cabling flowchart, or '' if no links.
|
|
||||||
|
|
||||||
Assumes `validate_links` has already passed: every link has a non-empty
|
|
||||||
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
|
|
||||||
hardware file. `generate` validates before any render call.
|
|
||||||
"""
|
|
||||||
linked = [fm for fm in items if fm.get("links")]
|
|
||||||
if not linked:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
by_host = {fm.get("hostname"): fm for fm in items}
|
|
||||||
|
|
||||||
edges: list[tuple[str, str, str, int, object]] = []
|
|
||||||
nodes: set[str] = set()
|
|
||||||
for fm in linked:
|
|
||||||
source = fm.get("hostname", "?")
|
|
||||||
nodes.add(source)
|
|
||||||
for link in fm["links"]:
|
|
||||||
peer = link["peer"]
|
|
||||||
nodes.add(peer)
|
|
||||||
edges.append(
|
|
||||||
(source, link["local"], peer, link["peer_port"],
|
|
||||||
link.get("speed_gbps"))
|
|
||||||
)
|
|
||||||
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
|
|
||||||
|
|
||||||
def node_label(name: str) -> str:
|
|
||||||
fm = by_host.get(name)
|
|
||||||
kind = fm.get("kind") if fm else None
|
|
||||||
if kind in ("switch", "patch-panel"):
|
|
||||||
return f"{name}<br/>{kind}"
|
|
||||||
return name
|
|
||||||
|
|
||||||
lines: list[str] = ["```mermaid", "flowchart LR"]
|
|
||||||
for name in sorted(nodes):
|
|
||||||
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
|
|
||||||
for source, local, peer, peer_port, speed in edges:
|
|
||||||
label = f"{local} → p{peer_port}"
|
|
||||||
if speed is not None:
|
|
||||||
label += f" · {speed}G"
|
|
||||||
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
|
|
||||||
lines.append("```")
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Insert the `## Network` section in `render_page` in `scripts/gen_rack.py`**
|
|
||||||
|
|
||||||
`render_page` currently has this block (the Power section followed directly by Occupancy):
|
|
||||||
|
|
||||||
```python
|
|
||||||
power = render_power(rack, items)
|
|
||||||
if power:
|
|
||||||
lines.append("## Power")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(power.rstrip())
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Occupancy")
|
|
||||||
```
|
|
||||||
|
|
||||||
Insert the Network section between the Power block and the Occupancy line:
|
|
||||||
|
|
||||||
```python
|
|
||||||
power = render_power(rack, items)
|
|
||||||
if power:
|
|
||||||
lines.append("## Power")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(power.rstrip())
|
|
||||||
lines.append("")
|
|
||||||
network = render_network(rack, items)
|
|
||||||
if network:
|
|
||||||
lines.append("## Network")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(network.rstrip())
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Occupancy")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run to verify pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: PASS (all prior tests + 6 new).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
|
||||||
git commit -m "feat(rack): render mermaid network graph into the rack page"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Populate provisional network data, regenerate
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `docs/hardware/sw01.md`, `docs/hardware/pp01.md`
|
|
||||||
- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `links:`)
|
|
||||||
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
|
|
||||||
|
|
||||||
> **Operator note — provisional data.** The switch/patch-panel placements and the cable assignments below are placeholders proving the feature, matching the existing fictional mfNN positions and Phase 2 power data. Replace with real values when known; `validate_links` rejects dangling peers and over-count ports loudly. sw01/pp01 deliberately get no `power:` feeds in this phase.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the switch and patch-panel files**
|
|
||||||
|
|
||||||
Create `docs/hardware/sw01.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
hostname: sw01
|
|
||||||
kind: switch
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 10
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
ports: 24
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder switch. Port assignments are not yet real.
|
|
||||||
```
|
|
||||||
|
|
||||||
Create `docs/hardware/pp01.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
hostname: pp01
|
|
||||||
kind: patch-panel
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 24
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
ports: 24
|
|
||||||
links:
|
|
||||||
- { local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder patch panel. Devices patch in here; rear uplink to sw01.
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Add `links:` to the five host files**
|
|
||||||
|
|
||||||
These files already carry rack-placement and `power:` frontmatter. ADD a `links:` block to each (before the closing `---`); do not remove anything.
|
|
||||||
|
|
||||||
In `docs/hardware/mf00.md` add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
links:
|
|
||||||
- { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
In `docs/hardware/mf01.md` add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
In `docs/hardware/mf02.md` add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 2, speed_gbps: 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
In `docs/hardware/mf03.md` add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 3, speed_gbps: 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
In `docs/hardware/mf04.md` add:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
links:
|
|
||||||
- { local: eth0, peer: pp01, peer_port: 4, speed_gbps: 1 }
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Regenerate all indices and rack artifacts**
|
|
||||||
|
|
||||||
Run: `make docs-index`
|
|
||||||
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing sw01 under "Switches" and pp01 under "Patch panels"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (9 item(s))`.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Confirm the generated page has a network graph and the new boxes**
|
|
||||||
|
|
||||||
Run: `grep -c "→ p" docs/infrastructure/racks/rack01.md`
|
|
||||||
Expected: `6` (one network edge per link: mf00→sw01, mf01..mf04→pp01, pp01→sw01).
|
|
||||||
|
|
||||||
Run: `grep -q "sw01" docs/infrastructure/racks/rack01-elevation.svg && grep -q "pp01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
|
|
||||||
Expected: `OK` (switch and patch-panel drawn as boxes in the elevation).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `make test`
|
|
||||||
Expected: PASS (all tests).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Build the site strictly**
|
|
||||||
|
|
||||||
Run: `mkdocs build --strict` (if `mkdocs` is not on PATH, use `python3 -m mkdocs build --strict`)
|
|
||||||
Expected: build succeeds with no warnings-as-errors.
|
|
||||||
|
|
||||||
Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html`
|
|
||||||
Expected: `≥ 2` (a power block and a network block both render as mermaid diagrams).
|
|
||||||
|
|
||||||
- [ ] **Step 7: Confirm the drift guard is satisfied**
|
|
||||||
|
|
||||||
Run: `make docs-check`
|
|
||||||
Expected: exit 0 — committed artifacts match a fresh regeneration.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/hardware/ docs/infrastructure/racks/
|
|
||||||
git commit -m "feat(rack): populate provisional network topology (sw01, pp01, links)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**Spec coverage (`2026-06-24-rack-network-design.md`):**
|
|
||||||
- `links:` frontmatter on devices/peers — Task 3 (populate); validated Task 1. ✔
|
|
||||||
- Switch + patch-panel peer files (`ports`, placed 1U front) — Task 3; appear via Phase 1 SVG + gen_overview, no new code. ✔
|
|
||||||
- Validation rule 4 (peer resolves to a real file globally; peer_port within `ports` when declared; malformed/missing fields) — Task 1 (`validate_links` + `load_hardware_index`), wired into `generate`. ✔
|
|
||||||
- Global peer resolution (not per-rack) — Task 1 (`load_hardware_index` over all files; `generate` passes `hw_index`). ✔
|
|
||||||
- Mermaid network graph, full edge label (local → port · speed), kind subtitle for switch/patch-panel, omit-when-empty, deterministic — Task 2 (`render_network`), inserted in `render_page` between Power and Occupancy. ✔
|
|
||||||
- Node-id sanitization reused (`_node_id`) — Task 2. ✔
|
|
||||||
- Speed omitted when absent; unicode `→` — Task 2 (label build), tested. ✔
|
|
||||||
- No mkdocs/Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 3/7. ✔
|
|
||||||
- Provisional data (mf01–mf04 → pp01 1–4; pp01 uplink → sw01:24; mf00 → sw01:1) — Task 3 Steps 1–2. ✔
|
|
||||||
|
|
||||||
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional network values, explicitly bounded and guarded by `validate_links`.
|
|
||||||
|
|
||||||
**Type consistency:** `load_hardware_index` → `dict[str, dict]`; `validate_links(items, hw_index)`/`check_overlaps`/`validate_power` → `None` (raise `SchemaError`); `render_network`/`render_power`/`render_page`/`_node_id` → `str`; `generate` → `int` (0/1). `validate_links(ritems, hw_index)` is called per-rack alongside `check_overlaps`/`validate_power`, with `hw_index` built once at the top of `generate`. `render_network` consumes `_node_id` and feeds `render_page`. Names match across tasks and tests.
|
|
||||||
|
|
@ -1,178 +0,0 @@
|
||||||
# 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.
|
|
||||||
|
|
@ -120,61 +120,6 @@ def check_overlaps(items: list[dict]) -> None:
|
||||||
occupied[key] = name
|
occupied[key] = name
|
||||||
|
|
||||||
|
|
||||||
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
|
|
||||||
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
|
|
||||||
index: dict[str, dict] = {}
|
|
||||||
for path in sorted(hardware_dir.glob("*.md")):
|
|
||||||
if path.name == "index.md":
|
|
||||||
continue
|
|
||||||
fm = parse_frontmatter(path)
|
|
||||||
if fm is None:
|
|
||||||
continue
|
|
||||||
name = fm.get("hostname")
|
|
||||||
if isinstance(name, str) and name:
|
|
||||||
index[name] = fm
|
|
||||||
return index
|
|
||||||
|
|
||||||
|
|
||||||
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
|
|
||||||
"""Validate `links` cable declarations (rule 4).
|
|
||||||
|
|
||||||
Every links[].peer must resolve to a real hardware file (global lookup via
|
|
||||||
hw_index); peer_port must fall within the peer's declared `ports` when it
|
|
||||||
declares an integer count.
|
|
||||||
"""
|
|
||||||
for fm in items:
|
|
||||||
links = fm.get("links")
|
|
||||||
if links is None:
|
|
||||||
continue
|
|
||||||
name = fm.get("hostname", "?")
|
|
||||||
if not isinstance(links, list):
|
|
||||||
raise SchemaError(f"{name}: links must be a list")
|
|
||||||
for link in links:
|
|
||||||
if not isinstance(link, dict):
|
|
||||||
raise SchemaError(f"{name}: links entry must be a mapping")
|
|
||||||
local = link.get("local")
|
|
||||||
peer = link.get("peer")
|
|
||||||
peer_port = link.get("peer_port")
|
|
||||||
if not isinstance(local, str) or not local:
|
|
||||||
raise SchemaError(f"{name}: links entry needs a non-empty 'local'")
|
|
||||||
if not isinstance(peer, str) or not peer:
|
|
||||||
raise SchemaError(f"{name}: links entry needs a non-empty 'peer'")
|
|
||||||
if not isinstance(peer_port, int):
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: links entry for {peer} needs an integer 'peer_port'"
|
|
||||||
)
|
|
||||||
target = hw_index.get(peer)
|
|
||||||
if target is None:
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: links peer={peer!r} is not a known hardware file"
|
|
||||||
)
|
|
||||||
ports = target.get("ports")
|
|
||||||
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
|
|
||||||
raise SchemaError(
|
|
||||||
f"{name}: peer_port {peer_port} out of range 1..{ports} on {peer}"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _pdu_index(items: list[dict]) -> dict[str, dict]:
|
def _pdu_index(items: list[dict]) -> dict[str, dict]:
|
||||||
"""Map hostname -> frontmatter for every kind:pdu item."""
|
"""Map hostname -> frontmatter for every kind:pdu item."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -393,52 +338,6 @@ def render_power(rack: str, items: list[dict]) -> str:
|
||||||
return "\n".join(lines) + "\n"
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
|
||||||
def render_network(rack: str, items: list[dict]) -> str:
|
|
||||||
"""Return a mermaid network-cabling flowchart, or '' if no links.
|
|
||||||
|
|
||||||
Assumes `validate_links` has already passed: every link has a non-empty
|
|
||||||
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
|
|
||||||
hardware file. `generate` validates before any render call.
|
|
||||||
"""
|
|
||||||
linked = [fm for fm in items if fm.get("links")]
|
|
||||||
if not linked:
|
|
||||||
return ""
|
|
||||||
|
|
||||||
by_host = {fm.get("hostname"): fm for fm in items}
|
|
||||||
|
|
||||||
edges: list[tuple[str, str, str, int, object]] = []
|
|
||||||
nodes: set[str] = set()
|
|
||||||
for fm in linked:
|
|
||||||
source = fm.get("hostname", "?")
|
|
||||||
nodes.add(source)
|
|
||||||
for link in fm["links"]:
|
|
||||||
peer = link["peer"]
|
|
||||||
nodes.add(peer)
|
|
||||||
edges.append(
|
|
||||||
(source, link["local"], peer, link["peer_port"],
|
|
||||||
link.get("speed_gbps"))
|
|
||||||
)
|
|
||||||
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
|
|
||||||
|
|
||||||
def node_label(name: str) -> str:
|
|
||||||
fm = by_host.get(name)
|
|
||||||
kind = fm.get("kind") if fm else None
|
|
||||||
if kind in ("switch", "patch-panel"):
|
|
||||||
return f"{name}<br/>{kind}"
|
|
||||||
return name
|
|
||||||
|
|
||||||
lines: list[str] = ["```mermaid", "flowchart LR"]
|
|
||||||
for name in sorted(nodes):
|
|
||||||
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
|
|
||||||
for source, local, peer, peer_port, speed in edges:
|
|
||||||
label = f"{local} → p{peer_port}"
|
|
||||||
if speed is not None:
|
|
||||||
label += f" · {speed}G"
|
|
||||||
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
|
|
||||||
lines.append("```")
|
|
||||||
return "\n".join(lines) + "\n"
|
|
||||||
|
|
||||||
|
|
||||||
def render_page(rack: str, items: list[dict]) -> str:
|
def render_page(rack: str, items: list[dict]) -> str:
|
||||||
items = _sorted_items(items)
|
items = _sorted_items(items)
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
|
|
@ -460,12 +359,6 @@ def render_page(rack: str, items: list[dict]) -> str:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append(power.rstrip())
|
lines.append(power.rstrip())
|
||||||
lines.append("")
|
lines.append("")
|
||||||
network = render_network(rack, items)
|
|
||||||
if network:
|
|
||||||
lines.append("## Network")
|
|
||||||
lines.append("")
|
|
||||||
lines.append(network.rstrip())
|
|
||||||
lines.append("")
|
|
||||||
lines.append("## Occupancy")
|
lines.append("## Occupancy")
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("| U | Device | Kind | Face | Status |")
|
lines.append("| U | Device | Kind | Face | Status |")
|
||||||
|
|
@ -490,7 +383,6 @@ def render_page(rack: str, items: list[dict]) -> str:
|
||||||
|
|
||||||
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
||||||
items = load_rack_items(hardware_dir)
|
items = load_rack_items(hardware_dir)
|
||||||
hw_index = load_hardware_index(hardware_dir)
|
|
||||||
|
|
||||||
errors: list[str] = []
|
errors: list[str] = []
|
||||||
for fm in items:
|
for fm in items:
|
||||||
|
|
@ -508,7 +400,6 @@ def generate(hardware_dir: Path, output_dir: Path) -> int:
|
||||||
try:
|
try:
|
||||||
check_overlaps(ritems)
|
check_overlaps(ritems)
|
||||||
validate_power(ritems)
|
validate_power(ritems)
|
||||||
validate_links(ritems, hw_index)
|
|
||||||
except SchemaError as e:
|
except SchemaError as e:
|
||||||
errors.append(f"{rack}: {e}")
|
errors.append(f"{rack}: {e}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -342,171 +342,3 @@ def test_generate_includes_power_section(tmp_path):
|
||||||
assert "## Power" in page
|
assert "## Power" in page
|
||||||
assert "```mermaid" in page
|
assert "```mermaid" in page
|
||||||
assert "outlet 1" in page
|
assert "outlet 1" in page
|
||||||
|
|
||||||
|
|
||||||
def test_load_hardware_index_maps_all_hostnames(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "sw01",
|
|
||||||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
|
|
||||||
)
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
|
||||||
)
|
|
||||||
idx = gen_rack.load_hardware_index(hw)
|
|
||||||
assert set(idx) == {"sw01", "mf00"}
|
|
||||||
assert idx["sw01"]["ports"] == 24
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_accepts_valid_link():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_unknown_peer():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, {})
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_peer_port_over_count():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_peer_port_below_one():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 0}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_accepts_peer_without_ports():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
|
|
||||||
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
|
|
||||||
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_missing_local():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"peer": "sw01", "peer_port": 1}])]
|
|
||||||
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, hw_index)
|
|
||||||
|
|
||||||
|
|
||||||
def test_validate_links_rejects_malformed_entry():
|
|
||||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=["sw01"])]
|
|
||||||
with pytest.raises(gen_rack.SchemaError):
|
|
||||||
gen_rack.validate_links(items, {})
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_returns_1_on_bad_link_peer(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
out = tmp_path / "out"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
|
||||||
"links:\n - { local: eth0, peer: ghost, peer_port: 1 }\n---\n",
|
|
||||||
)
|
|
||||||
rc = gen_rack.generate(hw, out)
|
|
||||||
assert rc == 1
|
|
||||||
assert not (out / "rack01.md").exists()
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_has_nodes_and_edge_labels():
|
|
||||||
items = [
|
|
||||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "```mermaid" in out
|
|
||||||
assert "flowchart LR" in out
|
|
||||||
assert "sw01<br/>switch" in out
|
|
||||||
assert "mf00" in out
|
|
||||||
assert "eth0" in out
|
|
||||||
assert "p1" in out
|
|
||||||
assert "1G" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_patch_panel_subtitle():
|
|
||||||
items = [
|
|
||||||
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "pp01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "pp01<br/>patch-panel" in out
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_empty_when_no_links():
|
|
||||||
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
|
|
||||||
assert gen_rack.render_network("rack01", items) == ""
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_omits_speed_when_absent():
|
|
||||||
items = [
|
|
||||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24),
|
|
||||||
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
|
|
||||||
]
|
|
||||||
out = gen_rack.render_network("rack01", items)
|
|
||||||
assert "eth0" in out and "p1" in out
|
|
||||||
assert "·" not in out # no speed suffix rendered
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_network_is_deterministic():
|
|
||||||
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
|
||||||
rack_face="front", ports=24)
|
|
||||||
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 2, "speed_gbps": 1}])
|
|
||||||
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
|
|
||||||
links=[{"local": "eth0", "peer": "sw01",
|
|
||||||
"peer_port": 1, "speed_gbps": 1}])
|
|
||||||
assert gen_rack.render_network("rack01", [a, b, c]) == \
|
|
||||||
gen_rack.render_network("rack01", [c, b, a])
|
|
||||||
|
|
||||||
|
|
||||||
def test_generate_includes_network_section(tmp_path):
|
|
||||||
hw = tmp_path / "hardware"
|
|
||||||
out = tmp_path / "out"
|
|
||||||
hw.mkdir()
|
|
||||||
_write_item(
|
|
||||||
hw, "sw01",
|
|
||||||
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
|
|
||||||
)
|
|
||||||
_write_item(
|
|
||||||
hw, "mf00",
|
|
||||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
|
||||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
|
|
||||||
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
|
|
||||||
)
|
|
||||||
rc = gen_rack.generate(hw, out)
|
|
||||||
assert rc == 0
|
|
||||||
page = (out / "rack01.md").read_text()
|
|
||||||
assert "## Network" in page
|
|
||||||
assert "```mermaid" in page
|
|
||||||
assert "eth0" in page
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue