# 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.