19 KiB
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.pystyle). No randomness/time in generated output. reandyamlare already imported inscripts/gen_rack.py; do not add new imports.- Validation failures raise
SchemaError;generateprintsERROR: …to stderr and returns1, 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, norack_u/u_height, and a positive-intoutlets. - Power data added here is provisional placeholder data (like the existing
mfNNU positions), not real values. - The Makefile
docs-checkand CI drift step already diff the wholedocs/infrastructure/racks/dir — do not editMakefile,.forgejo/workflows/docs.yml, orscripts/overview_config.yml. mkdocs build --strictmust pass;make docs-checkmust 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 ingenerate) - Modify:
tests/test_gen_rack.py(append tests)
Interfaces:
-
Consumes:
SchemaError, theitem()/_write_itemtest helpers,generate. -
Produces:
_pdu_index(items: list[dict]) -> dict[str, dict]—{hostname: fm}forkind == "pdu"items.validate_power(items: list[dict]) -> None— raisesSchemaErroron a bad PDUoutletsdeclaration or a badpowerfeed.
-
Step 1: Append failing tests to
tests/test_gen_rack.py
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_indexandvalidate_poweraftercheck_overlapsinscripts/gen_rack.py
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_powerintogenerateinscripts/gen_rack.py
Change the overlap loop so it also validates power. Replace:
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:
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
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; editrender_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 fencedmermaidflowchart LRending in a newline, or""when no item has apowerfeed.
-
Step 1: Append failing tests to
tests/test_gen_rack.py
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_idandrender_powerafterrender_svginscripts/gen_rack.py
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}<br/>{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
## Powersection inrender_pageinscripts/gen_rack.py
In render_page, replace this block:
lines.append(f"")
lines.append("")
lines.append("## Occupancy")
with:
lines.append(f"")
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
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(addpower:) - 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
mfNNU positions. Replace with real values when known;validate_powerwill reject dangling/over-count feeds loudly.
- Step 1: Enable the mermaid custom fence in
mkdocs.yml
In mkdocs.yml, replace the bare superfences line:
- pymdownx.superfences
with:
- 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:
---
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:
---
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):
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
In docs/hardware/mf01.md add:
power:
- { pdu: pdu01, outlet: 2 }
In docs/hardware/mf02.md add:
power:
- { pdu: pdu01, outlet: 3 }
In docs/hardware/mf03.md add:
power:
- { pdu: pdu01, outlet: 4 }
In docs/hardware/mf04.md add:
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 <pre class="mermaid"> (or <div class="mermaid">) block rather than a plain <code> 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
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, 0Uleft/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 intogenerate. ✔ - Mermaid power graph, outlet as edge label, redundancy as two edges, omit-when-empty, deterministic — Task 2 (
render_power), inserted inrender_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.