From 3324c01810f815e3e41a99dba11e9b827ff865b7 Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 13:42:21 +0200 Subject: [PATCH] feat(rack): gen_rack placement parsing and validation --- requirements-dev.txt | 2 + scripts/gen_rack.py | 96 ++++++++++++++++++++++++++++++++++++++++++ tests/conftest.py | 5 +++ tests/test_gen_rack.py | 42 ++++++++++++++++++ 4 files changed, 145 insertions(+) create mode 100644 requirements-dev.txt create mode 100644 scripts/gen_rack.py create mode 100644 tests/conftest.py create mode 100644 tests/test_gen_rack.py diff --git a/requirements-dev.txt b/requirements-dev.txt new file mode 100644 index 0000000..1d592ac --- /dev/null +++ b/requirements-dev.txt @@ -0,0 +1,2 @@ +-r requirements.txt +pytest==8.* diff --git a/scripts/gen_rack.py b/scripts/gen_rack.py new file mode 100644 index 0000000..a3e65d5 --- /dev/null +++ b/scripts/gen_rack.py @@ -0,0 +1,96 @@ +#!/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", "?") + 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" + ) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..76f54de --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import sys +from pathlib import Path + +# Make scripts/ importable as top-level modules in tests. +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) diff --git a/tests/test_gen_rack.py b/tests/test_gen_rack.py new file mode 100644 index 0000000..724cb59 --- /dev/null +++ b/tests/test_gen_rack.py @@ -0,0 +1,42 @@ +import pytest + +import gen_rack + + +def item(**kw): + base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"} + base.update(kw) + return base + + +def test_validate_accepts_valid_placement(): + gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front")) + + +def test_validate_rejects_u_overflow(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front")) + + +def test_validate_rejects_u_below_one(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front")) + + +def test_validate_rejects_bad_face(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways")) + + +def test_validate_rejects_zero_u_with_units(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1)) + + +def test_validate_accepts_zero_u_rail(): + gen_rack.validate_item(item(rack_face="left")) + + +def test_validate_rejects_missing_units_on_faced_item(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(rack_face="front"))