From a45d6d0266ba25d69622ce935e9cc71ad76c0c8c Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 14:33:18 +0200 Subject: [PATCH] docs(rack): Phase 2 power implementation plan --- notes/dev/plans/2026-06-24-rack-power.md | 553 +++++++++++++++++++++++ 1 file changed, 553 insertions(+) create mode 100644 notes/dev/plans/2026-06-24-rack-power.md diff --git a/notes/dev/plans/2026-06-24-rack-power.md b/notes/dev/plans/2026-06-24-rack-power.md new file mode 100644 index 0000000..bc00e35 --- /dev/null +++ b/notes/dev/plans/2026-06-24-rack-power.md @@ -0,0 +1,553 @@ +# Rack Power (Phase 2) 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 power-distribution data (`power:` feeds + PDU files) to the rack pipeline, validate it, and render a mermaid power graph on the generated rack page — reusing every Phase 1 mechanism. + +**Architecture:** Extend the existing `scripts/gen_rack.py` with `validate_power` (rule 3 from the spec) and `render_power` (a `flowchart LR` with the outlet number as the edge label); insert a `## Power` section into `render_page`. PDU files are 0U `left`/`right` items that Phase 1 already renders as side-rails and `gen_overview.py` already lists. Pull the mermaid superfences fence forward into `mkdocs.yml` so the graph renders. + +**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI. + +**Spec:** `notes/dev/specs/2026-06-24-rack-power-design.md`. + +## Global Constraints + +- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_rack.py`/`gen_overview.py` style). No randomness/time in generated output. +- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports. +- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing Phase 1 behaviour). +- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner. +- PDU files are **0U**: `rack_face: left|right`, **no** `rack_u`/`u_height`, and a positive-int `outlets`. +- Power data added here is **provisional placeholder data** (like the existing `mfNN` U positions), not real values. +- The Makefile `docs-check` and CI drift step already diff the whole `docs/infrastructure/racks/` dir — **do not edit** `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`. +- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration. + +--- + +### Task 1: `validate_power` — rule 3 validation (TDD) + +Add PDU lookup + power-feed validation and wire it into `generate`. This task is testable on validation alone (no rendering needed). + +**Files:** +- Modify: `scripts/gen_rack.py` (add `_pdu_index`, `validate_power`; call in `generate`) +- Modify: `tests/test_gen_rack.py` (append tests) + +**Interfaces:** +- Consumes: `SchemaError`, the `item()`/`_write_item` test helpers, `generate`. +- Produces: + - `_pdu_index(items: list[dict]) -> dict[str, dict]` — `{hostname: fm}` for `kind == "pdu"` items. + - `validate_power(items: list[dict]) -> None` — raises `SchemaError` on a bad PDU `outlets` declaration or a bad `power` feed. + +- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`** + +```python +def test_validate_power_accepts_valid_feed(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}]), + ] + gen_rack.validate_power(items) + + +def test_validate_power_rejects_unknown_pdu(): + items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "ghost", "outlet": 1}])] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_validate_power_rejects_non_pdu_target(): + items = [ + item(hostname="sw01", kind="switch", rack_u=1, u_height=1, + rack_face="front"), + item(hostname="mf00", rack_u=2, u_height=1, rack_face="front", + power=[{"pdu": "sw01", "outlet": 1}]), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_validate_power_rejects_outlet_over_count(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 9}]), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_validate_power_rejects_outlet_zero(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 0}]), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_validate_power_rejects_malformed_entry(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=["pdu01"]), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_validate_power_rejects_pdu_without_outlets(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left"), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}]), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_power(items) + + +def test_generate_returns_1_on_bad_power_ref(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" + "power:\n - { pdu: ghost, outlet: 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 'validate_power'`. + +- [ ] **Step 3: Add `_pdu_index` and `validate_power` after `check_overlaps` in `scripts/gen_rack.py`** + +```python +def _pdu_index(items: list[dict]) -> dict[str, dict]: + """Map hostname -> frontmatter for every kind:pdu item.""" + return { + fm.get("hostname"): fm + for fm in items + if fm.get("kind") == "pdu" + } + + +def validate_power(items: list[dict]) -> None: + """Validate PDU outlet declarations and `power` feeds within one rack. + + Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is + within that PDU's `outlets` count. + """ + pdus = _pdu_index(items) + for name, fm in pdus.items(): + outlets = fm.get("outlets") + if not isinstance(outlets, int) or outlets < 1: + raise SchemaError( + f"{name}: kind:pdu must declare a positive integer 'outlets'" + ) + for fm in items: + feeds = fm.get("power") + if feeds is None: + continue + name = fm.get("hostname", "?") + if not isinstance(feeds, list): + raise SchemaError(f"{name}: power must be a list") + for feed in feeds: + if not isinstance(feed, dict): + raise SchemaError(f"{name}: power entry must be a mapping") + pdu = feed.get("pdu") + outlet = feed.get("outlet") + if not isinstance(pdu, str) or not pdu: + raise SchemaError(f"{name}: power entry needs a non-empty 'pdu'") + if not isinstance(outlet, int): + raise SchemaError( + f"{name}: power entry for {pdu} needs an integer 'outlet'" + ) + target = pdus.get(pdu) + if target is None: + raise SchemaError( + f"{name}: power pdu={pdu!r} is not a known kind:pdu file" + ) + count = target["outlets"] + if outlet < 1 or outlet > count: + raise SchemaError( + f"{name}: outlet {outlet} out of range 1..{count} on {pdu}" + ) +``` + +- [ ] **Step 4: Wire `validate_power` into `generate` in `scripts/gen_rack.py`** + +Change the overlap loop so it also validates power. Replace: + +```python + if not errors: # only check overlaps once placements are individually valid + for rack, ritems in racks.items(): + try: + check_overlaps(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) + 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 power feeds against PDU outlets" +``` + +--- + +### Task 2: `render_power` + page section (TDD) + +**Files:** +- Modify: `scripts/gen_rack.py` (add `_node_id`, `render_power`; edit `render_page`) +- Modify: `tests/test_gen_rack.py` (append tests) + +**Interfaces:** +- Consumes: `_pdu_index` (Task 1), `render_page`, `generate`. +- Produces: + - `_node_id(name: str) -> str` — hostname with non-alphanumeric chars replaced by `_`. + - `render_power(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `power` feed. + +- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`** + +```python +def test_render_power_has_nodes_and_edge_labels(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 3}]), + ] + out = gen_rack.render_power("rack01", items) + assert "```mermaid" in out + assert "flowchart LR" in out + assert "pdu01" in out + assert "8 outlets" in out + assert "outlet 3" in out + assert "mf00" in out + + +def test_render_power_redundant_device_has_two_edges(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}, + {"pdu": "pdu02", "outlet": 1}]), + ] + out = gen_rack.render_power("rack01", items) + assert out.count("-->|outlet") == 2 + + +def test_render_power_empty_when_no_feeds(): + items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")] + assert gen_rack.render_power("rack01", items) == "" + + +def test_render_power_is_deterministic(): + a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8) + b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 2}]) + c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}]) + assert gen_rack.render_power("rack01", [a, b, c]) == \ + gen_rack.render_power("rack01", [c, b, a]) + + +def test_generate_includes_power_section(tmp_path): + hw = tmp_path / "hardware" + out = tmp_path / "out" + hw.mkdir() + _write_item( + hw, + "pdu01", + "---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n" + "rack: rack01\nrack_face: left\noutlets: 8\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" + "power:\n - { pdu: pdu01, outlet: 1 }\n---\n", + ) + rc = gen_rack.generate(hw, out) + assert rc == 0 + page = (out / "rack01.md").read_text() + assert "## Power" in page + assert "```mermaid" in page + assert "outlet 1" 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_power'`. + +- [ ] **Step 3: Add `_node_id` and `render_power` after `render_svg` in `scripts/gen_rack.py`** + +```python +def _node_id(name: str) -> str: + """A mermaid-safe node id derived from a hostname.""" + return re.sub(r"[^0-9A-Za-z]", "_", str(name)) + + +def render_power(rack: str, items: list[dict]) -> str: + """Return a mermaid power-distribution flowchart, or '' if no feeds.""" + powered = [fm for fm in items if fm.get("power")] + if not powered: + return "" + pdus = _pdu_index(items) + + edges: list[tuple[str, int, str]] = [] + for fm in powered: + device = fm.get("hostname", "?") + for feed in fm["power"]: + edges.append((feed["pdu"], feed["outlet"], device)) + edges.sort() + + lines: list[str] = ["```mermaid", "flowchart LR"] + for pdu in sorted(pdus): + outlets = pdus[pdu].get("outlets") + lines.append(f' {_node_id(pdu)}["{pdu}
{outlets} outlets"]') + devices = sorted( + powered, + key=lambda i: ( + i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0, + i.get("hostname", ""), + ), + ) + for fm in devices: + device = fm.get("hostname", "?") + lines.append(f' {_node_id(device)}["{device}"]') + for pdu, outlet, device in edges: + lines.append( + f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}" + ) + lines.append("```") + return "\n".join(lines) + "\n" +``` + +- [ ] **Step 4: Insert the `## Power` section in `render_page` in `scripts/gen_rack.py`** + +In `render_page`, replace this block: + +```python + lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)") + lines.append("") + lines.append("## Occupancy") +``` + +with: + +```python + lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)") + lines.append("") + power = render_power(rack, items) + if power: + lines.append("## Power") + lines.append("") + lines.append(power.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 + 5 new). + +- [ ] **Step 6: Commit** + +```bash +git add scripts/gen_rack.py tests/test_gen_rack.py +git commit -m "feat(rack): render mermaid power graph into the rack page" +``` + +--- + +### Task 3: Enable mermaid, populate provisional power data, regenerate + +**Files:** +- Modify: `mkdocs.yml` (mermaid superfences fence) +- Create: `docs/hardware/pdu01.md`, `docs/hardware/pdu02.md` +- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `power:`) +- 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 PDU placements and outlet assignments below are placeholders proving the feature, matching the existing fictional `mfNN` U positions. Replace with real values when known; `validate_power` will reject dangling/over-count feeds loudly. + +- [ ] **Step 1: Enable the mermaid custom fence in `mkdocs.yml`** + +In `mkdocs.yml`, replace the bare superfences line: + +```yaml + - pymdownx.superfences +``` + +with: + +```yaml + - pymdownx.superfences: + custom_fences: + - name: mermaid + class: mermaid + format: !!python/name:pymdownx.superfences.fence_code_format +``` + +- [ ] **Step 2: Create the two PDU files** + +Create `docs/hardware/pdu01.md`: + +```markdown +--- +hostname: pdu01 +kind: pdu +status: in-use +rack: rack01 +rack_face: left +outlets: 8 +--- + +## Notes + +- Provisional placeholder PDU (left rail). Outlet assignments are not yet real. +``` + +Create `docs/hardware/pdu02.md`: + +```markdown +--- +hostname: pdu02 +kind: pdu +status: in-use +rack: rack01 +rack_face: right +outlets: 8 +--- + +## Notes + +- Provisional placeholder PDU (right rail). Provides redundant feeds. +``` + +- [ ] **Step 3: Add `power:` to the five host files** + +In `docs/hardware/mf00.md`, add to the frontmatter (before the closing `---`) — note mf00 has **two** feeds (the redundant demonstration): + +```yaml +power: + - { pdu: pdu01, outlet: 1 } + - { pdu: pdu02, outlet: 1 } +``` + +In `docs/hardware/mf01.md` add: + +```yaml +power: + - { pdu: pdu01, outlet: 2 } +``` + +In `docs/hardware/mf02.md` add: + +```yaml +power: + - { pdu: pdu01, outlet: 3 } +``` + +In `docs/hardware/mf03.md` add: + +```yaml +power: + - { pdu: pdu01, outlet: 4 } +``` + +In `docs/hardware/mf04.md` add: + +```yaml +power: + - { pdu: pdu01, outlet: 5 } +``` + +- [ ] **Step 4: Regenerate all indices and rack artifacts** + +Run: `make docs-index` +Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing pdu01/pdu02 under "PDUs"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (7 item(s))`. + +- [ ] **Step 5: Confirm the generated page has a rendered power graph and PDU rails** + +Run: `grep -c "outlet" docs/infrastructure/racks/rack01.md` +Expected: ≥ 6 (one edge per feed: mf00 ×2, mf01..mf04 ×1). + +Run: `grep -c "pdu0" docs/infrastructure/racks/rack01-elevation.svg` +Expected: ≥ 2 (pdu01 + pdu02 drawn as side-rails). + +- [ ] **Step 6: Run the full test suite** + +Run: `make test` +Expected: PASS (all tests). + +- [ ] **Step 7: Build the site strictly** + +Run: `mkdocs build --strict` +Expected: build succeeds with no warnings-as-errors; `site/infrastructure/racks/rack01/index.html` contains a `
` (or `
`) block rather than a plain `` fence. + +Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html` +Expected: ≥ 1. + +- [ ] **Step 8: Confirm the drift guard is satisfied** + +Run: `make docs-check` +Expected: exit 0 — committed artifacts match a fresh regeneration. + +- [ ] **Step 9: Commit** + +```bash +git add mkdocs.yml docs/hardware/ docs/infrastructure/racks/ +git commit -m "feat(rack): enable mermaid, populate provisional power data" +``` + +--- + +## Self-Review + +**Spec coverage (`2026-06-24-rack-power-design.md`):** +- `power:` frontmatter on devices — Task 3 (populate); validated Task 1. ✔ +- PDU files (`kind: pdu`, `outlets`, 0U `left`/`right`) — Task 3; outlets validated Task 1. ✔ +- Validation rule 3 (pdu resolves to kind:pdu; outlet in range; pdu declares outlets) — Task 1 (`validate_power`), wired into `generate`. ✔ +- Mermaid power graph, outlet as edge label, redundancy as two edges, omit-when-empty, deterministic — Task 2 (`render_power`), inserted in `render_page`. ✔ +- Node-id sanitization — Task 2 (`_node_id`). ✔ +- Mermaid pulled forward in `mkdocs.yml` — Task 3 Step 1. ✔ +- No Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 4/8. ✔ +- Provisional data (pdu01 left → mf00..mf04 o1..o5; pdu02 right → mf00 o1) — Task 3 Steps 2–3. ✔ + +**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional power values, explicitly bounded and guarded by `validate_power`. + +**Type consistency:** `_pdu_index` → `dict[str, dict]`; `validate_power`/`check_overlaps` → `None` (raise `SchemaError`); `render_power`/`render_page`/`render_svg`/`_node_id` → `str`; `generate` → `int` (0/1). `validate_power(ritems)` is called per-rack alongside `check_overlaps(ritems)`. `render_power` consumes `_pdu_index` and feeds `render_page`. Names match across tasks and tests.