From b85479b9a0cd7b0124110a0bfcf9137f00f403ab Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 17:42:07 +0200 Subject: [PATCH] feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot) --- scripts/gen_rack.py | 59 ++++++++++++++++++++++++++++ tests/test_gen_rack.py | 87 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+) diff --git a/scripts/gen_rack.py b/scripts/gen_rack.py index f950472..dec52d0 100644 --- a/scripts/gen_rack.py +++ b/scripts/gen_rack.py @@ -27,6 +27,7 @@ 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"} KIND_COLORS = { "server": "#4c78a8", @@ -76,6 +77,24 @@ def validate_item(fm: dict) -> None: rack = fm.get("rack") if not isinstance(rack, str) or not rack: raise SchemaError(f"{name}: rack must be a non-empty string") + 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 be a non-empty string") + for forbidden in ("rack_u", "u_height", "rack_face"): + if forbidden in fm: + raise SchemaError( + f"{name}: mounted item must omit {forbidden}" + ) + sface = fm.get("shelf_face") + if sface not in SHELF_FACES: + raise SchemaError( + f"{name}: shelf_face={sface!r} not in {sorted(SHELF_FACES)}" + ) + slot = fm.get("shelf_slot") + if not isinstance(slot, int) or slot < 1: + raise SchemaError(f"{name}: shelf_slot must be an integer >= 1") + return face = fm.get("rack_face") if face not in FACES: raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(FACES)}") @@ -103,6 +122,8 @@ 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 @@ -120,6 +141,43 @@ def check_overlaps(items: list[dict]) -> None: 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} is not in this rack" + ) + if target.get("kind") != "shelf": + raise SchemaError( + f"{name}: mounted_on={shelf_name!r} is not a kind:shelf item" + ) + if not isinstance(target.get("rack_u"), int) or not isinstance( + target.get("u_height"), int + ): + raise SchemaError( + f"{name}: shelf {shelf_name!r} is not placed (needs rack_u/u_height)" + ) + 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]}" + ) + 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] = {} @@ -509,6 +567,7 @@ def generate(hardware_dir: Path, output_dir: Path) -> int: check_overlaps(ritems) validate_power(ritems) validate_links(ritems, hw_index) + check_shelves(ritems) except SchemaError as e: errors.append(f"{rack}: {e}") diff --git a/tests/test_gen_rack.py b/tests/test_gen_rack.py index 39924b6..a874675 100644 --- a/tests/test_gen_rack.py +++ b/tests/test_gen_rack.py @@ -9,6 +9,13 @@ def item(**kw): return base +def shelf(**kw): + base = {"hostname": "shf01", "kind": "shelf", "status": "in-use", + "rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"} + 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")) @@ -510,3 +517,83 @@ def test_generate_includes_network_section(tmp_path): assert "## Network" in page assert "```mermaid" in page assert "eth0" in page + + +def test_validate_accepts_mounted_item(): + gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01", + shelf_face="front", shelf_slot=1)) + + +def test_validate_rejects_mounted_with_rack_u(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01", + shelf_face="front", shelf_slot=1, rack_u=5)) + + +def test_validate_rejects_mounted_bad_shelf_face(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01", + shelf_face="left", shelf_slot=1)) + + +def test_validate_rejects_mounted_bad_slot(): + with pytest.raises(gen_rack.SchemaError): + gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01", + shelf_face="front", shelf_slot=0)) + + +def test_overlaps_skips_mounted_items(): + items = [ + item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1), + item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2), + ] + gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range + + +def test_check_shelves_accepts_valid_mount(): + items = [shelf(), + item(hostname="srv01", mounted_on="shf01", + shelf_face="front", shelf_slot=1)] + gen_rack.check_shelves(items) + + +def test_check_shelves_rejects_missing_shelf(): + items = [item(hostname="srv01", mounted_on="ghost", + shelf_face="front", shelf_slot=1)] + with pytest.raises(gen_rack.SchemaError): + gen_rack.check_shelves(items) + + +def test_check_shelves_rejects_non_shelf_target(): + items = [ + item(hostname="sw01", kind="switch", rack_u=10, u_height=1, + rack_face="front"), + item(hostname="srv01", mounted_on="sw01", + shelf_face="front", shelf_slot=1), + ] + with pytest.raises(gen_rack.SchemaError): + gen_rack.check_shelves(items) + + +def test_check_shelves_rejects_duplicate_slot(): + items = [shelf(), + item(hostname="srv01", mounted_on="shf01", + shelf_face="front", shelf_slot=1), + item(hostname="srv02", mounted_on="shf01", + shelf_face="front", shelf_slot=1)] + with pytest.raises(gen_rack.SchemaError): + gen_rack.check_shelves(items) + + +def test_generate_returns_1_on_dangling_mount(tmp_path): + hw = tmp_path / "hardware" + out = tmp_path / "out" + hw.mkdir() + _write_item( + hw, "srv01", + "---\nhostname: srv01\nkind: server\nstatus: in-use\n" + "rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n", + ) + rc = gen_rack.generate(hw, out) + assert rc == 1 + assert not (out / "rack01.md").exists()