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