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

925 lines
30 KiB
Markdown
Raw Permalink 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 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:
```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/<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**
```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 `<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`:
```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("<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`:
```python
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**
```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 "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`:
```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.