MakerFLOSS/notes/dev/plans/2026-06-24-rack-elevation.md
sjat f8bcd7ec7f docs(plan): rack elevation Phase 1 implementation plan
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 13:32:52 +02:00

30 KiB
Raw Blame History

Rack Elevation (Phase 1) 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: Document the 48U rack as host frontmatter and generate, via CI, an SVG rack elevation plus an occupancy table — mirroring the existing gen_overview.py → generated-index pattern.

Architecture: A new self-contained script scripts/gen_rack.py reads docs/hardware/*.md, selects files carrying a rack: field, validates placement (U range, no overlaps), and writes two generated artifacts per rack into docs/infrastructure/racks/: a <rack>-elevation.svg picture and a <rack>.md page embedding it. CI regenerates and fails on drift, exactly like the existing indices.

Tech Stack: Python 3 (stdlib + PyYAML only), pytest (new dev dependency), MkDocs Material, Forgejo Actions CI.

Spec: notes/dev/specs/2026-06-24-rack-documentation-design.md (Phase 1 only — power and network are later phases).

Global Constraints

  • Scripts use stdlib + PyYAML only; deterministic and offline (copy gen_overview.py's style). No Date.now/randomness in generated output.
  • Rack has 48 U; the physical rack is labeled U1 at the top, descending to U48 — the SVG must render U1 at the top.
  • Generated files carry the banner: _Auto-generated … do not edit by hand. Run make docs-index after changing a source file._
  • Filenames: ASCII lowercase kebab-case; generated rack files are named after the rack id (e.g. rack01.md, rack01-elevation.svg).
  • Language: English for code, docs, commits. Trunk-based; simple commit messages.
  • mkdocs build --strict must pass; the drift guard must cover the new generated artifacts.

Task 1: Extend the hardware kind enum for rack items

Files:

  • Modify: scripts/overview_config.yml

Interfaces:

  • Produces: new valid kind values (pdu, patch-panel, shelf, blank, ups, kvm) that later tasks' rack item files may use. Phase 1 only uses existing kinds (server), but the enum must accept the rest so Phase 2/3 files validate.

  • Step 1: Extend the kind enum and group_titles under the hardware block

In scripts/overview_config.yml, the hardware block currently has:

  enums:
    kind: [server, laptop, sbc, switch, ap, desktop]
    status: [in-use, staging, spare, broken, donated]
    storage_type: [nvme, ssd, hdd, mixed]

Replace the kind: line with:

    kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm]

And in the same block's group_titles: map, add these entries below the existing ones:

    pdu: PDUs
    patch-panel: Patch panels
    shelf: Shelves
    blank: Blank panels
    ups: UPS
    kvm: KVM
  • Step 2: Confirm the existing hardware index still regenerates cleanly

Run: python3 scripts/gen_overview.py --category hardware Expected: Wrote docs/hardware/index.md (N item(s)) and git diff --exit-code docs/hardware/index.md is clean (no new kinds are used yet, so the table is unchanged).

  • Step 3: Commit
git add scripts/overview_config.yml
git commit -m "feat(hardware): allow rack item kinds (pdu, patch-panel, shelf, blank, ups, kvm)"

Task 2: gen_rack.py core — parse, load, validate placement (TDD)

This task introduces the test harness and the first slice of the generator: frontmatter parsing, selecting rack items, and per-item placement validation.

Files:

  • Create: scripts/gen_rack.py
  • Create: tests/test_gen_rack.py
  • Create: tests/conftest.py
  • Create: requirements-dev.txt

Interfaces:

  • Produces:

    • SchemaError (exception)
    • RACK_UNITS = 48, FACES, ZERO_U_FACES (constants)
    • parse_frontmatter(path: Path) -> dict | None
    • load_rack_items(hardware_dir: Path) -> list[dict] — returns frontmatter dicts (each with an added _path key) for files declaring a rack
    • validate_item(fm: dict) -> None — raises SchemaError on bad placement
  • Step 1: Create requirements-dev.txt

-r requirements.txt
pytest==8.*
  • Step 2: Install dev dependencies

Run: pip install -r requirements-dev.txt Expected: pytest installs successfully.

  • Step 3: Create tests/conftest.py so tests can import the script
import sys
from pathlib import Path

# Make scripts/ importable as top-level modules in tests.
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
  • Step 4: Write the failing tests for validate_item

Create tests/test_gen_rack.py:

import pytest

import gen_rack


def item(**kw):
    base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
    base.update(kw)
    return base


def test_validate_accepts_valid_placement():
    gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front"))


def test_validate_rejects_u_overflow():
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))


def test_validate_rejects_u_below_one():
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))


def test_validate_rejects_bad_face():
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))


def test_validate_rejects_zero_u_with_units():
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))


def test_validate_accepts_zero_u_rail():
    gen_rack.validate_item(item(rack_face="left"))


def test_validate_rejects_missing_units_on_faced_item():
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.validate_item(item(rack_face="front"))
  • Step 5: Run the tests to verify they fail

Run: pytest tests/test_gen_rack.py -q Expected: FAIL — ModuleNotFoundError: No module named 'gen_rack'.

  • Step 6: Create scripts/gen_rack.py with constants, parsing, loading, and validate_item
#!/usr/bin/env python3
"""Generate per-rack elevation SVG + page from hardware frontmatter.

Reads `docs/hardware/*.md`, selects files that declare a `rack` field,
validates rack placement, and writes for each rack:
  docs/infrastructure/racks/<rack>-elevation.svg
  docs/infrastructure/racks/<rack>.md

Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation.
The physical rack is labeled U1 at the top; the SVG renders U1 at the top.
"""

from __future__ import annotations

import re
import sys
from pathlib import Path

import yaml

REPO_ROOT = Path(__file__).resolve().parent.parent
HARDWARE_DIR = REPO_ROOT / "docs" / "hardware"
OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks"
RACK_UNITS = 48

FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)

FACES = {"front", "rear", "both", "left", "right"}
ZERO_U_FACES = {"left", "right"}

KIND_COLORS = {
    "server": "#4c78a8",
    "switch": "#59a14f",
    "patch-panel": "#9c755f",
    "pdu": "#e15759",
    "ups": "#edc948",
    "shelf": "#bab0ac",
    "kvm": "#b07aa1",
    "blank": "#d4d4d4",
}
DEFAULT_COLOR = "#888888"


class SchemaError(Exception):
    pass


def parse_frontmatter(path: Path) -> dict | None:
    text = path.read_text(encoding="utf-8")
    m = FRONTMATTER_RE.match(text)
    if not m:
        return None
    data = yaml.safe_load(m.group(1))
    if not isinstance(data, dict):
        raise SchemaError(f"{path}: frontmatter is not a mapping")
    return data


def load_rack_items(hardware_dir: Path) -> list[dict]:
    """Return frontmatter dicts for hardware files that declare a rack."""
    items: list[dict] = []
    for path in sorted(hardware_dir.glob("*.md")):
        if path.name == "index.md":
            continue
        fm = parse_frontmatter(path)
        if fm is None or "rack" not in fm:
            continue
        fm = dict(fm)
        fm["_path"] = str(path)
        items.append(fm)
    return items


def validate_item(fm: dict) -> None:
    name = fm.get("hostname") or fm.get("_path", "?")
    face = fm.get("rack_face")
    if face not in FACES:
        raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(FACES)}")
    if face in ZERO_U_FACES:
        if "rack_u" in fm or "u_height" in fm:
            raise SchemaError(
                f"{name}: 0U item (face={face}) must omit rack_u/u_height"
            )
        return
    u = fm.get("rack_u")
    h = fm.get("u_height")
    if not isinstance(u, int) or not isinstance(h, int):
        raise SchemaError(f"{name}: rack_u and u_height must be integers")
    if u < 1 or u > RACK_UNITS:
        raise SchemaError(f"{name}: rack_u={u} out of range 1..{RACK_UNITS}")
    if h < 1:
        raise SchemaError(f"{name}: u_height={h} must be >= 1")
    if u + h - 1 > RACK_UNITS:
        raise SchemaError(
            f"{name}: occupies U{u}..U{u + h - 1}, exceeds {RACK_UNITS}U"
        )
  • Step 7: Run the tests to verify they pass

Run: pytest tests/test_gen_rack.py -q Expected: PASS (7 passed).

  • Step 8: Commit
git add scripts/gen_rack.py tests/test_gen_rack.py tests/conftest.py requirements-dev.txt
git commit -m "feat(rack): gen_rack placement parsing and validation"

Task 3: Overlap detection (TDD)

Files:

  • Modify: scripts/gen_rack.py
  • Modify: tests/test_gen_rack.py

Interfaces:

  • Consumes: validate_item semantics (items already individually valid).

  • Produces: check_overlaps(items: list[dict]) -> None — raises SchemaError if any two items share a U on the same face. both expands to both front and rear; 0U rail items are exempt.

  • Step 1: Add failing overlap tests to tests/test_gen_rack.py

Append:

def test_overlaps_detects_same_face_overlap():
    items = [
        item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
        item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
    ]
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.check_overlaps(items)


def test_overlaps_allows_same_u_different_face():
    items = [
        item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
        item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
    ]
    gen_rack.check_overlaps(items)  # no raise


def test_overlaps_both_face_conflicts_with_front():
    items = [
        item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
        item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
    ]
    with pytest.raises(gen_rack.SchemaError):
        gen_rack.check_overlaps(items)


def test_overlaps_ignores_zero_u_rails():
    items = [
        item(hostname="p1", rack_face="left"),
        item(hostname="p2", rack_face="left"),
    ]
    gen_rack.check_overlaps(items)  # no raise
  • Step 2: Run to verify failure

Run: pytest tests/test_gen_rack.py -q Expected: FAIL — AttributeError: module 'gen_rack' has no attribute 'check_overlaps'.

  • Step 3: Implement check_overlaps in scripts/gen_rack.py

Add after validate_item:

def check_overlaps(items: list[dict]) -> None:
    """Raise if two items share a U on the same face within one rack."""
    occupied: dict[tuple[str, int], str] = {}
    for fm in items:
        face = fm.get("rack_face")
        if face in ZERO_U_FACES:
            continue
        faces = ("front", "rear") if face == "both" else (face,)
        u = fm["rack_u"]
        h = fm["u_height"]
        name = fm.get("hostname", "?")
        for f in faces:
            for uu in range(u, u + h):
                key = (f, uu)
                if key in occupied:
                    raise SchemaError(
                        f"U{uu} {f}: {name} overlaps {occupied[key]}"
                    )
                occupied[key] = name
  • Step 4: Run to verify pass

Run: pytest tests/test_gen_rack.py -q Expected: PASS (11 passed).

  • Step 5: Commit
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): detect U overlaps within a rack face"

Task 4: SVG elevation rendering (TDD)

Files:

  • Modify: scripts/gen_rack.py
  • Modify: tests/test_gen_rack.py

Interfaces:

  • Consumes: validated items (non-0U items have integer rack_u/u_height).

  • Produces: render_svg(rack: str, items: list[dict]) -> str — a complete deterministic <svg>…</svg> string ending in a newline; front and rear columns of 48 U slots with U1 at the top, device boxes colored by kind, 0U items as side rails.

  • Step 1: Add failing SVG tests

Append to tests/test_gen_rack.py:

def test_render_svg_has_two_columns_of_48_slots():
    svg = gen_rack.render_svg("rack01", [])
    # one faint slot rect per U per column (front + rear)
    assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
    assert svg.startswith("<svg")
    assert svg.rstrip().endswith("</svg>")


def test_render_svg_includes_device_label():
    items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
    svg = gen_rack.render_svg("rack01", items)
    assert "mf00" in svg
    assert "U1" in svg


def test_render_svg_is_deterministic():
    items = [
        item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
        item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
    ]
    assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
        "rack01", list(reversed(items))
    )
  • Step 2: Run to verify failure

Run: pytest tests/test_gen_rack.py -q Expected: FAIL — AttributeError: module 'gen_rack' has no attribute 'render_svg'.

  • Step 3: Implement _esc, _sorted_items, and render_svg

Add to scripts/gen_rack.py:

def _esc(s: object) -> str:
    return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")


def _sorted_items(items: list[dict]) -> list[dict]:
    """Deterministic order: faced items by U then hostname, 0U items last."""
    return sorted(
        items,
        key=lambda i: (
            0 if i.get("rack_face") not in ZERO_U_FACES else 1,
            i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
            i.get("hostname", ""),
        ),
    )


def render_svg(rack: str, items: list[dict]) -> str:
    U_H = 20
    COL_W = 240
    LABEL_W = 30
    RAIL_W = 16
    PAD = 12
    GAP = 50
    TITLE_H = 28

    items = _sorted_items(items)
    left_items = [i for i in items if i.get("rack_face") == "left"]
    right_items = [i for i in items if i.get("rack_face") == "right"]

    body_h = RACK_UNITS * U_H
    height = PAD + TITLE_H + body_h + PAD
    front_x = PAD + len(left_items) * RAIL_W + LABEL_W
    rear_x = front_x + COL_W + GAP
    width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
    top = PAD + TITLE_H

    def u_y(u: int) -> int:
        # U1 at the top; U numbers increase downward.
        return top + (u - 1) * U_H

    p: list[str] = []
    p.append(
        f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
        f'height="{height}" viewBox="0 0 {width} {height}" '
        f'font-family="sans-serif" font-size="11">'
    )
    p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
    p.append(
        f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
        f'font-weight="bold">Rack {_esc(rack)}</text>'
    )

    for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
        p.append(
            f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
            f'text-anchor="middle" font-weight="bold">{col_label}</text>'
        )
        for u in range(1, RACK_UNITS + 1):
            y = u_y(u)
            p.append(
                f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
                f'fill="#f5f5f5" stroke="#e0e0e0"/>'
            )

    # U numbers in the gutter left of the front column.
    for u in range(1, RACK_UNITS + 1):
        y = u_y(u)
        p.append(
            f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
            f'fill="#999">{u}</text>'
        )

    def draw_device(fm: dict, col_x: int) -> None:
        u = fm["rack_u"]
        h = fm["u_height"]
        y = u_y(u)
        box_h = h * U_H
        color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
        name = fm.get("hostname", "?")
        urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
        p.append(
            f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
            f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
        )
        p.append(
            f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
            f'text-anchor="middle" fill="#ffffff">'
            f'{_esc(name)} ({urange})</text>'
        )

    for fm in items:
        face = fm.get("rack_face")
        if face in ("front", "both"):
            draw_device(fm, front_x)
        if face in ("rear", "both"):
            draw_device(fm, rear_x)

    def draw_rail(fm: dict, x: int) -> None:
        color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
        name = fm.get("hostname", "?")
        cx = x + RAIL_W // 2
        cy = top + body_h // 2
        p.append(
            f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
            f'fill="{color}" stroke="#333"/>'
        )
        p.append(
            f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
            f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
        )

    for idx, fm in enumerate(left_items):
        draw_rail(fm, PAD + idx * RAIL_W)
    for idx, fm in enumerate(right_items):
        draw_rail(fm, rear_x + COL_W + idx * RAIL_W)

    p.append("</svg>")
    return "\n".join(p) + "\n"
  • Step 4: Run to verify pass

Run: pytest tests/test_gen_rack.py -q Expected: PASS (14 passed).

  • Step 5: Commit
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render SVG elevation (U1 at top, front/rear columns)"

Task 5: Page rendering + orchestration (TDD)

Files:

  • Modify: scripts/gen_rack.py
  • Modify: tests/test_gen_rack.py

Interfaces:

  • Consumes: render_svg, validate_item, check_overlaps, load_rack_items.

  • Produces:

    • render_page(rack: str, items: list[dict]) -> str — the generated Markdown page (banner, embedded SVG image, occupancy table linking to host pages).
    • generate(hardware_dir: Path, output_dir: Path) -> int — orchestrates load/validate/group/write; returns 0 on success, 1 on any schema error (printing errors to stderr, writing nothing on failure).
    • main() -> int — calls generate(HARDWARE_DIR, OUTPUT_DIR).
  • Step 1: Add failing tests for render_page and generate

Append to tests/test_gen_rack.py:

def test_render_page_has_banner_image_and_table():
    items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
    page = gen_rack.render_page("rack01", items)
    assert "do not edit by hand" in page
    assert "![Rack rack01 elevation](rack01-elevation.svg)" in page
    assert "../../hardware/mf00.md" in page
    assert "U1U2" in page


def _write_item(d, name, body):
    (d / f"{name}.md").write_text(body, encoding="utf-8")


def test_generate_writes_artifacts(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---\n",
    )
    # a non-rack file must be ignored
    _write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")

    rc = gen_rack.generate(hw, out)

    assert rc == 0
    assert (out / "rack01.md").exists()
    assert (out / "rack01-elevation.svg").exists()
    assert "mf00" in (out / "rack01-elevation.svg").read_text()


def test_generate_returns_1_on_overlap(tmp_path):
    hw = tmp_path / "hardware"
    out = tmp_path / "out"
    hw.mkdir()
    for n, u in (("a", 1), ("b", 1)):
        _write_item(
            hw,
            n,
            f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
            f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\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 'render_page'.

  • Step 3: Implement render_page, generate, and main

Add to scripts/gen_rack.py:

def render_page(rack: str, items: list[dict]) -> str:
    items = _sorted_items(items)
    lines: list[str] = []
    lines.append(f"# Rack {rack}")
    lines.append("")
    lines.append(
        f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
        f"— do not edit by hand. Run `make docs-index` after changing a "
        f"source file._"
    )
    lines.append("")
    lines.append("## Elevation")
    lines.append("")
    lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
    lines.append("")
    lines.append("## Occupancy")
    lines.append("")
    lines.append("| U | Device | Kind | Face | Status |")
    lines.append("|---|---|---|---|---|")
    for fm in items:
        name = fm.get("hostname", "?")
        link = f"[{name}](../../hardware/{name}.md)"
        face = fm.get("rack_face", "")
        if face in ZERO_U_FACES:
            urange = "0U"
        else:
            u = fm["rack_u"]
            h = fm["u_height"]
            urange = f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
        lines.append(
            f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
            f"| {fm.get('status', '')} |"
        )
    lines.append("")
    return "\n".join(lines).rstrip() + "\n"


def generate(hardware_dir: Path, output_dir: Path) -> int:
    items = load_rack_items(hardware_dir)

    errors: list[str] = []
    for fm in items:
        try:
            validate_item(fm)
        except SchemaError as e:
            errors.append(str(e))

    racks: dict[str, list[dict]] = {}
    for fm in items:
        racks.setdefault(fm["rack"], []).append(fm)

    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}")

    if errors:
        for err in errors:
            print(f"ERROR: {err}", file=sys.stderr)
        return 1

    output_dir.mkdir(parents=True, exist_ok=True)
    for rack in sorted(racks):
        ritems = racks[rack]
        (output_dir / f"{rack}-elevation.svg").write_text(
            render_svg(rack, ritems), encoding="utf-8"
        )
        (output_dir / f"{rack}.md").write_text(
            render_page(rack, ritems), encoding="utf-8"
        )
        print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
    return 0


def main() -> int:
    return generate(HARDWARE_DIR, OUTPUT_DIR)


if __name__ == "__main__":
    sys.exit(main())
  • Step 4: Run to verify pass

Run: pytest tests/test_gen_rack.py -q Expected: PASS (18 passed).

  • Step 5: Commit
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render page and orchestrate generation"

Task 6: Wire build tooling and populate rack01

Files:

  • Modify: Makefile
  • Modify: docs/hardware/mf00.md (and other host files actually in the rack — see note)
  • Create (generated): docs/infrastructure/racks/rack01.md, docs/infrastructure/racks/rack01-elevation.svg

Interfaces:

  • Consumes: gen_rack.main via python3 scripts/gen_rack.py.

Operator note — real data required. I do not know the true U positions of the devices in the physical rack, and the mfNN machines are tower/desktop units that may sit on a shelf rather than occupy U slots. The edit below is a worked example for mf00. Apply the same shape to each device actually mounted in the rack, using its real rack_u, u_height, and rack_face (front/rear/both). Remove rack fields from any host not in the rack. The overlap validator (check_overlaps) will reject conflicting positions, so wrong guesses fail loudly rather than silently. makerfloss.eu is cloud-hosted and must NOT get a rack field.

  • Step 1: Add rack placement to each in-rack host file (example: mf00)

In docs/hardware/mf00.md, add these four lines to the frontmatter (between the existing keys and the closing ---):

rack: rack01
rack_u: 1
u_height: 1
rack_face: front

Repeat for every other device physically in the rack, choosing real, non-overlapping U positions.

  • Step 2: Add the gen_rack step to the Makefile

In Makefile, change the docs-index target to:

docs-index:
	python3 scripts/gen_overview.py --category hardware
	python3 scripts/gen_overview.py --category services
	python3 scripts/gen_rack.py

Change the docs-check target to:

docs-check:
	python3 scripts/gen_overview.py --category hardware
	python3 scripts/gen_overview.py --category services
	python3 scripts/gen_rack.py
	git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/

Add a test target at the end of the file:

test:
	pytest -q

And add test to the .PHONY line and a help line:

.PHONY: help docs-index docs-build docs-serve docs-check slides test
	@echo "  test         Run the Python unit tests (pytest)"
  • Step 3: Generate the rack artifacts

Run: make docs-index Expected: prints Wrote rack01.md + rack01-elevation.svg (N item(s)); the two files now exist under docs/infrastructure/racks/.

  • Step 4: Eyeball the SVG

Open docs/infrastructure/racks/rack01-elevation.svg in a browser. Expected: a "Rack rack01" title, front and rear columns, U numbers running 1 at the top → 48 at the bottom, and each placed device as a colored box at its U position.

  • Step 5: Commit
git add Makefile docs/hardware/*.md docs/infrastructure/racks/
git commit -m "feat(rack): populate rack01 and wire gen_rack into make targets"

Task 7: CI integration, nav, and end-to-end verification

Files:

  • Modify: .forgejo/workflows/docs.yml
  • Modify: mkdocs.yml

Interfaces:

  • Consumes: python3 scripts/gen_rack.py, pytest, the generated artifacts under docs/infrastructure/racks/.

  • Step 1: Add a test step and the rack generator to CI

In .forgejo/workflows/docs.yml, after the Install Python dependencies step, add a new step:

      - name: Install dev dependencies and run tests
        run: |
          pip install --quiet -r requirements-dev.txt
          pytest -q

In the Regenerate hardware and services indices step, append the rack generator so the run: block reads:

        run: |
          python3 scripts/gen_overview.py --category hardware
          python3 scripts/gen_overview.py --category services
          python3 scripts/gen_rack.py

In the Fail on drift in generated indices step, extend the diff to cover the rack artifacts:

        run: |
          if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
            echo
            echo "::error::A generated index is stale."
            echo "Regenerate locally via 'make docs-index' and commit the result."
            exit 1
          fi
  • Step 2: Add an Infrastructure section to the MkDocs nav

In mkdocs.yml, replace the nav: block with:

nav:
  - Home: index.md
  - Hardware:
      - hardware/index.md
  - Services:
      - services/index.md
  - Infrastructure:
      - Lab design: infrastructure/labdesign.md
      - VPS & DNS: infrastructure/vps-and-dns.md
      - Rack rack01: infrastructure/racks/rack01.md
  - House rules: house-rules.md
  • Step 3: Build the site strictly and confirm it passes

Run: mkdocs build --strict Expected: build succeeds with no warnings-as-errors. The rack page and its SVG appear under site/infrastructure/racks/.

  • Step 4: Confirm the drift guard is satisfied

Run: make docs-check Expected: exit 0 (no diff) — the committed artifacts match a fresh regeneration.

  • Step 5: Preview the page

Run: mkdocs serve Open http://127.0.0.1:8000/infrastructure/racks/rack01/. Expected: the elevation SVG renders inline, U1 at the top; the occupancy table lists devices and links to their host pages.

  • Step 6: Commit
git add .forgejo/workflows/docs.yml mkdocs.yml
git commit -m "ci(rack): generate rack artifacts, run tests, add nav entry"
  • Step 7: Push and confirm CI is green
git push origin main

Open the Forgejo Actions run for this push. Expected: the tests step passes, the drift guard passes, the site builds, and docs.makerfloss.eu/infrastructure/racks/rack01/ shows the elevation.


Self-Review

Spec coverage (Phase 1 scope):

  • Placement schema (rack, rack_u, u_height, rack_face) — Task 2 (validation), Task 6 (population). ✔
  • New kind values — Task 1. ✔
  • gen_rack.py producing SVG elevation + occupancy table — Tasks 4, 5. ✔
  • U1-at-top rendering — Task 4 (u_y), verified Task 6 Step 4 / Task 7 Step 5. ✔
  • Validation rules 1, 2, 5 (U range, overlap, 0U-omits-units) — Tasks 2, 3. (Rules 3, 4 are power/network — Phases 2/3, out of scope.) ✔
  • Do-not-edit banner — Task 5 (render_page). ✔
  • CI drift check + nav + strict build — Task 7. ✔
  • Generated artifacts under docs/infrastructure/racks/ — Tasks 5, 6. ✔

Placeholder scan: No "TBD"/"handle edge cases"/"similar to" placeholders. The only deferred-to-operator item is real U-position data in Task 6, which is unavoidable physical-world input and is explicitly bounded with a worked example and the overlap validator as a safety net.

Type consistency: SchemaError, RACK_UNITS, FACES, ZERO_U_FACES, parse_frontmatter, load_rack_items, validate_item, check_overlaps, _esc, _sorted_items, render_svg, render_page, generate, main — names and signatures match across tasks and tests. generate returns int (0/1); render_* return str; validate_item/check_overlaps return None and raise SchemaError. Consistent.