feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot)
This commit is contained in:
parent
4961a748d4
commit
b85479b9a0
2 changed files with 146 additions and 0 deletions
|
|
@ -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}")
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue