MakerFLOSS/scripts/gen_rack.py
sjat 4dc975062a
All checks were successful
Build docs site / build (push) Successful in 49s
Build slides / build (push) Successful in 1m11s
docs(hardware): reconcile rack01 to as-mounted layout and document cabling
Rebuild rack01 from the physically remounted hardware:
- Correct stale positions/ports/outlets for pp01, pp02, sw01, pdu01-04
- Model shelves as 1U trays (towers stand above without consuming rack U's);
  add shf02 and empty half-depth shf03/shf04
- Add ups01/ups02; reseat nas01/02 and sw02-05; move srv04-07 onto shf02
- Add `wan` hardware kind; add WAN demarcation hosts wan01 (active) and
  wan02 (staging)
- Document full live network wiring: srv01-07 -> pp02 -> sw01 (LAN) and
  srv01 eth0 -> pp02 -> pp01 -> wan01 (WAN); keep non-active lines
  (wan2, working-table patches, sw01 mgmt) in notes only
- Regenerate hardware index + rack01 elevation/network/power artifacts

Also includes the in-progress generator updates (gen_rack.py, gen_overview.py,
Makefile, tests) that the regenerated artifacts depend on.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 21:31:48 +02:00

872 lines
31 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"}
SHELF_FACES = {"front", "rear"}
# Shown at the bottom of every error report so a newcomer knows where to look.
GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/"
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 name a rack, e.g. 'rack: rack01'.")
if "mounted_on" in fm:
mounted_on = fm.get("mounted_on")
if not isinstance(mounted_on, str) or not mounted_on:
raise SchemaError(
f"{name}: 'mounted_on' must name the shelf it sits on, "
f"e.g. 'mounted_on: shf01'."
)
for forbidden in ("rack_u", "u_height", "rack_face"):
if forbidden in fm:
raise SchemaError(
f"{name}: a shelf-mounted device must not set '{forbidden}'"
f"it takes its position from the shelf. Use 'shelf_face' and "
f"'shelf_slot' instead."
)
sface = fm.get("shelf_face")
if sface not in SHELF_FACES:
raise SchemaError(
f"{name}: shelf_face {sface!r} must be 'front' or 'rear'."
)
slot = fm.get("shelf_slot")
if not isinstance(slot, int) or slot < 1:
raise SchemaError(
f"{name}: 'shelf_slot' must be a whole number 1 or higher "
f"(got {slot!r})."
)
return
face = fm.get("rack_face")
if face not in FACES:
raise SchemaError(
f"{name}: rack_face {face!r} is not valid. Use 'front', 'rear' or "
f"'both' for a U-mounted device, or 'left'/'right' for a 0U side rail."
)
if face in ZERO_U_FACES:
if "rack_u" in fm or "u_height" in fm:
raise SchemaError(
f"{name}: a side-rail device (rack_face: {face}) is 0U — remove "
f"'rack_u' and '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}: a {face}-mounted device needs whole-number 'rack_u' and "
f"'u_height' (e.g. 'rack_u: 12' and 'u_height: 2')."
)
if u < 1 or u > RACK_UNITS:
raise SchemaError(
f"{name}: rack_u={u} is outside the rack — it must be between 1 "
f"and {RACK_UNITS}."
)
if h < 1:
raise SchemaError(f"{name}: u_height={h} must be at least 1.")
if u + h - 1 > RACK_UNITS:
raise SchemaError(
f"{name}: a {h}U device starting at U{u} runs off the top of the rack "
f"(it would need U{u}U{u + h - 1}, but the rack is only {RACK_UNITS}U). "
f"Lower 'rack_u' or 'u_height'."
)
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:
if "mounted_on" in fm:
continue
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]} — two devices "
f"can't share the same U on the same face. Move one to a "
f"free U or to the other face."
)
occupied[key] = name
def check_shelves(items: list[dict]) -> None:
"""Validate shelf-mounted devices within one rack.
Every mounted_on resolves to a placed kind:shelf item in the same rack;
no two devices share (shelf, face, slot).
"""
by_host = {fm.get("hostname"): fm for fm in items}
occupied: dict[tuple[str, str, int], str] = {}
for fm in items:
if "mounted_on" not in fm:
continue
name = fm.get("hostname", "?")
shelf_name = fm["mounted_on"]
target = by_host.get(shelf_name)
if target is None:
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} — no device with that id is in "
f"this rack. Check the shelf's hostname."
)
if target.get("kind") != "shelf":
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} is a {target.get('kind')!r}, "
f"not a shelf. Only kind:shelf devices can hold mounted gear."
)
if not isinstance(target.get("rack_u"), int) or not isinstance(
target.get("u_height"), int
):
raise SchemaError(
f"{name}: the shelf {shelf_name!r} has no position yet — give it "
f"'rack_u' and 'u_height' first."
)
key = (shelf_name, fm["shelf_face"], fm["shelf_slot"])
if key in occupied:
raise SchemaError(
f"{shelf_name} {fm['shelf_face']} slot {fm['shelf_slot']}: "
f"{name} overlaps {occupied[key]} — each shelf face and slot holds "
f"one device."
)
occupied[key] = name
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {}
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None:
continue
name = fm.get("hostname")
if isinstance(name, str) and name:
index[name] = fm
return index
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
"""Validate `links` cable declarations (rule 4).
Every links[].peer must resolve to a real hardware file (global lookup via
hw_index); peer_port must fall within the peer's declared `ports` when it
declares an integer count.
"""
for fm in items:
links = fm.get("links")
if links is None:
continue
name = fm.get("hostname", "?")
if not isinstance(links, list):
raise SchemaError(
f"{name}: 'links' must be a list of cables like "
f"'- {{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
for link in links:
if not isinstance(link, dict):
raise SchemaError(
f"{name}: each 'links' entry must look like "
f"'{{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
local = link.get("local")
peer = link.get("peer")
peer_port = link.get("peer_port")
if not isinstance(local, str) or not local:
raise SchemaError(
f"{name}: a 'links' entry needs a 'local' port name, "
f"e.g. 'local: eth0'."
)
if not isinstance(peer, str) or not peer:
raise SchemaError(
f"{name}: a 'links' entry needs a 'peer' device, "
f"e.g. 'peer: sw01'."
)
if not isinstance(peer_port, int):
raise SchemaError(
f"{name}: the link to {peer} needs a whole-number 'peer_port'."
)
target = hw_index.get(peer)
if target is None:
raise SchemaError(
f"{name}: link points at peer={peer!r}, but no hardware file "
f"has that id. Check the peer hostname."
)
ports = target.get("ports")
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
raise SchemaError(
f"{name}: peer_port {peer_port} doesn't exist on {peer} — it "
f"has {ports} port(s) (valid 1{ports})."
)
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}: a PDU must say how many outlets it has, e.g. 'outlets: 8'."
)
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 of feeds like "
f"'- {{ pdu: pdu01, outlet: 1 }}'."
)
for feed in feeds:
if not isinstance(feed, dict):
raise SchemaError(
f"{name}: each 'power' feed must look like "
f"'{{ pdu: pdu01, outlet: 1 }}'."
)
pdu = feed.get("pdu")
outlet = feed.get("outlet")
if not isinstance(pdu, str) or not pdu:
raise SchemaError(
f"{name}: a 'power' feed needs a 'pdu' name, "
f"e.g. '{{ pdu: pdu01, outlet: 1 }}'."
)
if not isinstance(outlet, int):
raise SchemaError(
f"{name}: the 'power' feed to {pdu} needs a whole-number 'outlet'."
)
target = pdus.get(pdu)
if target is None:
raise SchemaError(
f"{name}: power feed points at pdu={pdu!r}, but no kind:pdu "
f"device has that id. Check the PDU hostname."
)
count = target["outlets"]
if outlet < 1 or outlet > count:
raise SchemaError(
f"{name}: outlet {outlet} doesn't exist on {pdu} — it has "
f"{count} outlet(s) (valid 1{count})."
)
def _esc(s: object) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '')} · {_placement(fm)}"
)
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
LEGEND_H = 56
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 + LEGEND_H
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_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'style="max-width:100%;height:auto" '
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>'
)
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
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>"
)
p.append("</a>")
for fm in items:
if fm.get("kind") == "shelf" or "mounted_on" in fm:
continue
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" '
f'dominant-baseline="central" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
for idx, fm in enumerate(left_items):
draw_rail(fm, PAD + idx * RAIL_W)
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
SHELF_STRIP_H = 6
shelves = [i for i in items if i.get("kind") == "shelf"]
mounted = [i for i in items if "mounted_on" in i]
def draw_shelf(fm: dict) -> None:
u = fm["rack_u"]
h = fm["u_height"]
y = u_y(u)
block_h = h * U_H
strip_y = y + block_h - SHELF_STRIP_H
avail_h = block_h - SHELF_STRIP_H
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
sname = fm.get("hostname", "?")
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
occ = sorted(
(m for m in mounted
if m.get("mounted_on") == sname
and m.get("shelf_face") == sface),
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
)
n = len(occ)
for idx, m in enumerate(occ):
sub_w = COL_W // n
bx = col_x + idx * sub_w
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
mname = m.get("hostname", "?")
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
p.append(
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
)
p.append(
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
)
p.append("</a>")
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
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}<br/>{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)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
def render_network(rack: str, items: list[dict]) -> str:
"""Return a mermaid network-cabling flowchart, or '' if no links.
Assumes `validate_links` has already passed: every link has a non-empty
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
hardware file. `generate` validates before any render call.
"""
linked = [fm for fm in items if fm.get("links")]
if not linked:
return ""
by_host = {fm.get("hostname"): fm for fm in items}
edges: list[tuple[str, str, str, int, object]] = []
nodes: set[str] = set()
for fm in linked:
source = fm.get("hostname", "?")
nodes.add(source)
for link in fm["links"]:
peer = link["peer"]
nodes.add(peer)
edges.append(
(source, link["local"], peer, link["peer_port"],
link.get("speed_gbps"))
)
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
def node_label(name: str) -> str:
fm = by_host.get(name)
kind = fm.get("kind") if fm else None
if kind in ("switch", "patch-panel"):
return f"{name}<br/>{kind}"
return name
lines: list[str] = ["```mermaid", "flowchart LR"]
for name in sorted(nodes):
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
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('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
network = render_network(rack, items)
if network:
lines.append("## Network")
lines.append("")
lines.append(network.rstrip())
lines.append("")
lines.append("## Occupancy")
lines.append("")
lines.append("| U | Device | Kind | Face | Status |")
lines.append("|---|---|---|---|---|")
by_host = {fm.get("hostname"): fm for fm in items}
mounted_by_shelf: dict[str, list[dict]] = {}
for fm in items:
if "mounted_on" in fm:
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
def occ_row(fm: dict) -> str:
name = fm.get("hostname", "?")
link = f"[{name}](../../hardware/{name}.md)"
if "mounted_on" in fm:
target = by_host.get(fm["mounted_on"])
if target and isinstance(target.get("rack_u"), int):
su = target["rack_u"]
sh = target["u_height"]
urange = f"U{su}" if sh == 1 else f"U{su}U{su + sh - 1}"
else:
urange = ""
face = (
f"{fm.get('shelf_face', '')} · "
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
)
else:
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}"
return (
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
f"| {fm.get('status', '')} |"
)
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
lines.append(occ_row(fm))
if fm.get("kind") == "shelf":
occ = sorted(
mounted_by_shelf.get(fm.get("hostname"), []),
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
)
for m in occ:
lines.append(occ_row(m))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def report_errors(errors: list[str]) -> None:
"""Print a collected list of problems with orientation for newcomers."""
print(
f"\ngen_rack: found {len(errors)} problem(s) in docs/hardware/:",
file=sys.stderr,
)
for err in errors:
print(f"{err}", file=sys.stderr)
print(
"\nEach line is '<device>: what's wrong'. Fix the named frontmatter "
"field(s),\nthen run 'make docs-index' again.\n"
f"Guide: {GUIDE_URL}",
file=sys.stderr,
)
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
hw_index = load_hardware_index(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)
validate_links(ritems, hw_index)
check_shelves(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
if errors:
report_errors(errors)
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())