321 lines
10 KiB
Python
321 lines
10 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"
|
||
)
|
||
|
||
|
||
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
|
||
|
||
|
||
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"
|
||
|
||
|
||
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())
|