Compare commits
No commits in common. "c24978436d66ddaeff9ef64444500b649d154633" and "613a5c3cab6e63529f9bba462411f6ab42376d4b" have entirely different histories.
c24978436d
...
613a5c3cab
11 changed files with 25 additions and 1142 deletions
|
|
@ -26,12 +26,6 @@ _Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make doc
|
||||||
| [srv04](srv04.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
| [srv04](srv04.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
||||||
| [srv05](srv05.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
| [srv05](srv05.md) | The pile | Intel Core i5-3570K @ 3.40GHz · 4c | 8 GB | 500 GB HDD | 1 GbE | staging |
|
||||||
|
|
||||||
## Shelves
|
|
||||||
|
|
||||||
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
|
||||||
|---|---|---|---|---|---|---|
|
|
||||||
| [shf01](shf01.md) | | | | | | in-use |
|
|
||||||
|
|
||||||
## Switches
|
## Switches
|
||||||
|
|
||||||
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
---
|
|
||||||
hostname: shf01
|
|
||||||
kind: shelf
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 37
|
|
||||||
u_height: 10
|
|
||||||
rack_face: both
|
|
||||||
cluster: tappaas
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder shelf holding the TaPPaaS nodes (srv01/srv02 front, srv03 rear).
|
|
||||||
|
|
@ -11,9 +11,9 @@ ram_gb: "?"
|
||||||
storage: "?"
|
storage: "?"
|
||||||
nic_gbps: "?"
|
nic_gbps: "?"
|
||||||
rack: rack01
|
rack: rack01
|
||||||
mounted_on: shf01
|
rack_u: 1
|
||||||
shelf_face: front
|
u_height: 1
|
||||||
shelf_slot: 1
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 1 }
|
- { pdu: pdu01, outlet: 1 }
|
||||||
- { pdu: pdu02, outlet: 1 }
|
- { pdu: pdu02, outlet: 1 }
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
hostname: srv02
|
hostname: srv02
|
||||||
kind: server
|
kind: server
|
||||||
status: staging
|
status: staging
|
||||||
cluster: tappaas
|
|
||||||
location: The pile
|
location: The pile
|
||||||
cpu: Intel Core i5-8500 @ 3.00GHz
|
cpu: Intel Core i5-8500 @ 3.00GHz
|
||||||
cpu_cores: 6
|
cpu_cores: 6
|
||||||
|
|
@ -13,9 +12,9 @@ storage_gb: 40
|
||||||
storage_type: nvme
|
storage_type: nvme
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
rack: rack01
|
rack: rack01
|
||||||
mounted_on: shf01
|
rack_u: 2
|
||||||
shelf_face: front
|
u_height: 1
|
||||||
shelf_slot: 2
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 2 }
|
- { pdu: pdu01, outlet: 2 }
|
||||||
links:
|
links:
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,6 @@
|
||||||
hostname: srv03
|
hostname: srv03
|
||||||
kind: server
|
kind: server
|
||||||
status: staging
|
status: staging
|
||||||
cluster: tappaas
|
|
||||||
location: The pile
|
location: The pile
|
||||||
cpu: Intel Core i5-8500 @ 3.00GHz
|
cpu: Intel Core i5-8500 @ 3.00GHz
|
||||||
cpu_cores: 6
|
cpu_cores: 6
|
||||||
|
|
@ -13,9 +12,9 @@ storage_gb: 40
|
||||||
storage_type: nvme
|
storage_type: nvme
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
rack: rack01
|
rack: rack01
|
||||||
mounted_on: shf01
|
rack_u: 3
|
||||||
shelf_face: rear
|
u_height: 1
|
||||||
shelf_slot: 1
|
rack_face: front
|
||||||
power:
|
power:
|
||||||
- { pdu: pdu01, outlet: 3 }
|
- { pdu: pdu01, outlet: 3 }
|
||||||
links:
|
links:
|
||||||
|
|
|
||||||
|
|
@ -147,6 +147,12 @@
|
||||||
<text x="54" y="954" text-anchor="end" fill="#999">46</text>
|
<text x="54" y="954" text-anchor="end" fill="#999">46</text>
|
||||||
<text x="54" y="974" text-anchor="end" fill="#999">47</text>
|
<text x="54" y="974" text-anchor="end" fill="#999">47</text>
|
||||||
<text x="54" y="994" text-anchor="end" fill="#999">48</text>
|
<text x="54" y="994" text-anchor="end" fill="#999">48</text>
|
||||||
|
<rect x="59" y="41" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="178" y="54" text-anchor="middle" fill="#ffffff">srv01 (U1)</text>
|
||||||
|
<rect x="59" y="61" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="178" y="74" text-anchor="middle" fill="#ffffff">srv02 (U2)</text>
|
||||||
|
<rect x="59" y="81" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="178" y="94" text-anchor="middle" fill="#ffffff">srv03 (U3)</text>
|
||||||
<rect x="59" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
<rect x="59" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
<text x="178" y="144" text-anchor="middle" fill="#ffffff">srv04 (U5–U6)</text>
|
<text x="178" y="144" text-anchor="middle" fill="#ffffff">srv04 (U5–U6)</text>
|
||||||
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
<rect x="349" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
|
@ -159,13 +165,4 @@
|
||||||
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
|
<text x="20" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 20 520)">pdu01</text>
|
||||||
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
<rect x="588" y="40" width="16" height="960" fill="#e15759" stroke="#333"/>
|
||||||
<text x="596" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 596 520)">pdu02</text>
|
<text x="596" y="520" text-anchor="middle" fill="#ffffff" transform="rotate(-90 596 520)">pdu02</text>
|
||||||
<rect x="59" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333"/>
|
|
||||||
<text x="118" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
|
|
||||||
<rect x="179" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333"/>
|
|
||||||
<text x="238" y="861" text-anchor="middle" fill="#ffffff">srv02</text>
|
|
||||||
<rect x="58" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
|
|
||||||
<rect x="349" y="761" width="238" height="192" rx="3" fill="#4c78a8" stroke="#333"/>
|
|
||||||
<text x="468" y="861" text-anchor="middle" fill="#ffffff">srv03</text>
|
|
||||||
<rect x="348" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
|
|
||||||
<text x="178" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
|
|
||||||
</svg>
|
</svg>
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
|
@ -48,13 +48,12 @@ flowchart LR
|
||||||
|
|
||||||
| U | Device | Kind | Face | Status |
|
| U | Device | Kind | Face | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| U1 | [srv01](../../hardware/srv01.md) | server | front | staging |
|
||||||
|
| U2 | [srv02](../../hardware/srv02.md) | server | front | staging |
|
||||||
|
| U3 | [srv03](../../hardware/srv03.md) | server | front | staging |
|
||||||
| U5–U6 | [srv04](../../hardware/srv04.md) | server | front | staging |
|
| U5–U6 | [srv04](../../hardware/srv04.md) | server | front | staging |
|
||||||
| U5–U6 | [srv05](../../hardware/srv05.md) | server | rear | staging |
|
| U5–U6 | [srv05](../../hardware/srv05.md) | server | rear | staging |
|
||||||
| U10 | [sw01](../../hardware/sw01.md) | switch | front | in-use |
|
| U10 | [sw01](../../hardware/sw01.md) | switch | front | in-use |
|
||||||
| U24 | [pp01](../../hardware/pp01.md) | patch-panel | front | in-use |
|
| U24 | [pp01](../../hardware/pp01.md) | patch-panel | front | in-use |
|
||||||
| U37–U46 | [shf01](../../hardware/shf01.md) | shelf | both | in-use |
|
|
||||||
| U37–U46 | [srv01](../../hardware/srv01.md) | server | front · shf01/1 | staging |
|
|
||||||
| U37–U46 | [srv02](../../hardware/srv02.md) | server | front · shf01/2 | staging |
|
|
||||||
| U37–U46 | [srv03](../../hardware/srv03.md) | server | rear · shf01/1 | staging |
|
|
||||||
| 0U | [pdu01](../../hardware/pdu01.md) | pdu | left | in-use |
|
| 0U | [pdu01](../../hardware/pdu01.md) | pdu | left | in-use |
|
||||||
| 0U | [pdu02](../../hardware/pdu02.md) | pdu | right | in-use |
|
| 0U | [pdu02](../../hardware/pdu02.md) | pdu | right | in-use |
|
||||||
|
|
|
||||||
|
|
@ -1,677 +0,0 @@
|
||||||
# Shelf-Mounted Devices Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Model cabinet/tower PCs that sit on a rack shelf — the shelf reserves a U-range, occupants attach via `mounted_on`/`shelf_face`/`shelf_slot` — and render them as side-by-side boxes in the elevation.
|
|
||||||
|
|
||||||
**Architecture:** Extend `scripts/gen_rack.py`: a `mounted_on` branch in `validate_item`, a skip in `check_overlaps`, a new `check_shelves` cross-item validator, and shelf rendering in `render_svg` (a shelf strip plus per-occupant boxes subdividing the column) and `render_page` (occupancy rows for mounted devices). Then populate the worked example. No generator-config or CI changes.
|
|
||||||
|
|
||||||
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
|
|
||||||
|
|
||||||
**Spec:** `notes/dev/specs/2026-06-24-rack-shelves-design.md`.
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy existing `gen_rack.py` style). No randomness/time in generated output.
|
|
||||||
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
|
|
||||||
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure.
|
|
||||||
- A **mounted device** declares `mounted_on` (str), `shelf_face` ∈ {front, rear}, `shelf_slot` (int ≥ 1), and **omits** `rack_u`/`u_height`/`rack_face`. A **shelf** (`kind: shelf`) is placed normally (`rack_u`/`u_height`/`rack_face: both`) and reserves the assembly's U-range.
|
|
||||||
- Peer/PDU/grouping fields (`power:`, `links:`, `cluster:`) on a mounted device are unchanged — they key off hostname, not placement.
|
|
||||||
- Reuse the existing `item()` and `_write_item` test helpers in `tests/test_gen_rack.py`; add a local `shelf()` helper where noted.
|
|
||||||
- `isinstance(x, int)` style (bool-is-int acceptable, matching existing code).
|
|
||||||
- Provisional placeholder data only (matches the existing `srvNN` positions and power/network demos).
|
|
||||||
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`shelf`/`server` already in the enum; drift already covers `racks/`).
|
|
||||||
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 1: Validation — mounted branch, overlap skip, `check_shelves` (TDD)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `scripts/gen_rack.py` (add `SHELF_FACES`; `validate_item` mounted branch; `check_overlaps` skip; new `check_shelves`; call it in `generate`)
|
|
||||||
- Modify: `tests/test_gen_rack.py` (append tests + a `shelf()` helper)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `SchemaError`, `item()`/`_write_item` helpers, `generate`.
|
|
||||||
- Produces:
|
|
||||||
- `SHELF_FACES = {"front", "rear"}` (constant)
|
|
||||||
- `check_shelves(items: list[dict]) -> None` — raises `SchemaError` on a bad mount.
|
|
||||||
- `validate_item` and `check_overlaps` gain mounted-item handling.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Append a `shelf()` helper and failing tests to `tests/test_gen_rack.py`**
|
|
||||||
|
|
||||||
Add near the top, just after the existing `item()` helper:
|
|
||||||
|
|
||||||
```python
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
Append these tests at the end of the file:
|
|
||||||
|
|
||||||
```python
|
|
||||||
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()
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'check_shelves'` (and the mounted-validation tests fail because `validate_item` rejects the missing `rack_face`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Add the `SHELF_FACES` constant**
|
|
||||||
|
|
||||||
In `scripts/gen_rack.py`, just after the existing `ZERO_U_FACES = {"left", "right"}` line, add:
|
|
||||||
|
|
||||||
```python
|
|
||||||
SHELF_FACES = {"front", "rear"}
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Add the mounted branch to `validate_item`**
|
|
||||||
|
|
||||||
In `validate_item`, the function currently begins:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def validate_item(fm: dict) -> None:
|
|
||||||
name = fm.get("hostname") or fm.get("_path", "?")
|
|
||||||
rack = fm.get("rack")
|
|
||||||
if not isinstance(rack, str) or not rack:
|
|
||||||
raise SchemaError(f"{name}: rack must be a non-empty string")
|
|
||||||
face = fm.get("rack_face")
|
|
||||||
```
|
|
||||||
|
|
||||||
Insert the mounted branch between the `rack` check and the `face = …` line, so it reads:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def validate_item(fm: dict) -> None:
|
|
||||||
name = fm.get("hostname") or fm.get("_path", "?")
|
|
||||||
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")
|
|
||||||
```
|
|
||||||
|
|
||||||
(The rest of `validate_item` — the `face`/0U/U-range checks — is unchanged.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Skip mounted items in `check_overlaps`**
|
|
||||||
|
|
||||||
In `check_overlaps`, the loop body currently starts:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for fm in items:
|
|
||||||
face = fm.get("rack_face")
|
|
||||||
if face in ZERO_U_FACES:
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
Add a mounted skip as the first thing in the loop:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for fm in items:
|
|
||||||
if "mounted_on" in fm:
|
|
||||||
continue
|
|
||||||
face = fm.get("rack_face")
|
|
||||||
if face in ZERO_U_FACES:
|
|
||||||
continue
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 6: Add `check_shelves` after `check_overlaps`**
|
|
||||||
|
|
||||||
Add this function immediately after `check_overlaps` in `scripts/gen_rack.py`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
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
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 7: Wire `check_shelves` into `generate`**
|
|
||||||
|
|
||||||
In `generate`, the per-rack validation loop currently reads:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
check_overlaps(ritems)
|
|
||||||
validate_power(ritems)
|
|
||||||
validate_links(ritems, hw_index)
|
|
||||||
except SchemaError as e:
|
|
||||||
errors.append(f"{rack}: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
Add `check_shelves(ritems)`:
|
|
||||||
|
|
||||||
```python
|
|
||||||
try:
|
|
||||||
check_overlaps(ritems)
|
|
||||||
validate_power(ritems)
|
|
||||||
validate_links(ritems, hw_index)
|
|
||||||
check_shelves(ritems)
|
|
||||||
except SchemaError as e:
|
|
||||||
errors.append(f"{rack}: {e}")
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 8: Run to verify pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: PASS (all prior tests + 10 new).
|
|
||||||
|
|
||||||
- [ ] **Step 9: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
|
||||||
git commit -m "feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Rendering — shelf strip + occupant boxes + occupancy rows (TDD)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `scripts/gen_rack.py` (`render_svg` shelf drawing; `render_page` occupancy)
|
|
||||||
- Modify: `tests/test_gen_rack.py` (append tests)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `render_svg`, `render_page`, the `shelf()` helper (Task 1).
|
|
||||||
- Produces: `render_svg`/`render_page` render shelves and mounted occupants. No new public function.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
|
|
||||||
|
|
||||||
```python
|
|
||||||
def test_render_svg_draws_shelf_and_occupants():
|
|
||||||
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=2),
|
|
||||||
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
|
|
||||||
]
|
|
||||||
svg = gen_rack.render_svg("rack01", items)
|
|
||||||
assert "shf01" in svg
|
|
||||||
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
|
|
||||||
# the shelf is NOT drawn as a generic full-height device box
|
|
||||||
assert "shf01 (U37" not in svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_svg_shelf_is_deterministic():
|
|
||||||
base = [
|
|
||||||
shelf(),
|
|
||||||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
|
||||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
|
||||||
]
|
|
||||||
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
|
|
||||||
"rack01", list(reversed(base))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_page_lists_mounted_devices():
|
|
||||||
items = [shelf(),
|
|
||||||
item(hostname="srv01", mounted_on="shf01",
|
|
||||||
shelf_face="front", shelf_slot=1)]
|
|
||||||
page = gen_rack.render_page("rack01", items)
|
|
||||||
assert "../../hardware/srv01.md" in page
|
|
||||||
assert "front · shf01/1" in page
|
|
||||||
assert "U37–U46" in page # mounted device shows its shelf's U-range
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Run to verify failure**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: FAIL — `test_render_page_lists_mounted_devices` raises `KeyError: 'rack_u'` (occupancy loop has no mounted handling) and the SVG tests fail (`shf01`/occupant boxes not drawn; the shelf is drawn as a generic box so `"shf01 (U37"` is present).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Render shelves in `render_svg`**
|
|
||||||
|
|
||||||
In `render_svg`, the generic device loop currently reads:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for fm in items:
|
|
||||||
face = fm.get("rack_face")
|
|
||||||
if face in ("front", "both"):
|
|
||||||
draw_device(fm, front_x)
|
|
||||||
if face in ("rear", "both"):
|
|
||||||
draw_device(fm, rear_x)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace it with a version that skips shelves and mounted items:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for fm in items:
|
|
||||||
if fm.get("kind") == "shelf" or "mounted_on" in fm:
|
|
||||||
continue
|
|
||||||
face = fm.get("rack_face")
|
|
||||||
if face in ("front", "both"):
|
|
||||||
draw_device(fm, front_x)
|
|
||||||
if face in ("rear", "both"):
|
|
||||||
draw_device(fm, rear_x)
|
|
||||||
```
|
|
||||||
|
|
||||||
Then, immediately before the final `p.append("</svg>")` line, add the shelf drawing:
|
|
||||||
|
|
||||||
```python
|
|
||||||
SHELF_STRIP_H = 6
|
|
||||||
shelves = [i for i in items if i.get("kind") == "shelf"]
|
|
||||||
mounted = [i for i in items if "mounted_on" in i]
|
|
||||||
|
|
||||||
def draw_shelf(fm: dict) -> None:
|
|
||||||
u = fm["rack_u"]
|
|
||||||
h = fm["u_height"]
|
|
||||||
y = u_y(u)
|
|
||||||
block_h = h * U_H
|
|
||||||
strip_y = y + block_h - SHELF_STRIP_H
|
|
||||||
avail_h = block_h - SHELF_STRIP_H
|
|
||||||
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
|
|
||||||
sname = fm.get("hostname", "?")
|
|
||||||
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
|
|
||||||
occ = sorted(
|
|
||||||
(m for m in mounted
|
|
||||||
if m.get("mounted_on") == sname
|
|
||||||
and m.get("shelf_face") == sface),
|
|
||||||
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
|
|
||||||
)
|
|
||||||
n = len(occ)
|
|
||||||
for idx, m in enumerate(occ):
|
|
||||||
sub_w = COL_W // n
|
|
||||||
bx = col_x + idx * sub_w
|
|
||||||
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
|
|
||||||
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
|
|
||||||
mname = m.get("hostname", "?")
|
|
||||||
p.append(
|
|
||||||
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
|
|
||||||
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" stroke="#333"/>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
|
|
||||||
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
|
|
||||||
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
|
|
||||||
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
|
|
||||||
)
|
|
||||||
|
|
||||||
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
|
|
||||||
draw_shelf(fm)
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Render mounted devices in the `render_page` occupancy table**
|
|
||||||
|
|
||||||
In `render_page`, the occupancy loop currently reads:
|
|
||||||
|
|
||||||
```python
|
|
||||||
for fm in items:
|
|
||||||
name = fm.get("hostname", "?")
|
|
||||||
link = f"[{name}](../../hardware/{name}.md)"
|
|
||||||
face = fm.get("rack_face", "")
|
|
||||||
if face in ZERO_U_FACES:
|
|
||||||
urange = "0U"
|
|
||||||
else:
|
|
||||||
u = fm["rack_u"]
|
|
||||||
h = fm["u_height"]
|
|
||||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
|
||||||
lines.append(
|
|
||||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
|
||||||
f"| {fm.get('status', '')} |"
|
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
Replace that whole loop with one that orders mounted devices right after their
|
|
||||||
shelf and renders their shelf-relative position:
|
|
||||||
|
|
||||||
```python
|
|
||||||
by_host = {fm.get("hostname"): fm for fm in items}
|
|
||||||
mounted_by_shelf: dict[str, list[dict]] = {}
|
|
||||||
for fm in items:
|
|
||||||
if "mounted_on" in fm:
|
|
||||||
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
|
|
||||||
|
|
||||||
def occ_row(fm: dict) -> str:
|
|
||||||
name = fm.get("hostname", "?")
|
|
||||||
link = f"[{name}](../../hardware/{name}.md)"
|
|
||||||
if "mounted_on" in fm:
|
|
||||||
target = by_host.get(fm["mounted_on"])
|
|
||||||
if target and isinstance(target.get("rack_u"), int):
|
|
||||||
su = target["rack_u"]
|
|
||||||
sh = target["u_height"]
|
|
||||||
urange = f"U{su}" if sh == 1 else f"U{su}–U{su + sh - 1}"
|
|
||||||
else:
|
|
||||||
urange = "—"
|
|
||||||
face = (
|
|
||||||
f"{fm.get('shelf_face', '')} · "
|
|
||||||
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
face = fm.get("rack_face", "")
|
|
||||||
if face in ZERO_U_FACES:
|
|
||||||
urange = "0U"
|
|
||||||
else:
|
|
||||||
u = fm["rack_u"]
|
|
||||||
h = fm["u_height"]
|
|
||||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
|
||||||
return (
|
|
||||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
|
||||||
f"| {fm.get('status', '')} |"
|
|
||||||
)
|
|
||||||
|
|
||||||
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
|
|
||||||
lines.append(occ_row(fm))
|
|
||||||
if fm.get("kind") == "shelf":
|
|
||||||
occ = sorted(
|
|
||||||
mounted_by_shelf.get(fm.get("hostname"), []),
|
|
||||||
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
|
|
||||||
)
|
|
||||||
for m in occ:
|
|
||||||
lines.append(occ_row(m))
|
|
||||||
```
|
|
||||||
|
|
||||||
(The `items = _sorted_items(items)` line at the top of `render_page` and the
|
|
||||||
graph sections above are unchanged.)
|
|
||||||
|
|
||||||
- [ ] **Step 5: Run to verify pass**
|
|
||||||
|
|
||||||
Run: `pytest tests/test_gen_rack.py -q`
|
|
||||||
Expected: PASS (all prior tests + 3 new).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
|
||||||
git commit -m "feat(rack): render shelf strip, occupant boxes, and mounted occupancy rows"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Populate the shelf demo, regenerate
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `docs/hardware/shf01.md`
|
|
||||||
- Modify: `docs/hardware/srv01.md`, `srv02.md`, `srv03.md` (convert to mounted)
|
|
||||||
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
- Consumes: `make docs-index`, `make test`, `mkdocs build --strict`, `make docs-check`.
|
|
||||||
|
|
||||||
> **Operator note — provisional data.** The shelf placement and the front/rear/slot assignments are placeholders matching the worked example (TaPPaaS: srv01/srv02 front, srv03 rear, on a shelf reserving U37–U46). `check_shelves`/`check_overlaps` reject inconsistent data loudly.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the shelf file**
|
|
||||||
|
|
||||||
Create `docs/hardware/shf01.md`:
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
---
|
|
||||||
hostname: shf01
|
|
||||||
kind: shelf
|
|
||||||
status: in-use
|
|
||||||
rack: rack01
|
|
||||||
rack_u: 37
|
|
||||||
u_height: 10
|
|
||||||
rack_face: both
|
|
||||||
cluster: tappaas
|
|
||||||
---
|
|
||||||
|
|
||||||
## Notes
|
|
||||||
|
|
||||||
- Provisional placeholder shelf holding the TaPPaaS nodes (srv01/srv02 front, srv03 rear).
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Convert `srv01.md` to mounted**
|
|
||||||
|
|
||||||
In `docs/hardware/srv01.md`, replace these three frontmatter lines:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rack_u: 1
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
mounted_on: shf01
|
|
||||||
shelf_face: front
|
|
||||||
shelf_slot: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Leave everything else (including `cluster: tappaas`, `power:`, `links:`) unchanged.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Convert `srv02.md` to mounted and tag its cluster**
|
|
||||||
|
|
||||||
In `docs/hardware/srv02.md`, replace these three frontmatter lines:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rack_u: 2
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
mounted_on: shf01
|
|
||||||
shelf_face: front
|
|
||||||
shelf_slot: 2
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add a `cluster: tappaas` line immediately after the `status: staging` line, so the top reads:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
hostname: srv02
|
|
||||||
kind: server
|
|
||||||
status: staging
|
|
||||||
cluster: tappaas
|
|
||||||
location: The pile
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 4: Convert `srv03.md` to mounted and tag its cluster**
|
|
||||||
|
|
||||||
In `docs/hardware/srv03.md`, replace these three frontmatter lines:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
rack_u: 3
|
|
||||||
u_height: 1
|
|
||||||
rack_face: front
|
|
||||||
```
|
|
||||||
|
|
||||||
with:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
mounted_on: shf01
|
|
||||||
shelf_face: rear
|
|
||||||
shelf_slot: 1
|
|
||||||
```
|
|
||||||
|
|
||||||
Then add a `cluster: tappaas` line immediately after the `status: staging` line:
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
hostname: srv03
|
|
||||||
kind: server
|
|
||||||
status: staging
|
|
||||||
cluster: tappaas
|
|
||||||
location: The pile
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Regenerate all indices and rack artifacts**
|
|
||||||
|
|
||||||
Run: `make docs-index`
|
|
||||||
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing `shf01` under Shelves); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (10 item(s))`, exit 0 (no schema error).
|
|
||||||
|
|
||||||
- [ ] **Step 6: Confirm the shelf and mounted devices rendered**
|
|
||||||
|
|
||||||
Run: `grep -c "shf01/" docs/infrastructure/racks/rack01.md`
|
|
||||||
Expected: `3` (occupancy notes `front · shf01/1`, `front · shf01/2`, `rear · shf01/1`).
|
|
||||||
|
|
||||||
Run: `grep -q "shf01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
|
|
||||||
Expected: `OK` (shelf strip drawn).
|
|
||||||
|
|
||||||
Run: `grep -q "U37–U46" docs/infrastructure/racks/rack01.md && echo OK`
|
|
||||||
Expected: `OK` (mounted devices show the shelf's U-range).
|
|
||||||
|
|
||||||
- [ ] **Step 7: Run the full test suite**
|
|
||||||
|
|
||||||
Run: `make test`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 8: Build the site strictly**
|
|
||||||
|
|
||||||
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict` if `mkdocs` is not on PATH)
|
|
||||||
Expected: build succeeds with no warnings-as-errors.
|
|
||||||
|
|
||||||
- [ ] **Step 9: Confirm the drift guard is satisfied**
|
|
||||||
|
|
||||||
Run: `make docs-check`
|
|
||||||
Expected: exit 0.
|
|
||||||
|
|
||||||
- [ ] **Step 10: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add docs/hardware/ docs/infrastructure/racks/
|
|
||||||
git commit -m "feat(rack): place TaPPaaS nodes on shelf shf01 (provisional)"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Self-Review
|
|
||||||
|
|
||||||
**Spec coverage (`2026-06-24-rack-shelves-design.md`):**
|
|
||||||
- Container model: shelf placed + reserves U-range; mounted devices via `mounted_on`/`shelf_face`/`shelf_slot` — Task 1 (validation), Task 3 (data). ✔
|
|
||||||
- `validate_item` mounted branch (forbid rack_u/u_height/rack_face; require shelf_face/shelf_slot) — Task 1 Step 4. ✔
|
|
||||||
- `check_overlaps` skips mounted items — Task 1 Step 5. ✔
|
|
||||||
- `check_shelves` (resolves to placed kind:shelf in rack; unique slot) wired into `generate` — Task 1 Steps 6–7. ✔
|
|
||||||
- SVG shelf strip + subdivided occupant boxes — Task 2 Step 3. ✔
|
|
||||||
- Occupancy rows for mounted devices (shelf U-range + `face · shelf/slot`, ordered after the shelf) — Task 2 Step 4. ✔
|
|
||||||
- Power/network unchanged — no edits to `render_power`/`render_network`; mounted devices keep `power:`/`links:`. ✔
|
|
||||||
- Demo data (shf01 U37–U46; srv01/srv02 front 1–2, srv03 rear 1; cluster tappaas) — Task 3. ✔
|
|
||||||
- No mkdocs/Makefile/CI/overview_config changes — Global Constraints. ✔
|
|
||||||
|
|
||||||
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The provisional demo placements are real-data-dependent and explicitly bounded.
|
|
||||||
|
|
||||||
**Type consistency:** `SHELF_FACES: set`; `check_shelves(items) -> None` (raises `SchemaError`), called per-rack alongside `check_overlaps`/`validate_power`/`validate_links`; `render_svg`/`render_page` -> `str`. Mounted items are identified uniformly by `"mounted_on" in fm` across `validate_item`, `check_overlaps`, `check_shelves`, `render_svg`, and `render_page`. The `shelf()` test helper (Task 1) is reused in Task 2. Field names (`mounted_on`, `shelf_face`, `shelf_slot`) match across tasks, tests, and demo data.
|
|
||||||
|
|
@ -1,155 +0,0 @@
|
||||||
# Shelf-Mounted Devices Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-24
|
|
||||||
**Status:** Approved
|
|
||||||
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (extension)
|
|
||||||
|
|
||||||
## Goal
|
|
||||||
|
|
||||||
Let the rack model represent cabinet/tower-style PCs that sit on a rack shelf
|
|
||||||
rather than bolting into the rails. Several PCs share one shelf, side by side and
|
|
||||||
front/back, and the assembly spans a tall U-range (e.g. a shelf at U46 with three
|
|
||||||
towers standing up to U37 = a reserved block U37–U46). The current model cannot
|
|
||||||
express this: two PCs in the same U-range on the same face trip `check_overlaps`.
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
- Placement today (`scripts/gen_rack.py`): a rack item declares `rack_u` (lowest
|
|
||||||
U), `u_height`, and `rack_face` ∈ {front, rear, both, left, right}. `left`/`right`
|
|
||||||
are 0U side-rail items that omit `rack_u`/`u_height` and attach to a rail.
|
|
||||||
`check_overlaps` rejects two items sharing a U on the same face; it already
|
|
||||||
skips 0U rail items.
|
|
||||||
- This gives a precedent: **some items don't claim a U-range directly** (0U rails
|
|
||||||
attach to a rail). Shelf-mounted PCs are a third placement style — they attach
|
|
||||||
to a shelf.
|
|
||||||
- The physical rack is labeled U1 at the top → U48 at the bottom, so a shelf at
|
|
||||||
U46 (near the bottom) with towers standing upward to U37 reserves U37–U46.
|
|
||||||
- `shelf` is already a valid `kind` (abbrev `shf`) with a color in `KIND_COLORS`.
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### 1. Container model
|
|
||||||
|
|
||||||
The **shelf** is the rack-placed item; tower PCs are **contained by** it.
|
|
||||||
|
|
||||||
- A shelf (`kind: shelf`) is placed normally: `rack_u`, `u_height`,
|
|
||||||
`rack_face: both` (full depth). Its `rack_u`/`u_height` **reserve the whole
|
|
||||||
assembly's U-range** (the shelf plus the towers standing on it), e.g.
|
|
||||||
`rack_u: 37, u_height: 10`.
|
|
||||||
- A **mounted device** declares, instead of `rack_u`/`u_height`/`rack_face`:
|
|
||||||
- `mounted_on: <shelf hostname>` — the shelf it sits on.
|
|
||||||
- `shelf_face: front | rear` — which side of the shelf.
|
|
||||||
- `shelf_slot: <integer ≥ 1>` — left-to-right position within that face.
|
|
||||||
|
|
||||||
Mounted items still declare `rack: <rack>` (so they load as rack items) and may
|
|
||||||
carry `cluster:`, `power:`, `links:` unchanged — those key off hostname, not
|
|
||||||
placement.
|
|
||||||
|
|
||||||
No `scripts/overview_config.yml` change: `shelf` and `server` kinds already
|
|
||||||
exist; `mounted_on`/`shelf_face`/`shelf_slot` are extra fields `gen_overview.py`
|
|
||||||
ignores and `gen_rack.py` validates.
|
|
||||||
|
|
||||||
### 2. Validation (in `gen_rack.py`)
|
|
||||||
|
|
||||||
`validate_item` gains a placement branch, checked before the existing rail logic:
|
|
||||||
|
|
||||||
- If `mounted_on` is present, the item is **mounted**:
|
|
||||||
- require `shelf_face ∈ {front, rear}`;
|
|
||||||
- require `shelf_slot` to be an integer ≥ 1;
|
|
||||||
- forbid `rack_u`, `u_height`, and `rack_face` (mutually exclusive with
|
|
||||||
mounting, mirroring the 0U rule).
|
|
||||||
- Otherwise the existing rules apply unchanged (rail item: `rack_face` ∈ FACES,
|
|
||||||
0U rules for left/right, U-range for the rest).
|
|
||||||
|
|
||||||
`check_overlaps` **skips mounted items** (they claim no U-range; their shelf
|
|
||||||
reserves the block) — added alongside the existing 0U skip. The shelf itself is
|
|
||||||
a normal placed item, so it still cannot overlap rail gear.
|
|
||||||
|
|
||||||
New `check_shelves(items: list[dict]) -> None` (called per rack from `generate`,
|
|
||||||
alongside `check_overlaps`/`validate_power`/`validate_links`):
|
|
||||||
|
|
||||||
- every `mounted_on` resolves to an item **in the same rack** whose `kind` is
|
|
||||||
`shelf` and which is itself placed (has integer `rack_u`/`u_height`);
|
|
||||||
- `(mounted_on, shelf_face, shelf_slot)` is unique — no two devices in the same
|
|
||||||
spot on the same shelf.
|
|
||||||
|
|
||||||
### 3. Rendering (`gen_rack.py`)
|
|
||||||
|
|
||||||
**Elevation SVG (`render_svg`):**
|
|
||||||
- The shelf draws as a thin **shelf strip** (shelf-colored rect) at the bottom 1U
|
|
||||||
of its reserved U-range, in both columns, labeled with the shelf hostname.
|
|
||||||
- Mounted occupants draw inside the reserved range, above the strip: for each
|
|
||||||
face (`front` → front column, `rear` → rear column), gather that shelf's
|
|
||||||
occupants for that face, order them by `shelf_slot` ascending, and subdivide
|
|
||||||
the column width by the **number of occupants on that face** — one labeled box
|
|
||||||
per tower (hostname), drawn side by side. This produces the approved
|
|
||||||
"two front, one back" picture.
|
|
||||||
- Determinism: occupants ordered by `(shelf_slot, hostname)`; shelves processed
|
|
||||||
in hostname order.
|
|
||||||
|
|
||||||
**Occupancy table (`render_page`):**
|
|
||||||
- Mounted devices list the **shelf's U-range** in the U column (e.g. `U37–U46`)
|
|
||||||
and a `\<shelf_face\> · \<mounted_on\>/\<shelf_slot\>` note in the Face column
|
|
||||||
(e.g. `front · shf01/1`).
|
|
||||||
- Ordering: rail items by U; each shelf's mounted devices appear immediately
|
|
||||||
after the shelf, ordered by `(shelf_face, shelf_slot)`; 0U rail items last.
|
|
||||||
|
|
||||||
**Power/network graphs:** unchanged — mounted PCs appear by hostname exactly as
|
|
||||||
rail-mounted devices do.
|
|
||||||
|
|
||||||
## Provisional demo data
|
|
||||||
|
|
||||||
Applies the worked example to the existing TaPPaaS nodes (user-confirmed):
|
|
||||||
|
|
||||||
- New `docs/hardware/shf01.md`: `kind: shelf`, `status: in-use`, `rack: rack01`,
|
|
||||||
`rack_u: 37`, `u_height: 10`, `rack_face: both`, `cluster: tappaas`.
|
|
||||||
- `srv01`: drop `rack_u`/`u_height`/`rack_face`; add `mounted_on: shf01`,
|
|
||||||
`shelf_face: front`, `shelf_slot: 1`. (Keeps `cluster: tappaas`, `power:`,
|
|
||||||
`links:`.)
|
|
||||||
- `srv02`: same, `shelf_face: front`, `shelf_slot: 2`; add `cluster: tappaas`.
|
|
||||||
- `srv03`: same, `shelf_face: rear`, `shelf_slot: 1`; add `cluster: tappaas`.
|
|
||||||
- `srv04`, `srv05` stay rail-mounted at U5–U6 (unchanged). The shelf block
|
|
||||||
U37–U46 does not overlap any existing rail item (sw01 U10, pp01 U24, srv04/05
|
|
||||||
U5–U6, pdu rails).
|
|
||||||
|
|
||||||
All placements remain provisional placeholders; `check_shelves`/`check_overlaps`
|
|
||||||
reject inconsistent data loudly.
|
|
||||||
|
|
||||||
## Integration
|
|
||||||
|
|
||||||
| File | Change |
|
|
||||||
|------|--------|
|
|
||||||
| `scripts/gen_rack.py` | `validate_item` mounted branch; `check_overlaps` skip mounted; new `check_shelves`; `render_svg` shelf strip + subdivided occupant boxes; `render_page` occupancy rows for mounted items |
|
|
||||||
| `tests/test_gen_rack.py` | mounted-validation, `check_shelves`, SVG, and occupancy cases |
|
|
||||||
| `docs/hardware/shf01.md` | New shelf file |
|
|
||||||
| `docs/hardware/srv01.md`..`srv03.md` | Convert to mounted; `srv02`/`srv03` gain `cluster: tappaas` |
|
|
||||||
| `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.*` | Regenerated |
|
|
||||||
|
|
||||||
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
|
|
||||||
`overview_config.yml` changes.
|
|
||||||
|
|
||||||
## Test plan
|
|
||||||
|
|
||||||
- **Unit — `validate_item`:** accept a valid mounted item; reject a mounted item
|
|
||||||
that also has `rack_u`/`u_height`/`rack_face`, a bad `shelf_face`, or a
|
|
||||||
non-integer/<1 `shelf_slot`.
|
|
||||||
- **Unit — `check_overlaps`:** two mounted items sharing their shelf's U-range do
|
|
||||||
not raise (mounted items are skipped).
|
|
||||||
- **Unit — `check_shelves`:** accept valid mounts; reject `mounted_on` pointing at
|
|
||||||
a missing item, at a non-`shelf` kind, or at an unplaced shelf; reject two
|
|
||||||
occupants sharing `(shelf, face, slot)`.
|
|
||||||
- **Unit — `render_svg`:** a shelf with two front + one rear occupant produces a
|
|
||||||
shelf strip and three labeled occupant boxes; front occupants share the front
|
|
||||||
column side by side; deterministic for reordered input.
|
|
||||||
- **Unit — `render_page`:** mounted devices appear in the occupancy table with the
|
|
||||||
shelf's U-range and the `face · shelf/slot` note.
|
|
||||||
- **Integration — `generate`:** valid fixtures write a page; a dangling
|
|
||||||
`mounted_on` returns `1` and writes nothing.
|
|
||||||
- **Drift / visual:** `make docs-check` exits 0; `mkdocs build --strict` renders
|
|
||||||
the rack page with the shelf block showing srv01/srv02 front and srv03 rear.
|
|
||||||
|
|
||||||
## Out of scope
|
|
||||||
|
|
||||||
- Shelf capacity limits (max slots per face) — slot-collision detection suffices.
|
|
||||||
- Modeling individual tower height separately from the shelf's reserved block.
|
|
||||||
- Nested shelves or shelves spanning racks.
|
|
||||||
|
|
@ -27,7 +27,6 @@ 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",
|
||||||
|
|
@ -77,24 +76,6 @@ 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)}")
|
||||||
|
|
@ -122,8 +103,6 @@ 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
|
||||||
|
|
@ -141,43 +120,6 @@ 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] = {}
|
||||||
|
|
@ -376,8 +318,6 @@ def render_svg(rack: str, items: list[dict]) -> str:
|
||||||
)
|
)
|
||||||
|
|
||||||
for fm in items:
|
for fm in items:
|
||||||
if fm.get("kind") == "shelf" or "mounted_on" in fm:
|
|
||||||
continue
|
|
||||||
face = fm.get("rack_face")
|
face = fm.get("rack_face")
|
||||||
if face in ("front", "both"):
|
if face in ("front", "both"):
|
||||||
draw_device(fm, front_x)
|
draw_device(fm, front_x)
|
||||||
|
|
@ -403,53 +343,6 @@ def render_svg(rack: str, items: list[dict]) -> str:
|
||||||
for idx, fm in enumerate(right_items):
|
for idx, fm in enumerate(right_items):
|
||||||
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
||||||
|
|
||||||
SHELF_STRIP_H = 6
|
|
||||||
shelves = [i for i in items if i.get("kind") == "shelf"]
|
|
||||||
mounted = [i for i in items if "mounted_on" in i]
|
|
||||||
|
|
||||||
def draw_shelf(fm: dict) -> None:
|
|
||||||
u = fm["rack_u"]
|
|
||||||
h = fm["u_height"]
|
|
||||||
y = u_y(u)
|
|
||||||
block_h = h * U_H
|
|
||||||
strip_y = y + block_h - SHELF_STRIP_H
|
|
||||||
avail_h = block_h - SHELF_STRIP_H
|
|
||||||
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
|
|
||||||
sname = fm.get("hostname", "?")
|
|
||||||
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
|
|
||||||
occ = sorted(
|
|
||||||
(m for m in mounted
|
|
||||||
if m.get("mounted_on") == sname
|
|
||||||
and m.get("shelf_face") == sface),
|
|
||||||
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
|
|
||||||
)
|
|
||||||
n = len(occ)
|
|
||||||
for idx, m in enumerate(occ):
|
|
||||||
sub_w = COL_W // n
|
|
||||||
bx = col_x + idx * sub_w
|
|
||||||
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
|
|
||||||
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
|
|
||||||
mname = m.get("hostname", "?")
|
|
||||||
p.append(
|
|
||||||
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
|
|
||||||
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" stroke="#333"/>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
|
|
||||||
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
|
|
||||||
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
|
|
||||||
)
|
|
||||||
p.append(
|
|
||||||
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
|
|
||||||
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
|
|
||||||
)
|
|
||||||
|
|
||||||
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
|
|
||||||
draw_shelf(fm)
|
|
||||||
|
|
||||||
p.append("</svg>")
|
p.append("</svg>")
|
||||||
return "\n".join(p) + "\n"
|
return "\n".join(p) + "\n"
|
||||||
|
|
||||||
|
|
@ -577,28 +470,9 @@ def render_page(rack: str, items: list[dict]) -> str:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("| U | Device | Kind | Face | Status |")
|
lines.append("| U | Device | Kind | Face | Status |")
|
||||||
lines.append("|---|---|---|---|---|")
|
lines.append("|---|---|---|---|---|")
|
||||||
by_host = {fm.get("hostname"): fm for fm in items}
|
|
||||||
mounted_by_shelf: dict[str, list[dict]] = {}
|
|
||||||
for fm in items:
|
for fm in items:
|
||||||
if "mounted_on" in fm:
|
|
||||||
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
|
|
||||||
|
|
||||||
def occ_row(fm: dict) -> str:
|
|
||||||
name = fm.get("hostname", "?")
|
name = fm.get("hostname", "?")
|
||||||
link = f"[{name}](../../hardware/{name}.md)"
|
link = f"[{name}](../../hardware/{name}.md)"
|
||||||
if "mounted_on" in fm:
|
|
||||||
target = by_host.get(fm["mounted_on"])
|
|
||||||
if target and isinstance(target.get("rack_u"), int):
|
|
||||||
su = target["rack_u"]
|
|
||||||
sh = target["u_height"]
|
|
||||||
urange = f"U{su}" if sh == 1 else f"U{su}–U{su + sh - 1}"
|
|
||||||
else:
|
|
||||||
urange = "—"
|
|
||||||
face = (
|
|
||||||
f"{fm.get('shelf_face', '')} · "
|
|
||||||
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
face = fm.get("rack_face", "")
|
face = fm.get("rack_face", "")
|
||||||
if face in ZERO_U_FACES:
|
if face in ZERO_U_FACES:
|
||||||
urange = "0U"
|
urange = "0U"
|
||||||
|
|
@ -606,20 +480,10 @@ def render_page(rack: str, items: list[dict]) -> str:
|
||||||
u = fm["rack_u"]
|
u = fm["rack_u"]
|
||||||
h = fm["u_height"]
|
h = fm["u_height"]
|
||||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||||
return (
|
lines.append(
|
||||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
||||||
f"| {fm.get('status', '')} |"
|
f"| {fm.get('status', '')} |"
|
||||||
)
|
)
|
||||||
|
|
||||||
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
|
|
||||||
lines.append(occ_row(fm))
|
|
||||||
if fm.get("kind") == "shelf":
|
|
||||||
occ = sorted(
|
|
||||||
mounted_by_shelf.get(fm.get("hostname"), []),
|
|
||||||
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
|
|
||||||
)
|
|
||||||
for m in occ:
|
|
||||||
lines.append(occ_row(m))
|
|
||||||
lines.append("")
|
lines.append("")
|
||||||
return "\n".join(lines).rstrip() + "\n"
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
@ -645,7 +509,6 @@ 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,13 +9,6 @@ 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"))
|
||||||
|
|
||||||
|
|
@ -517,118 +510,3 @@ 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()
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_svg_draws_shelf_and_occupants():
|
|
||||||
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=2),
|
|
||||||
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
|
|
||||||
]
|
|
||||||
svg = gen_rack.render_svg("rack01", items)
|
|
||||||
assert "shf01" in svg
|
|
||||||
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
|
|
||||||
# the shelf is NOT drawn as a generic full-height device box
|
|
||||||
assert "shf01 (U37" not in svg
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_svg_shelf_is_deterministic():
|
|
||||||
base = [
|
|
||||||
shelf(),
|
|
||||||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
|
||||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
|
||||||
]
|
|
||||||
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
|
|
||||||
"rack01", list(reversed(base))
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def test_render_page_lists_mounted_devices():
|
|
||||||
items = [shelf(),
|
|
||||||
item(hostname="srv01", mounted_on="shf01",
|
|
||||||
shelf_face="front", shelf_slot=1)]
|
|
||||||
page = gen_rack.render_page("rack01", items)
|
|
||||||
assert "../../hardware/srv01.md" in page
|
|
||||||
assert "front · shf01/1" in page
|
|
||||||
assert "U37–U46" in page # mounted device shows its shelf's U-range
|
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue