#!/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/-elevation.svg docs/infrastructure/racks/.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", "?") rack = fm.get("rack") if not isinstance(rack, str) or not rack: raise SchemaError(f"{name}: rack must be a non-empty string") 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 _pdu_index(items: list[dict]) -> dict[str, dict]: """Map hostname -> frontmatter for every kind:pdu item.""" return { fm.get("hostname"): fm for fm in items if fm.get("kind") == "pdu" } def validate_power(items: list[dict]) -> None: """Validate PDU outlet declarations and `power` feeds within one rack. Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is within that PDU's `outlets` count. """ pdus = _pdu_index(items) for name, fm in pdus.items(): outlets = fm.get("outlets") if not isinstance(outlets, int) or outlets < 1: raise SchemaError( f"{name}: kind:pdu must declare a positive integer 'outlets'" ) for fm in items: feeds = fm.get("power") if feeds is None: continue name = fm.get("hostname", "?") if not isinstance(feeds, list): raise SchemaError(f"{name}: power must be a list") for feed in feeds: if not isinstance(feed, dict): raise SchemaError(f"{name}: power entry must be a mapping") pdu = feed.get("pdu") outlet = feed.get("outlet") if not isinstance(pdu, str) or not pdu: raise SchemaError(f"{name}: power entry needs a non-empty 'pdu'") if not isinstance(outlet, int): raise SchemaError( f"{name}: power entry for {pdu} needs an integer 'outlet'" ) target = pdus.get(pdu) if target is None: raise SchemaError( f"{name}: power pdu={pdu!r} is not a known kind:pdu file" ) count = target["outlets"] if outlet < 1 or outlet > count: raise SchemaError( f"{name}: outlet {outlet} out of range 1..{count} on {pdu}" ) 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'' ) p.append(f'') p.append( f'Rack {_esc(rack)}' ) for col_x, col_label in ((front_x, "front"), (rear_x, "rear")): p.append( f'{col_label}' ) for u in range(1, RACK_UNITS + 1): y = u_y(u) p.append( f'' ) # 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'{u}' ) 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'' ) p.append( f'' f'{_esc(name)} ({urange})' ) 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'' ) p.append( f'{_esc(name)}' ) 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("") return "\n".join(p) + "\n" def _node_id(name: str) -> str: """A mermaid-safe node id derived from a hostname.""" return re.sub(r"[^0-9A-Za-z]", "_", str(name)) def render_power(rack: str, items: list[dict]) -> str: """Return a mermaid power-distribution flowchart, or '' if no feeds. Assumes `validate_power(items)` has already passed: every referenced PDU resolves to a kind:pdu item with a positive-int `outlets`. `generate` guarantees this by validating before any render call. """ powered = [fm for fm in items if fm.get("power")] if not powered: return "" pdus = _pdu_index(items) edges: list[tuple[str, int, str]] = [] for fm in powered: device = fm.get("hostname", "?") for feed in fm["power"]: edges.append((feed["pdu"], feed["outlet"], device)) edges.sort() lines: list[str] = ["```mermaid", "flowchart LR"] for pdu in sorted(pdus): outlets = pdus[pdu].get("outlets") lines.append(f' {_node_id(pdu)}["{pdu}
{outlets} outlets"]') devices = sorted( powered, key=lambda i: ( i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0, i.get("hostname", ""), ), ) for fm in devices: device = fm.get("hostname", "?") lines.append(f' {_node_id(device)}["{device}"]') for pdu, outlet, device in edges: lines.append( f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}" ) lines.append("```") return "\n".join(lines) + "\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"![Rack {rack} elevation]({rack}-elevation.svg)") lines.append("") power = render_power(rack, items) if power: lines.append("## Power") lines.append("") lines.append(power.rstrip()) 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) validate_power(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())