feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot)

This commit is contained in:
sjat 2026-06-24 17:42:07 +02:00
parent 4961a748d4
commit b85479b9a0
2 changed files with 146 additions and 0 deletions

View file

@ -27,6 +27,7 @@ FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
FACES = {"front", "rear", "both", "left", "right"} FACES = {"front", "rear", "both", "left", "right"}
ZERO_U_FACES = {"left", "right"} ZERO_U_FACES = {"left", "right"}
SHELF_FACES = {"front", "rear"}
KIND_COLORS = { KIND_COLORS = {
"server": "#4c78a8", "server": "#4c78a8",
@ -76,6 +77,24 @@ def validate_item(fm: dict) -> None:
rack = fm.get("rack") rack = fm.get("rack")
if not isinstance(rack, str) or not rack: if not isinstance(rack, str) or not rack:
raise SchemaError(f"{name}: rack must be a non-empty string") 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") face = fm.get("rack_face")
if face not in FACES: if face not in FACES:
raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(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.""" """Raise if two items share a U on the same face within one rack."""
occupied: dict[tuple[str, int], str] = {} occupied: dict[tuple[str, int], str] = {}
for fm in items: for fm in items:
if "mounted_on" in fm:
continue
face = fm.get("rack_face") face = fm.get("rack_face")
if face in ZERO_U_FACES: if face in ZERO_U_FACES:
continue continue
@ -120,6 +141,43 @@ def check_overlaps(items: list[dict]) -> None:
occupied[key] = name 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]: def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup).""" """Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {} index: dict[str, dict] = {}
@ -509,6 +567,7 @@ def generate(hardware_dir: Path, output_dir: Path) -> int:
check_overlaps(ritems) check_overlaps(ritems)
validate_power(ritems) validate_power(ritems)
validate_links(ritems, hw_index) validate_links(ritems, hw_index)
check_shelves(ritems)
except SchemaError as e: except SchemaError as e:
errors.append(f"{rack}: {e}") errors.append(f"{rack}: {e}")

View file

@ -9,6 +9,13 @@ def item(**kw):
return base 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(): def test_validate_accepts_valid_placement():
gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front")) 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 "## Network" in page
assert "```mermaid" in page assert "```mermaid" in page
assert "eth0" 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()