diff --git a/notes/dev/plans/2026-06-24-rack-elevation.md b/notes/dev/plans/2026-06-24-rack-elevation.md new file mode 100644 index 0000000..eb4b41c --- /dev/null +++ b/notes/dev/plans/2026-06-24-rack-elevation.md @@ -0,0 +1,925 @@ +# 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 `-elevation.svg` picture and a `.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: + +```yaml + 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: + +```yaml + 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: + +```yaml + 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** + +```bash +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** + +```python +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`: + +```python +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`** + +```python +#!/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/-elevation.svg + docs/infrastructure/racks/.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** + +```bash +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: + +```python +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`: + +```python +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** + +```bash +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 `` 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`: + +```python +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("") + + +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`: + +```python +def _esc(s: object) -> str: + return str(s).replace("&", "&").replace("<", "<").replace(">", ">") + + +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'' + ) + p.append(f'') + p.append( + f'Rack {_esc(rack)}' + ) + + for col_x, col_label in ((front_x, "front"), (rear_x, "rear")): + p.append( + f'{col_label}' + ) + for u in range(1, RACK_UNITS + 1): + y = u_y(u) + p.append( + f'' + ) + + # 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'{u}' + ) + + 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'' + ) + p.append( + f'' + f'{_esc(name)} ({urange})' + ) + + 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'' + ) + p.append( + f'{_esc(name)}' + ) + + 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("") + 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** + +```bash +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`: + +```python +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 "U1–U2" 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`: + +```python +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** + +```bash +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 `---`): + +```yaml +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: + +```makefile +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: + +```makefile +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: + +```makefile +test: + pytest -q +``` + +And add `test` to the `.PHONY` line and a help line: + +```makefile +.PHONY: help docs-index docs-build docs-serve docs-check slides test +``` + +```makefile + @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** + +```bash +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: + +```yaml + - 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: + +```yaml + 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: + +```yaml + 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: + +```yaml +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** + +```bash +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** + +```bash +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.