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

19 KiB
Raw Blame History

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

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
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:

    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; 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

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
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:

    lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
    lines.append("")
    lines.append("## Occupancy")

with:

    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
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:

  - 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, 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_indexdict[str, dict]; validate_power/check_overlapsNone (raise SchemaError); render_power/render_page/render_svg/_node_idstr; generateint (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.