feat(rack): gen_rack placement parsing and validation
This commit is contained in:
parent
717de70eca
commit
3324c01810
4 changed files with 145 additions and 0 deletions
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.*
|
||||||
96
scripts/gen_rack.py
Normal file
96
scripts/gen_rack.py
Normal 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
5
tests/conftest.py
Normal 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
42
tests/test_gen_rack.py
Normal 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"))
|
||||||
Loading…
Add table
Reference in a new issue