2026-06-24 13:42:21 +02:00
|
|
|
|
#!/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", "?")
|
2026-06-24 14:05:56 +02:00
|
|
|
|
rack = fm.get("rack")
|
|
|
|
|
|
if not isinstance(rack, str) or not rack:
|
|
|
|
|
|
raise SchemaError(f"{name}: rack must be a non-empty string")
|
2026-06-24 13:42:21 +02:00
|
|
|
|
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"
|
|
|
|
|
|
)
|
2026-06-24 13:45:08 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-06-24 13:48:19 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _esc(s: object) -> str:
|
|
|
|
|
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
2026-06-24 13:51:52 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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"")
|
|
|
|
|
|
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())
|