feat(rack): gen_rack placement parsing and validation

This commit is contained in:
sjat 2026-06-24 13:42:21 +02:00
parent 717de70eca
commit 3324c01810
4 changed files with 145 additions and 0 deletions

2
requirements-dev.txt Normal file
View file

@ -0,0 +1,2 @@
-r requirements.txt
pytest==8.*

96
scripts/gen_rack.py Normal file
View file

@ -0,0 +1,96 @@
#!/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"
)

5
tests/conftest.py Normal file
View file

@ -0,0 +1,5 @@
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"))

42
tests/test_gen_rack.py Normal file
View file

@ -0,0 +1,42 @@
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"))