MakerFLOSS/notes/dev/plans/2026-06-24-rack-power.md

553 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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}<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 `## 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 `<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**
```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 23. ✔
**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.