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"}
|
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}")
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue