97 lines
2.8 KiB
Python
97 lines
2.8 KiB
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"
|
||
|
|
)
|