Compare commits
11 commits
6b06550447
...
74b43ed5af
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74b43ed5af | ||
|
|
b7fb69cf9a | ||
|
|
b0c933011b | ||
|
|
15666e0470 | ||
|
|
039b1212b9 | ||
|
|
2fd0df1597 | ||
|
|
a1b889209a | ||
|
|
3324c01810 | ||
|
|
717de70eca | ||
|
|
f8bcd7ec7f | ||
|
|
c362c93f65 |
19 changed files with 1845 additions and 6 deletions
|
|
@ -26,14 +26,20 @@ jobs:
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: pip install --quiet -r requirements.txt
|
run: pip install --quiet -r requirements.txt
|
||||||
|
|
||||||
|
- name: Install dev dependencies and run tests
|
||||||
|
run: |
|
||||||
|
pip install --quiet -r requirements-dev.txt
|
||||||
|
pytest -q
|
||||||
|
|
||||||
- name: Regenerate hardware and services indices
|
- name: Regenerate hardware and services indices
|
||||||
run: |
|
run: |
|
||||||
python3 scripts/gen_overview.py --category hardware
|
python3 scripts/gen_overview.py --category hardware
|
||||||
python3 scripts/gen_overview.py --category services
|
python3 scripts/gen_overview.py --category services
|
||||||
|
python3 scripts/gen_rack.py
|
||||||
|
|
||||||
- name: Fail on drift in generated indices
|
- name: Fail on drift in generated indices
|
||||||
run: |
|
run: |
|
||||||
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md; then
|
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
|
||||||
echo
|
echo
|
||||||
echo "::error::A generated index is stale."
|
echo "::error::A generated index is stale."
|
||||||
echo "Regenerate locally via 'make docs-index' and commit the result."
|
echo "Regenerate locally via 'make docs-index' and commit the result."
|
||||||
|
|
|
||||||
10
Makefile
10
Makefile
|
|
@ -1,4 +1,4 @@
|
||||||
.PHONY: help docs-index docs-build docs-serve docs-check slides
|
.PHONY: help docs-index docs-build docs-serve docs-check slides test
|
||||||
|
|
||||||
help:
|
help:
|
||||||
@echo "Targets:"
|
@echo "Targets:"
|
||||||
|
|
@ -7,10 +7,12 @@ help:
|
||||||
@echo " docs-serve Run a live-reload local preview server"
|
@echo " docs-serve Run a live-reload local preview server"
|
||||||
@echo " docs-check Drift-check: regenerate indices, fail if they differ from the committed copies"
|
@echo " docs-check Drift-check: regenerate indices, fail if they differ from the committed copies"
|
||||||
@echo " slides Run build-slides.sh (Marp slides)"
|
@echo " slides Run build-slides.sh (Marp slides)"
|
||||||
|
@echo " test Run the Python unit tests (pytest)"
|
||||||
|
|
||||||
docs-index:
|
docs-index:
|
||||||
python3 scripts/gen_overview.py --category hardware
|
python3 scripts/gen_overview.py --category hardware
|
||||||
python3 scripts/gen_overview.py --category services
|
python3 scripts/gen_overview.py --category services
|
||||||
|
python3 scripts/gen_rack.py
|
||||||
|
|
||||||
docs-build:
|
docs-build:
|
||||||
mkdocs build --strict
|
mkdocs build --strict
|
||||||
|
|
@ -21,7 +23,11 @@ docs-serve:
|
||||||
docs-check:
|
docs-check:
|
||||||
python3 scripts/gen_overview.py --category hardware
|
python3 scripts/gen_overview.py --category hardware
|
||||||
python3 scripts/gen_overview.py --category services
|
python3 scripts/gen_overview.py --category services
|
||||||
git diff --exit-code docs/hardware/index.md docs/services/index.md
|
python3 scripts/gen_rack.py
|
||||||
|
git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/
|
||||||
|
|
||||||
slides:
|
slides:
|
||||||
./build-slides.sh
|
./build-slides.sh
|
||||||
|
|
||||||
|
test:
|
||||||
|
pytest -q
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,10 @@ cpu_threads: "?"
|
||||||
ram_gb: "?"
|
ram_gb: "?"
|
||||||
storage: "?"
|
storage: "?"
|
||||||
nic_gbps: "?"
|
nic_gbps: "?"
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 1
|
||||||
|
u_height: 1
|
||||||
|
rack_face: front
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ ram_type: DDR4-2666 non-ECC UDIMM
|
||||||
storage_gb: 40
|
storage_gb: 40
|
||||||
storage_type: nvme
|
storage_type: nvme
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 2
|
||||||
|
u_height: 1
|
||||||
|
rack_face: front
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,10 @@ ram_type: DDR4-2666 non-ECC UDIMM
|
||||||
storage_gb: 40
|
storage_gb: 40
|
||||||
storage_type: nvme
|
storage_type: nvme
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 3
|
||||||
|
u_height: 1
|
||||||
|
rack_face: front
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ ram_gb: 8
|
||||||
storage_gb: 500
|
storage_gb: 500
|
||||||
storage_type: hdd
|
storage_type: hdd
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 5
|
||||||
|
u_height: 2
|
||||||
|
rack_face: front
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ ram_gb: 8
|
||||||
storage_gb: 500
|
storage_gb: 500
|
||||||
storage_type: hdd
|
storage_type: hdd
|
||||||
nic_gbps: 1
|
nic_gbps: 1
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 5
|
||||||
|
u_height: 2
|
||||||
|
rack_face: rear
|
||||||
---
|
---
|
||||||
|
|
||||||
## Notes
|
## Notes
|
||||||
|
|
|
||||||
160
docs/infrastructure/racks/rack01-elevation.svg
Normal file
160
docs/infrastructure/racks/rack01-elevation.svg
Normal file
|
|
@ -0,0 +1,160 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="584" height="1012" viewBox="0 0 584 1012" font-family="sans-serif" font-size="11">
|
||||||
|
<rect width="584" height="1012" fill="#ffffff"/>
|
||||||
|
<text x="12" y="28" font-size="16" font-weight="bold">Rack rack01</text>
|
||||||
|
<text x="162" y="34" text-anchor="middle" font-weight="bold">front</text>
|
||||||
|
<rect x="42" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="42" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<text x="452" y="34" text-anchor="middle" font-weight="bold">rear</text>
|
||||||
|
<rect x="332" y="40" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="60" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="80" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="100" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="120" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="140" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="160" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="180" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="200" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="220" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="240" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="260" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="280" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="300" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="320" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="340" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="360" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="380" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="400" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="420" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="440" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="460" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="480" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="500" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="520" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="540" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="560" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="580" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="600" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="620" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="640" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="660" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="680" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="700" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="720" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="740" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="760" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="780" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="800" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="820" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="840" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="860" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="880" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="900" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="920" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="940" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="960" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<rect x="332" y="980" width="240" height="20" fill="#f5f5f5" stroke="#e0e0e0"/>
|
||||||
|
<text x="38" y="54" text-anchor="end" fill="#999">1</text>
|
||||||
|
<text x="38" y="74" text-anchor="end" fill="#999">2</text>
|
||||||
|
<text x="38" y="94" text-anchor="end" fill="#999">3</text>
|
||||||
|
<text x="38" y="114" text-anchor="end" fill="#999">4</text>
|
||||||
|
<text x="38" y="134" text-anchor="end" fill="#999">5</text>
|
||||||
|
<text x="38" y="154" text-anchor="end" fill="#999">6</text>
|
||||||
|
<text x="38" y="174" text-anchor="end" fill="#999">7</text>
|
||||||
|
<text x="38" y="194" text-anchor="end" fill="#999">8</text>
|
||||||
|
<text x="38" y="214" text-anchor="end" fill="#999">9</text>
|
||||||
|
<text x="38" y="234" text-anchor="end" fill="#999">10</text>
|
||||||
|
<text x="38" y="254" text-anchor="end" fill="#999">11</text>
|
||||||
|
<text x="38" y="274" text-anchor="end" fill="#999">12</text>
|
||||||
|
<text x="38" y="294" text-anchor="end" fill="#999">13</text>
|
||||||
|
<text x="38" y="314" text-anchor="end" fill="#999">14</text>
|
||||||
|
<text x="38" y="334" text-anchor="end" fill="#999">15</text>
|
||||||
|
<text x="38" y="354" text-anchor="end" fill="#999">16</text>
|
||||||
|
<text x="38" y="374" text-anchor="end" fill="#999">17</text>
|
||||||
|
<text x="38" y="394" text-anchor="end" fill="#999">18</text>
|
||||||
|
<text x="38" y="414" text-anchor="end" fill="#999">19</text>
|
||||||
|
<text x="38" y="434" text-anchor="end" fill="#999">20</text>
|
||||||
|
<text x="38" y="454" text-anchor="end" fill="#999">21</text>
|
||||||
|
<text x="38" y="474" text-anchor="end" fill="#999">22</text>
|
||||||
|
<text x="38" y="494" text-anchor="end" fill="#999">23</text>
|
||||||
|
<text x="38" y="514" text-anchor="end" fill="#999">24</text>
|
||||||
|
<text x="38" y="534" text-anchor="end" fill="#999">25</text>
|
||||||
|
<text x="38" y="554" text-anchor="end" fill="#999">26</text>
|
||||||
|
<text x="38" y="574" text-anchor="end" fill="#999">27</text>
|
||||||
|
<text x="38" y="594" text-anchor="end" fill="#999">28</text>
|
||||||
|
<text x="38" y="614" text-anchor="end" fill="#999">29</text>
|
||||||
|
<text x="38" y="634" text-anchor="end" fill="#999">30</text>
|
||||||
|
<text x="38" y="654" text-anchor="end" fill="#999">31</text>
|
||||||
|
<text x="38" y="674" text-anchor="end" fill="#999">32</text>
|
||||||
|
<text x="38" y="694" text-anchor="end" fill="#999">33</text>
|
||||||
|
<text x="38" y="714" text-anchor="end" fill="#999">34</text>
|
||||||
|
<text x="38" y="734" text-anchor="end" fill="#999">35</text>
|
||||||
|
<text x="38" y="754" text-anchor="end" fill="#999">36</text>
|
||||||
|
<text x="38" y="774" text-anchor="end" fill="#999">37</text>
|
||||||
|
<text x="38" y="794" text-anchor="end" fill="#999">38</text>
|
||||||
|
<text x="38" y="814" text-anchor="end" fill="#999">39</text>
|
||||||
|
<text x="38" y="834" text-anchor="end" fill="#999">40</text>
|
||||||
|
<text x="38" y="854" text-anchor="end" fill="#999">41</text>
|
||||||
|
<text x="38" y="874" text-anchor="end" fill="#999">42</text>
|
||||||
|
<text x="38" y="894" text-anchor="end" fill="#999">43</text>
|
||||||
|
<text x="38" y="914" text-anchor="end" fill="#999">44</text>
|
||||||
|
<text x="38" y="934" text-anchor="end" fill="#999">45</text>
|
||||||
|
<text x="38" y="954" text-anchor="end" fill="#999">46</text>
|
||||||
|
<text x="38" y="974" text-anchor="end" fill="#999">47</text>
|
||||||
|
<text x="38" y="994" text-anchor="end" fill="#999">48</text>
|
||||||
|
<rect x="43" y="41" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="162" y="54" text-anchor="middle" fill="#ffffff">mf00 (U1)</text>
|
||||||
|
<rect x="43" y="61" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="162" y="74" text-anchor="middle" fill="#ffffff">mf01 (U2)</text>
|
||||||
|
<rect x="43" y="81" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="162" y="94" text-anchor="middle" fill="#ffffff">mf02 (U3)</text>
|
||||||
|
<rect x="43" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="162" y="144" text-anchor="middle" fill="#ffffff">mf03 (U5–U6)</text>
|
||||||
|
<rect x="333" y="121" width="238" height="38" rx="3" fill="#4c78a8" stroke="#333"/>
|
||||||
|
<text x="452" y="144" text-anchor="middle" fill="#ffffff">mf04 (U5–U6)</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 12 KiB |
17
docs/infrastructure/racks/rack01.md
Normal file
17
docs/infrastructure/racks/rack01.md
Normal file
|
|
@ -0,0 +1,17 @@
|
||||||
|
# Rack rack01
|
||||||
|
|
||||||
|
_Auto-generated from `docs/hardware/*.md` (items with `rack: rack01`) — do not edit by hand. Run `make docs-index` after changing a source file._
|
||||||
|
|
||||||
|
## Elevation
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Occupancy
|
||||||
|
|
||||||
|
| U | Device | Kind | Face | Status |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| U1 | [mf00](../../hardware/mf00.md) | server | front | staging |
|
||||||
|
| U2 | [mf01](../../hardware/mf01.md) | server | front | staging |
|
||||||
|
| U3 | [mf02](../../hardware/mf02.md) | server | front | staging |
|
||||||
|
| U5–U6 | [mf03](../../hardware/mf03.md) | server | front | staging |
|
||||||
|
| U5–U6 | [mf04](../../hardware/mf04.md) | server | rear | staging |
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
---
|
---
|
||||||
name: event management
|
name: coursemanagement
|
||||||
status: proposal
|
kind: web-app
|
||||||
|
status: planned
|
||||||
---
|
---
|
||||||
|
|
||||||
# Problem statement
|
# Problem statement
|
||||||
|
|
|
||||||
|
|
@ -31,4 +31,5 @@ _Auto-generated from `docs/services/*.md` — do not edit by hand. Run `make doc
|
||||||
|
|
||||||
| Name | URL | Host | Tech | Status |
|
| Name | URL | Host | Tech | Status |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
|
| [coursemanagement](coursemanagement.md) | | | | planned |
|
||||||
| [forgejo](forgejo.md) | [forgejo.makerfloss.eu](https://forgejo.makerfloss.eu) | makerfloss.eu | Go | in-use |
|
| [forgejo](forgejo.md) | [forgejo.makerfloss.eu](https://forgejo.makerfloss.eu) | makerfloss.eu | Go | in-use |
|
||||||
|
|
|
||||||
|
|
@ -57,4 +57,8 @@ nav:
|
||||||
- hardware/index.md
|
- hardware/index.md
|
||||||
- Services:
|
- Services:
|
||||||
- services/index.md
|
- services/index.md
|
||||||
|
- Infrastructure:
|
||||||
|
- Lab design: infrastructure/labdesign.md
|
||||||
|
- VPS & DNS: infrastructure/vps-and-dns.md
|
||||||
|
- Rack rack01: infrastructure/racks/rack01.md
|
||||||
- House rules: house-rules.md
|
- House rules: house-rules.md
|
||||||
|
|
|
||||||
925
notes/dev/plans/2026-06-24-rack-elevation.md
Normal file
925
notes/dev/plans/2026-06-24-rack-elevation.md
Normal file
|
|
@ -0,0 +1,925 @@
|
||||||
|
# Rack Elevation (Phase 1) 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:** Document the 48U rack as host frontmatter and generate, via CI, an SVG rack elevation plus an occupancy table — mirroring the existing `gen_overview.py` → generated-index pattern.
|
||||||
|
|
||||||
|
**Architecture:** A new self-contained script `scripts/gen_rack.py` reads `docs/hardware/*.md`, selects files carrying a `rack:` field, validates placement (U range, no overlaps), and writes two generated artifacts per rack into `docs/infrastructure/racks/`: a `<rack>-elevation.svg` picture and a `<rack>.md` page embedding it. CI regenerates and fails on drift, exactly like the existing indices.
|
||||||
|
|
||||||
|
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest (new dev dependency), MkDocs Material, Forgejo Actions CI.
|
||||||
|
|
||||||
|
**Spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 1 only — power and network are later phases).
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_overview.py`'s style). No `Date.now`/randomness in generated output.
|
||||||
|
- Rack has **48 U**; the physical rack is labeled **U1 at the top**, descending to U48 — the SVG must render U1 at the top.
|
||||||
|
- Generated files carry the banner: `_Auto-generated … do not edit by hand. Run `make docs-index` after changing a source file._`
|
||||||
|
- Filenames: ASCII lowercase kebab-case; generated rack files are named after the rack id (e.g. `rack01.md`, `rack01-elevation.svg`).
|
||||||
|
- Language: English for code, docs, commits. Trunk-based; simple commit messages.
|
||||||
|
- `mkdocs build --strict` must pass; the drift guard must cover the new generated artifacts.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Extend the hardware `kind` enum for rack items
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/overview_config.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces: new valid `kind` values (`pdu`, `patch-panel`, `shelf`, `blank`, `ups`, `kvm`) that later tasks' rack item files may use. Phase 1 only uses existing kinds (`server`), but the enum must accept the rest so Phase 2/3 files validate.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Extend the `kind` enum and `group_titles` under the `hardware` block**
|
||||||
|
|
||||||
|
In `scripts/overview_config.yml`, the `hardware` block currently has:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
enums:
|
||||||
|
kind: [server, laptop, sbc, switch, ap, desktop]
|
||||||
|
status: [in-use, staging, spare, broken, donated]
|
||||||
|
storage_type: [nvme, ssd, hdd, mixed]
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace the `kind:` line with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm]
|
||||||
|
```
|
||||||
|
|
||||||
|
And in the same block's `group_titles:` map, add these entries below the existing ones:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
pdu: PDUs
|
||||||
|
patch-panel: Patch panels
|
||||||
|
shelf: Shelves
|
||||||
|
blank: Blank panels
|
||||||
|
ups: UPS
|
||||||
|
kvm: KVM
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Confirm the existing hardware index still regenerates cleanly**
|
||||||
|
|
||||||
|
Run: `python3 scripts/gen_overview.py --category hardware`
|
||||||
|
Expected: `Wrote docs/hardware/index.md (N item(s))` and `git diff --exit-code docs/hardware/index.md` is clean (no new kinds are used yet, so the table is unchanged).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/overview_config.yml
|
||||||
|
git commit -m "feat(hardware): allow rack item kinds (pdu, patch-panel, shelf, blank, ups, kvm)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: `gen_rack.py` core — parse, load, validate placement (TDD)
|
||||||
|
|
||||||
|
This task introduces the test harness and the first slice of the generator: frontmatter parsing, selecting rack items, and per-item placement validation.
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Create: `scripts/gen_rack.py`
|
||||||
|
- Create: `tests/test_gen_rack.py`
|
||||||
|
- Create: `tests/conftest.py`
|
||||||
|
- Create: `requirements-dev.txt`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `SchemaError` (exception)
|
||||||
|
- `RACK_UNITS = 48`, `FACES`, `ZERO_U_FACES` (constants)
|
||||||
|
- `parse_frontmatter(path: Path) -> dict | None`
|
||||||
|
- `load_rack_items(hardware_dir: Path) -> list[dict]` — returns frontmatter dicts (each with an added `_path` key) for files declaring a `rack`
|
||||||
|
- `validate_item(fm: dict) -> None` — raises `SchemaError` on bad placement
|
||||||
|
|
||||||
|
- [ ] **Step 1: Create `requirements-dev.txt`**
|
||||||
|
|
||||||
|
```
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.*
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Install dev dependencies**
|
||||||
|
|
||||||
|
Run: `pip install -r requirements-dev.txt`
|
||||||
|
Expected: pytest installs successfully.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Create `tests/conftest.py` so tests can import the script**
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Make scripts/ importable as top-level modules in tests.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the failing tests for `validate_item`**
|
||||||
|
|
||||||
|
Create `tests/test_gen_rack.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import gen_rack
|
||||||
|
|
||||||
|
|
||||||
|
def item(**kw):
|
||||||
|
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_u_overflow():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_u_below_one():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_bad_face():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_zero_u_with_units():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_zero_u_rail():
|
||||||
|
gen_rack.validate_item(item(rack_face="left"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_missing_units_on_faced_item():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_face="front"))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests to verify they fail**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: FAIL — `ModuleNotFoundError: No module named 'gen_rack'`.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Create `scripts/gen_rack.py` with constants, parsing, loading, and `validate_item`**
|
||||||
|
|
||||||
|
```python
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate per-rack elevation SVG + page from hardware frontmatter.
|
||||||
|
|
||||||
|
Reads `docs/hardware/*.md`, selects files that declare a `rack` field,
|
||||||
|
validates rack placement, and writes for each rack:
|
||||||
|
docs/infrastructure/racks/<rack>-elevation.svg
|
||||||
|
docs/infrastructure/racks/<rack>.md
|
||||||
|
|
||||||
|
Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation.
|
||||||
|
The physical rack is labeled U1 at the top; the SVG renders U1 at the top.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
HARDWARE_DIR = REPO_ROOT / "docs" / "hardware"
|
||||||
|
OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks"
|
||||||
|
RACK_UNITS = 48
|
||||||
|
|
||||||
|
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||||
|
|
||||||
|
FACES = {"front", "rear", "both", "left", "right"}
|
||||||
|
ZERO_U_FACES = {"left", "right"}
|
||||||
|
|
||||||
|
KIND_COLORS = {
|
||||||
|
"server": "#4c78a8",
|
||||||
|
"switch": "#59a14f",
|
||||||
|
"patch-panel": "#9c755f",
|
||||||
|
"pdu": "#e15759",
|
||||||
|
"ups": "#edc948",
|
||||||
|
"shelf": "#bab0ac",
|
||||||
|
"kvm": "#b07aa1",
|
||||||
|
"blank": "#d4d4d4",
|
||||||
|
}
|
||||||
|
DEFAULT_COLOR = "#888888"
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(path: Path) -> dict | None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
m = FRONTMATTER_RE.match(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
data = yaml.safe_load(m.group(1))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SchemaError(f"{path}: frontmatter is not a mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_rack_items(hardware_dir: Path) -> list[dict]:
|
||||||
|
"""Return frontmatter dicts for hardware files that declare a rack."""
|
||||||
|
items: list[dict] = []
|
||||||
|
for path in sorted(hardware_dir.glob("*.md")):
|
||||||
|
if path.name == "index.md":
|
||||||
|
continue
|
||||||
|
fm = parse_frontmatter(path)
|
||||||
|
if fm is None or "rack" not in fm:
|
||||||
|
continue
|
||||||
|
fm = dict(fm)
|
||||||
|
fm["_path"] = str(path)
|
||||||
|
items.append(fm)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
def validate_item(fm: dict) -> None:
|
||||||
|
name = fm.get("hostname") or fm.get("_path", "?")
|
||||||
|
face = fm.get("rack_face")
|
||||||
|
if face not in FACES:
|
||||||
|
raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(FACES)}")
|
||||||
|
if face in ZERO_U_FACES:
|
||||||
|
if "rack_u" in fm or "u_height" in fm:
|
||||||
|
raise SchemaError(
|
||||||
|
f"{name}: 0U item (face={face}) must omit rack_u/u_height"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
u = fm.get("rack_u")
|
||||||
|
h = fm.get("u_height")
|
||||||
|
if not isinstance(u, int) or not isinstance(h, int):
|
||||||
|
raise SchemaError(f"{name}: rack_u and u_height must be integers")
|
||||||
|
if u < 1 or u > RACK_UNITS:
|
||||||
|
raise SchemaError(f"{name}: rack_u={u} out of range 1..{RACK_UNITS}")
|
||||||
|
if h < 1:
|
||||||
|
raise SchemaError(f"{name}: u_height={h} must be >= 1")
|
||||||
|
if u + h - 1 > RACK_UNITS:
|
||||||
|
raise SchemaError(
|
||||||
|
f"{name}: occupies U{u}..U{u + h - 1}, exceeds {RACK_UNITS}U"
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Run the tests to verify they pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: PASS (7 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 8: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/gen_rack.py tests/test_gen_rack.py tests/conftest.py requirements-dev.txt
|
||||||
|
git commit -m "feat(rack): gen_rack placement parsing and validation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: Overlap detection (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/gen_rack.py`
|
||||||
|
- Modify: `tests/test_gen_rack.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `validate_item` semantics (items already individually valid).
|
||||||
|
- Produces: `check_overlaps(items: list[dict]) -> None` — raises `SchemaError` if any two items share a U on the same face. `both` expands to both `front` and `rear`; 0U rail items are exempt.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing overlap tests to `tests/test_gen_rack.py`**
|
||||||
|
|
||||||
|
Append:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_overlaps_detects_same_face_overlap():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
|
||||||
|
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
|
||||||
|
]
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.check_overlaps(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_allows_same_u_different_face():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
|
||||||
|
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
|
||||||
|
]
|
||||||
|
gen_rack.check_overlaps(items) # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_both_face_conflicts_with_front():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
|
||||||
|
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
|
||||||
|
]
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.check_overlaps(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_ignores_zero_u_rails():
|
||||||
|
items = [
|
||||||
|
item(hostname="p1", rack_face="left"),
|
||||||
|
item(hostname="p2", rack_face="left"),
|
||||||
|
]
|
||||||
|
gen_rack.check_overlaps(items) # no raise
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'check_overlaps'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `check_overlaps` in `scripts/gen_rack.py`**
|
||||||
|
|
||||||
|
Add after `validate_item`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
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:
|
||||||
|
face = fm.get("rack_face")
|
||||||
|
if face in ZERO_U_FACES:
|
||||||
|
continue
|
||||||
|
faces = ("front", "rear") if face == "both" else (face,)
|
||||||
|
u = fm["rack_u"]
|
||||||
|
h = fm["u_height"]
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
for f in faces:
|
||||||
|
for uu in range(u, u + h):
|
||||||
|
key = (f, uu)
|
||||||
|
if key in occupied:
|
||||||
|
raise SchemaError(
|
||||||
|
f"U{uu} {f}: {name} overlaps {occupied[key]}"
|
||||||
|
)
|
||||||
|
occupied[key] = name
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: PASS (11 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||||
|
git commit -m "feat(rack): detect U overlaps within a rack face"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: SVG elevation rendering (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/gen_rack.py`
|
||||||
|
- Modify: `tests/test_gen_rack.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: validated items (non-0U items have integer `rack_u`/`u_height`).
|
||||||
|
- Produces: `render_svg(rack: str, items: list[dict]) -> str` — a complete deterministic `<svg>…</svg>` string ending in a newline; front and rear columns of 48 U slots with U1 at the top, device boxes colored by kind, 0U items as side rails.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing SVG tests**
|
||||||
|
|
||||||
|
Append to `tests/test_gen_rack.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_render_svg_has_two_columns_of_48_slots():
|
||||||
|
svg = gen_rack.render_svg("rack01", [])
|
||||||
|
# one faint slot rect per U per column (front + rear)
|
||||||
|
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert svg.rstrip().endswith("</svg>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_includes_device_label():
|
||||||
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||||
|
svg = gen_rack.render_svg("rack01", items)
|
||||||
|
assert "mf00" in svg
|
||||||
|
assert "U1" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_is_deterministic():
|
||||||
|
items = [
|
||||||
|
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
|
||||||
|
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
|
||||||
|
]
|
||||||
|
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
|
||||||
|
"rack01", list(reversed(items))
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify failure**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_svg'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `_esc`, `_sorted_items`, and `render_svg`**
|
||||||
|
|
||||||
|
Add to `scripts/gen_rack.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _esc(s: object) -> str:
|
||||||
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_items(items: list[dict]) -> list[dict]:
|
||||||
|
"""Deterministic order: faced items by U then hostname, 0U items last."""
|
||||||
|
return sorted(
|
||||||
|
items,
|
||||||
|
key=lambda i: (
|
||||||
|
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
|
||||||
|
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
|
||||||
|
i.get("hostname", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(rack: str, items: list[dict]) -> str:
|
||||||
|
U_H = 20
|
||||||
|
COL_W = 240
|
||||||
|
LABEL_W = 30
|
||||||
|
RAIL_W = 16
|
||||||
|
PAD = 12
|
||||||
|
GAP = 50
|
||||||
|
TITLE_H = 28
|
||||||
|
|
||||||
|
items = _sorted_items(items)
|
||||||
|
left_items = [i for i in items if i.get("rack_face") == "left"]
|
||||||
|
right_items = [i for i in items if i.get("rack_face") == "right"]
|
||||||
|
|
||||||
|
body_h = RACK_UNITS * U_H
|
||||||
|
height = PAD + TITLE_H + body_h + PAD
|
||||||
|
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
|
||||||
|
rear_x = front_x + COL_W + GAP
|
||||||
|
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
|
||||||
|
top = PAD + TITLE_H
|
||||||
|
|
||||||
|
def u_y(u: int) -> int:
|
||||||
|
# U1 at the top; U numbers increase downward.
|
||||||
|
return top + (u - 1) * U_H
|
||||||
|
|
||||||
|
p: list[str] = []
|
||||||
|
p.append(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||||
|
f'height="{height}" viewBox="0 0 {width} {height}" '
|
||||||
|
f'font-family="sans-serif" font-size="11">'
|
||||||
|
)
|
||||||
|
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
|
||||||
|
p.append(
|
||||||
|
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
|
||||||
|
f'font-weight="bold">Rack {_esc(rack)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
|
||||||
|
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
|
||||||
|
)
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
|
||||||
|
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# U numbers in the gutter left of the front column.
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
|
||||||
|
f'fill="#999">{u}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw_device(fm: dict, col_x: int) -> None:
|
||||||
|
u = fm["rack_u"]
|
||||||
|
h = fm["u_height"]
|
||||||
|
y = u_y(u)
|
||||||
|
box_h = h * U_H
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
|
||||||
|
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
|
||||||
|
f'text-anchor="middle" fill="#ffffff">'
|
||||||
|
f'{_esc(name)} ({urange})</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def draw_rail(fm: dict, x: int) -> None:
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
cx = x + RAIL_W // 2
|
||||||
|
cy = top + body_h // 2
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
|
||||||
|
f'fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
|
||||||
|
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, fm in enumerate(left_items):
|
||||||
|
draw_rail(fm, PAD + idx * RAIL_W)
|
||||||
|
for idx, fm in enumerate(right_items):
|
||||||
|
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
||||||
|
|
||||||
|
p.append("</svg>")
|
||||||
|
return "\n".join(p) + "\n"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: PASS (14 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||||
|
git commit -m "feat(rack): render SVG elevation (U1 at top, front/rear columns)"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: Page rendering + orchestration (TDD)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `scripts/gen_rack.py`
|
||||||
|
- Modify: `tests/test_gen_rack.py`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `render_svg`, `validate_item`, `check_overlaps`, `load_rack_items`.
|
||||||
|
- Produces:
|
||||||
|
- `render_page(rack: str, items: list[dict]) -> str` — the generated Markdown page (banner, embedded SVG image, occupancy table linking to host pages).
|
||||||
|
- `generate(hardware_dir: Path, output_dir: Path) -> int` — orchestrates load/validate/group/write; returns `0` on success, `1` on any schema error (printing errors to stderr, writing nothing on failure).
|
||||||
|
- `main() -> int` — calls `generate(HARDWARE_DIR, OUTPUT_DIR)`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add failing tests for `render_page` and `generate`**
|
||||||
|
|
||||||
|
Append to `tests/test_gen_rack.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_render_page_has_banner_image_and_table():
|
||||||
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||||
|
page = gen_rack.render_page("rack01", items)
|
||||||
|
assert "do not edit by hand" in page
|
||||||
|
assert "" in page
|
||||||
|
assert "../../hardware/mf00.md" in page
|
||||||
|
assert "U1–U2" in page
|
||||||
|
|
||||||
|
|
||||||
|
def _write_item(d, name, body):
|
||||||
|
(d / f"{name}.md").write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_writes_artifacts(tmp_path):
|
||||||
|
hw = tmp_path / "hardware"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
hw.mkdir()
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
"mf00",
|
||||||
|
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
||||||
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||||
|
)
|
||||||
|
# a non-rack file must be ignored
|
||||||
|
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
|
||||||
|
|
||||||
|
rc = gen_rack.generate(hw, out)
|
||||||
|
|
||||||
|
assert rc == 0
|
||||||
|
assert (out / "rack01.md").exists()
|
||||||
|
assert (out / "rack01-elevation.svg").exists()
|
||||||
|
assert "mf00" in (out / "rack01-elevation.svg").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_returns_1_on_overlap(tmp_path):
|
||||||
|
hw = tmp_path / "hardware"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
hw.mkdir()
|
||||||
|
for n, u in (("a", 1), ("b", 1)):
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
n,
|
||||||
|
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
|
||||||
|
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\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 'render_page'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `render_page`, `generate`, and `main`**
|
||||||
|
|
||||||
|
Add to `scripts/gen_rack.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def render_page(rack: str, items: list[dict]) -> str:
|
||||||
|
items = _sorted_items(items)
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"# Rack {rack}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
|
||||||
|
f"— do not edit by hand. Run `make docs-index` after changing a "
|
||||||
|
f"source file._"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Elevation")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Occupancy")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| U | Device | Kind | Face | Status |")
|
||||||
|
lines.append("|---|---|---|---|---|")
|
||||||
|
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', '')} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
||||||
|
items = load_rack_items(hardware_dir)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
for fm in items:
|
||||||
|
try:
|
||||||
|
validate_item(fm)
|
||||||
|
except SchemaError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
racks: dict[str, list[dict]] = {}
|
||||||
|
for fm in items:
|
||||||
|
racks.setdefault(fm["rack"], []).append(fm)
|
||||||
|
|
||||||
|
if not errors: # only check overlaps once placements are individually valid
|
||||||
|
for rack, ritems in racks.items():
|
||||||
|
try:
|
||||||
|
check_overlaps(ritems)
|
||||||
|
except SchemaError as e:
|
||||||
|
errors.append(f"{rack}: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
print(f"ERROR: {err}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for rack in sorted(racks):
|
||||||
|
ritems = racks[rack]
|
||||||
|
(output_dir / f"{rack}-elevation.svg").write_text(
|
||||||
|
render_svg(rack, ritems), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(output_dir / f"{rack}.md").write_text(
|
||||||
|
render_page(rack, ritems), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
return generate(HARDWARE_DIR, OUTPUT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify pass**
|
||||||
|
|
||||||
|
Run: `pytest tests/test_gen_rack.py -q`
|
||||||
|
Expected: PASS (18 passed).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||||
|
git commit -m "feat(rack): render page and orchestrate generation"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 6: Wire build tooling and populate rack01
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `Makefile`
|
||||||
|
- Modify: `docs/hardware/mf00.md` (and other host files actually in the rack — see note)
|
||||||
|
- Create (generated): `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `gen_rack.main` via `python3 scripts/gen_rack.py`.
|
||||||
|
|
||||||
|
> **Operator note — real data required.** I do not know the true U positions of the devices in the physical rack, and the `mfNN` machines are tower/desktop units that may sit on a shelf rather than occupy U slots. The edit below is a **worked example** for `mf00`. Apply the same shape to **each device actually mounted in the rack**, using its real `rack_u`, `u_height`, and `rack_face` (front/rear/both). Remove rack fields from any host not in the rack. The overlap validator (`check_overlaps`) will reject conflicting positions, so wrong guesses fail loudly rather than silently. `makerfloss.eu` is cloud-hosted and must NOT get a `rack` field.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add rack placement to each in-rack host file (example: `mf00`)**
|
||||||
|
|
||||||
|
In `docs/hardware/mf00.md`, add these four lines to the frontmatter (between the existing keys and the closing `---`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 1
|
||||||
|
u_height: 1
|
||||||
|
rack_face: front
|
||||||
|
```
|
||||||
|
|
||||||
|
Repeat for every other device physically in the rack, choosing real, non-overlapping U positions.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the `gen_rack` step to the Makefile**
|
||||||
|
|
||||||
|
In `Makefile`, change the `docs-index` target to:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
docs-index:
|
||||||
|
python3 scripts/gen_overview.py --category hardware
|
||||||
|
python3 scripts/gen_overview.py --category services
|
||||||
|
python3 scripts/gen_rack.py
|
||||||
|
```
|
||||||
|
|
||||||
|
Change the `docs-check` target to:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
docs-check:
|
||||||
|
python3 scripts/gen_overview.py --category hardware
|
||||||
|
python3 scripts/gen_overview.py --category services
|
||||||
|
python3 scripts/gen_rack.py
|
||||||
|
git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/
|
||||||
|
```
|
||||||
|
|
||||||
|
Add a `test` target at the end of the file:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
test:
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
And add `test` to the `.PHONY` line and a help line:
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
.PHONY: help docs-index docs-build docs-serve docs-check slides test
|
||||||
|
```
|
||||||
|
|
||||||
|
```makefile
|
||||||
|
@echo " test Run the Python unit tests (pytest)"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Generate the rack artifacts**
|
||||||
|
|
||||||
|
Run: `make docs-index`
|
||||||
|
Expected: prints `Wrote rack01.md + rack01-elevation.svg (N item(s))`; the two files now exist under `docs/infrastructure/racks/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Eyeball the SVG**
|
||||||
|
|
||||||
|
Open `docs/infrastructure/racks/rack01-elevation.svg` in a browser. Expected: a "Rack rack01" title, front and rear columns, U numbers running **1 at the top → 48 at the bottom**, and each placed device as a colored box at its U position.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add Makefile docs/hardware/*.md docs/infrastructure/racks/
|
||||||
|
git commit -m "feat(rack): populate rack01 and wire gen_rack into make targets"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 7: CI integration, nav, and end-to-end verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `.forgejo/workflows/docs.yml`
|
||||||
|
- Modify: `mkdocs.yml`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Consumes: `python3 scripts/gen_rack.py`, `pytest`, the generated artifacts under `docs/infrastructure/racks/`.
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add a test step and the rack generator to CI**
|
||||||
|
|
||||||
|
In `.forgejo/workflows/docs.yml`, after the `Install Python dependencies` step, add a new step:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
- name: Install dev dependencies and run tests
|
||||||
|
run: |
|
||||||
|
pip install --quiet -r requirements-dev.txt
|
||||||
|
pytest -q
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `Regenerate hardware and services indices` step, append the rack generator so the `run:` block reads:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
run: |
|
||||||
|
python3 scripts/gen_overview.py --category hardware
|
||||||
|
python3 scripts/gen_overview.py --category services
|
||||||
|
python3 scripts/gen_rack.py
|
||||||
|
```
|
||||||
|
|
||||||
|
In the `Fail on drift in generated indices` step, extend the diff to cover the rack artifacts:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
run: |
|
||||||
|
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
|
||||||
|
echo
|
||||||
|
echo "::error::A generated index is stale."
|
||||||
|
echo "Regenerate locally via 'make docs-index' and commit the result."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add an Infrastructure section to the MkDocs nav**
|
||||||
|
|
||||||
|
In `mkdocs.yml`, replace the `nav:` block with:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
nav:
|
||||||
|
- Home: index.md
|
||||||
|
- Hardware:
|
||||||
|
- hardware/index.md
|
||||||
|
- Services:
|
||||||
|
- services/index.md
|
||||||
|
- Infrastructure:
|
||||||
|
- Lab design: infrastructure/labdesign.md
|
||||||
|
- VPS & DNS: infrastructure/vps-and-dns.md
|
||||||
|
- Rack rack01: infrastructure/racks/rack01.md
|
||||||
|
- House rules: house-rules.md
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Build the site strictly and confirm it passes**
|
||||||
|
|
||||||
|
Run: `mkdocs build --strict`
|
||||||
|
Expected: build succeeds with no warnings-as-errors. The rack page and its SVG appear under `site/infrastructure/racks/`.
|
||||||
|
|
||||||
|
- [ ] **Step 4: Confirm the drift guard is satisfied**
|
||||||
|
|
||||||
|
Run: `make docs-check`
|
||||||
|
Expected: exit 0 (no diff) — the committed artifacts match a fresh regeneration.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Preview the page**
|
||||||
|
|
||||||
|
Run: `mkdocs serve`
|
||||||
|
Open `http://127.0.0.1:8000/infrastructure/racks/rack01/`. Expected: the elevation SVG renders inline, U1 at the top; the occupancy table lists devices and links to their host pages.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add .forgejo/workflows/docs.yml mkdocs.yml
|
||||||
|
git commit -m "ci(rack): generate rack artifacts, run tests, add nav entry"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 7: Push and confirm CI is green**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push origin main
|
||||||
|
```
|
||||||
|
|
||||||
|
Open the Forgejo Actions run for this push. Expected: the tests step passes, the drift guard passes, the site builds, and `docs.makerfloss.eu/infrastructure/racks/rack01/` shows the elevation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Self-Review
|
||||||
|
|
||||||
|
**Spec coverage (Phase 1 scope):**
|
||||||
|
- Placement schema (`rack`, `rack_u`, `u_height`, `rack_face`) — Task 2 (validation), Task 6 (population). ✔
|
||||||
|
- New `kind` values — Task 1. ✔
|
||||||
|
- `gen_rack.py` producing SVG elevation + occupancy table — Tasks 4, 5. ✔
|
||||||
|
- U1-at-top rendering — Task 4 (`u_y`), verified Task 6 Step 4 / Task 7 Step 5. ✔
|
||||||
|
- Validation rules 1, 2, 5 (U range, overlap, 0U-omits-units) — Tasks 2, 3. (Rules 3, 4 are power/network — Phases 2/3, out of scope.) ✔
|
||||||
|
- Do-not-edit banner — Task 5 (`render_page`). ✔
|
||||||
|
- CI drift check + nav + strict build — Task 7. ✔
|
||||||
|
- Generated artifacts under `docs/infrastructure/racks/` — Tasks 5, 6. ✔
|
||||||
|
|
||||||
|
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to" placeholders. The only deferred-to-operator item is real U-position data in Task 6, which is unavoidable physical-world input and is explicitly bounded with a worked example and the overlap validator as a safety net.
|
||||||
|
|
||||||
|
**Type consistency:** `SchemaError`, `RACK_UNITS`, `FACES`, `ZERO_U_FACES`, `parse_frontmatter`, `load_rack_items`, `validate_item`, `check_overlaps`, `_esc`, `_sorted_items`, `render_svg`, `render_page`, `generate`, `main` — names and signatures match across tasks and tests. `generate` returns `int` (0/1); `render_*` return `str`; `validate_item`/`check_overlaps` return `None` and raise `SchemaError`. Consistent.
|
||||||
168
notes/dev/specs/2026-06-24-rack-documentation-design.md
Normal file
168
notes/dev/specs/2026-06-24-rack-documentation-design.md
Normal file
|
|
@ -0,0 +1,168 @@
|
||||||
|
# Rack Documentation Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-24
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Document the 48U server rack as hand-edited Markdown that a CI pass turns into a
|
||||||
|
clear visual presentation, mirroring the existing `docs/hardware/*.md` →
|
||||||
|
`gen_overview.py` → generated-index pattern. The rendered output covers three
|
||||||
|
views: a physical rack **elevation** (SVG), a **power** distribution graph
|
||||||
|
(mermaid), and a **network cabling** graph (mermaid).
|
||||||
|
|
||||||
|
Authors (operator or AI) edit frontmatter; a push regenerates the artifacts and
|
||||||
|
CI fails on drift, so the published page at `docs.makerfloss.eu` is always in
|
||||||
|
sync with the source.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
- Per-item frontmatter is already the norm: `docs/hardware/*.md` and
|
||||||
|
`docs/services/*.md` carry YAML frontmatter validated by
|
||||||
|
`scripts/gen_overview.py` against schemas in `scripts/overview_config.yml`.
|
||||||
|
- The generator writes a grouped/sorted `index.md`; CI (`.forgejo/workflows/docs.yml`)
|
||||||
|
regenerates it and runs `git diff --exit-code` to **fail on drift**.
|
||||||
|
- Mermaid already renders in the Marp slide pipeline; it is **not yet enabled in
|
||||||
|
MkDocs**. Enabling it is a small `mkdocs.yml` change (superfences custom fence;
|
||||||
|
Material ships mermaid.js).
|
||||||
|
- The rack contains devices that already have host pages (`mf01`..`mf04`).
|
||||||
|
- **The physical rack is labeled U1 at the top**, descending to U48 at the
|
||||||
|
bottom (non-standard; standard racks number U1 at the bottom). The elevation
|
||||||
|
must match the physical labels.
|
||||||
|
|
||||||
|
## Data model
|
||||||
|
|
||||||
|
Rack data is added to **host frontmatter** (decision: extend existing files
|
||||||
|
rather than introduce a separate layout file). Rack-mounted items that are not
|
||||||
|
hosts (PDUs, patch panels, shelves, blank panels, UPS, KVM) each get their own
|
||||||
|
lightweight file in `docs/hardware/` using new `kind` values.
|
||||||
|
|
||||||
|
### Frontmatter fields
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# placement (Phase 1)
|
||||||
|
rack: rack01 # rack identifier; one rack today, field enables future racks
|
||||||
|
rack_u: 12 # lowest U occupied (1–48)
|
||||||
|
u_height: 2 # number of U occupied
|
||||||
|
rack_face: front # front | rear | both | left | right (0U PDUs use left/right rails)
|
||||||
|
|
||||||
|
# power (Phase 2) — on each powered device
|
||||||
|
power:
|
||||||
|
- { pdu: pdu01, outlet: 3 }
|
||||||
|
- { pdu: pdu02, outlet: 3 } # a second entry expresses a redundant PSU feed
|
||||||
|
|
||||||
|
# network (Phase 3) — on each device, one entry per cable end originating here
|
||||||
|
links:
|
||||||
|
- { local: eth0, peer: sw01, peer_port: 12, speed_gbps: 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
### New `kind` enum values
|
||||||
|
|
||||||
|
Extend the `hardware` enum in `overview_config.yml` with: `pdu`, `patch-panel`,
|
||||||
|
`shelf`, `blank`, `ups`, `kvm` (joining the existing `server`, `laptop`, `sbc`,
|
||||||
|
`switch`, `ap`, `desktop`).
|
||||||
|
|
||||||
|
Non-host item files declare their own capacity where relevant:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docs/hardware/pdu01.md
|
||||||
|
hostname: pdu01
|
||||||
|
kind: pdu
|
||||||
|
status: in-use
|
||||||
|
rack: rack01
|
||||||
|
rack_face: left
|
||||||
|
outlets: 8
|
||||||
|
```
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# docs/hardware/pp01.md
|
||||||
|
hostname: pp01
|
||||||
|
kind: patch-panel
|
||||||
|
status: in-use
|
||||||
|
rack: rack01
|
||||||
|
rack_u: 24
|
||||||
|
u_height: 1
|
||||||
|
rack_face: front
|
||||||
|
ports: 24
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generator: `scripts/gen_rack.py`
|
||||||
|
|
||||||
|
A sibling to `gen_overview.py`, sharing its style (stdlib + PyYAML, deterministic,
|
||||||
|
offline, `SchemaError` → non-zero exit). It reads every `docs/hardware/*.md` with
|
||||||
|
a `rack:` field, validates the rack schema, groups by `rack`, and writes
|
||||||
|
generated artifacts per rack.
|
||||||
|
|
||||||
|
### Outputs (do-not-edit, generated)
|
||||||
|
|
||||||
|
- `docs/infrastructure/racks/rack01-elevation.svg` — the elevation picture.
|
||||||
|
- `docs/infrastructure/racks/rack01.md` — generated page embedding, in order:
|
||||||
|
1. the elevation SVG (``),
|
||||||
|
2. a mermaid **power** graph (Phase 2),
|
||||||
|
3. a mermaid **network** graph (Phase 3),
|
||||||
|
4. an occupancy table (U-range, hostname/link, kind, face, status).
|
||||||
|
|
||||||
|
Each generated file carries the same "Auto-generated … do not edit by hand"
|
||||||
|
banner the existing indices use.
|
||||||
|
|
||||||
|
### Rendering
|
||||||
|
|
||||||
|
- **Elevation (SVG):** two side-by-side columns, **front** and **rear**. 48 U
|
||||||
|
rows numbered **U1 at the top → U48 at the bottom** to match the physical
|
||||||
|
rack. Each device is a rectangle spanning its true `u_height`, filled by a
|
||||||
|
per-`kind` color, labeled with hostname and U-range. Empty U slots drawn
|
||||||
|
faintly. 0U side-rail items (`rack_face: left|right`) drawn as thin vertical
|
||||||
|
bars beside the columns. Plain hand-written SVG strings — no external drawing
|
||||||
|
library.
|
||||||
|
- **Power (mermaid):** `flowchart` of `pdu → (outlet) → device`; redundant feeds
|
||||||
|
appear as multiple edges into one device.
|
||||||
|
- **Network (mermaid):** `flowchart` of `device[:local] -- speed --> peer[:port]`
|
||||||
|
edges built from `links`.
|
||||||
|
|
||||||
|
### Validation rules (CI-enforced, fail with a clear message)
|
||||||
|
|
||||||
|
1. `rack_u` in 1–48; `rack_u + u_height − 1 ≤ 48`.
|
||||||
|
2. No two items overlap the same U range on the same `rack_face` within a rack.
|
||||||
|
3. Every `power[].pdu` resolves to a file whose `kind: pdu`; `outlet` within that
|
||||||
|
PDU's `outlets`.
|
||||||
|
4. Every `links[].peer` resolves to a real file; `peer_port` within the peer's
|
||||||
|
declared `ports`/port count where the peer declares one.
|
||||||
|
5. Items with `rack_face: left|right` (0U) must omit `rack_u`/`u_height`;
|
||||||
|
all other rack items must include them.
|
||||||
|
|
||||||
|
## Integration
|
||||||
|
|
||||||
|
| File | Change |
|
||||||
|
|------|--------|
|
||||||
|
| `scripts/gen_rack.py` | New generator (SVG + mermaid + table + validation) |
|
||||||
|
| `scripts/overview_config.yml` | Extend `hardware` `kind` enum with new values; optional `rack` config block if reusing config-driven validation |
|
||||||
|
| `docs/hardware/*.md` | Add placement (then power, then links) fields to rack occupants; add new non-host item files |
|
||||||
|
| `docs/infrastructure/racks/` | New dir holding generated `rack01.md` + `rack01-elevation.svg` |
|
||||||
|
| `Makefile` | `docs-index`/`docs-check` gain a `gen_rack.py` step |
|
||||||
|
| `.forgejo/workflows/docs.yml` | Run `gen_rack.py`; extend the drift `git diff` guard to the generated rack artifacts |
|
||||||
|
| `mkdocs.yml` | Enable mermaid (superfences custom fence) [Phase 3]; add the rack page to `nav` |
|
||||||
|
|
||||||
|
## Phasing
|
||||||
|
|
||||||
|
Each phase is independently shippable as its own PR.
|
||||||
|
|
||||||
|
1. **Phase 1 — Elevation.** Placement schema + new kinds + `gen_rack.py`
|
||||||
|
producing the SVG elevation and occupancy table + CI drift check + nav entry.
|
||||||
|
Populate `rack01` with the current devices.
|
||||||
|
2. **Phase 2 — Power.** `power` fields + PDU files + generated mermaid power
|
||||||
|
graph + validation rules 3.
|
||||||
|
3. **Phase 3 — Network.** `links` fields + patch-panel files + generated mermaid
|
||||||
|
network graph + validation rule 4 + enable mermaid in `mkdocs.yml`.
|
||||||
|
|
||||||
|
## Test plan
|
||||||
|
|
||||||
|
- **Unit (per phase):** run `python3 scripts/gen_rack.py`; assert it writes the
|
||||||
|
expected artifacts and exits 0 on a valid fixture set.
|
||||||
|
- **Validation:** craft fixtures that violate each rule (U overflow, overlap,
|
||||||
|
dangling `pdu`/`peer`, bad outlet/port, 0U with U fields) and assert non-zero
|
||||||
|
exit with the right message.
|
||||||
|
- **Drift:** run the generator, confirm `git diff --exit-code` is clean; mutate a
|
||||||
|
source file without regenerating and confirm CI's guard fails.
|
||||||
|
- **Visual:** `make docs-build` (or `docs-serve`), open the rack page, confirm
|
||||||
|
the SVG shows U1 at the top, devices at correct positions/faces, and (Phase
|
||||||
|
2/3) the mermaid graphs render rather than appearing as code blocks.
|
||||||
2
requirements-dev.txt
Normal file
2
requirements-dev.txt
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest==8.*
|
||||||
324
scripts/gen_rack.py
Normal file
324
scripts/gen_rack.py
Normal file
|
|
@ -0,0 +1,324 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate per-rack elevation SVG + page from hardware frontmatter.
|
||||||
|
|
||||||
|
Reads `docs/hardware/*.md`, selects files that declare a `rack` field,
|
||||||
|
validates rack placement, and writes for each rack:
|
||||||
|
docs/infrastructure/racks/<rack>-elevation.svg
|
||||||
|
docs/infrastructure/racks/<rack>.md
|
||||||
|
|
||||||
|
Deterministic, offline, stdlib + PyYAML. Non-zero exit on schema violation.
|
||||||
|
The physical rack is labeled U1 at the top; the SVG renders U1 at the top.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import yaml
|
||||||
|
|
||||||
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
HARDWARE_DIR = REPO_ROOT / "docs" / "hardware"
|
||||||
|
OUTPUT_DIR = REPO_ROOT / "docs" / "infrastructure" / "racks"
|
||||||
|
RACK_UNITS = 48
|
||||||
|
|
||||||
|
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
|
||||||
|
|
||||||
|
FACES = {"front", "rear", "both", "left", "right"}
|
||||||
|
ZERO_U_FACES = {"left", "right"}
|
||||||
|
|
||||||
|
KIND_COLORS = {
|
||||||
|
"server": "#4c78a8",
|
||||||
|
"switch": "#59a14f",
|
||||||
|
"patch-panel": "#9c755f",
|
||||||
|
"pdu": "#e15759",
|
||||||
|
"ups": "#edc948",
|
||||||
|
"shelf": "#bab0ac",
|
||||||
|
"kvm": "#b07aa1",
|
||||||
|
"blank": "#d4d4d4",
|
||||||
|
}
|
||||||
|
DEFAULT_COLOR = "#888888"
|
||||||
|
|
||||||
|
|
||||||
|
class SchemaError(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def parse_frontmatter(path: Path) -> dict | None:
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
m = FRONTMATTER_RE.match(text)
|
||||||
|
if not m:
|
||||||
|
return None
|
||||||
|
data = yaml.safe_load(m.group(1))
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
raise SchemaError(f"{path}: frontmatter is not a mapping")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
def load_rack_items(hardware_dir: Path) -> list[dict]:
|
||||||
|
"""Return frontmatter dicts for hardware files that declare a rack."""
|
||||||
|
items: list[dict] = []
|
||||||
|
for path in sorted(hardware_dir.glob("*.md")):
|
||||||
|
if path.name == "index.md":
|
||||||
|
continue
|
||||||
|
fm = parse_frontmatter(path)
|
||||||
|
if fm is None or "rack" not in fm:
|
||||||
|
continue
|
||||||
|
fm = dict(fm)
|
||||||
|
fm["_path"] = str(path)
|
||||||
|
items.append(fm)
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
if face not in FACES:
|
||||||
|
raise SchemaError(f"{name}: rack_face={face!r} not in {sorted(FACES)}")
|
||||||
|
if face in ZERO_U_FACES:
|
||||||
|
if "rack_u" in fm or "u_height" in fm:
|
||||||
|
raise SchemaError(
|
||||||
|
f"{name}: 0U item (face={face}) must omit rack_u/u_height"
|
||||||
|
)
|
||||||
|
return
|
||||||
|
u = fm.get("rack_u")
|
||||||
|
h = fm.get("u_height")
|
||||||
|
if not isinstance(u, int) or not isinstance(h, int):
|
||||||
|
raise SchemaError(f"{name}: rack_u and u_height must be integers")
|
||||||
|
if u < 1 or u > RACK_UNITS:
|
||||||
|
raise SchemaError(f"{name}: rack_u={u} out of range 1..{RACK_UNITS}")
|
||||||
|
if h < 1:
|
||||||
|
raise SchemaError(f"{name}: u_height={h} must be >= 1")
|
||||||
|
if u + h - 1 > RACK_UNITS:
|
||||||
|
raise SchemaError(
|
||||||
|
f"{name}: occupies U{u}..U{u + h - 1}, exceeds {RACK_UNITS}U"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
||||||
|
face = fm.get("rack_face")
|
||||||
|
if face in ZERO_U_FACES:
|
||||||
|
continue
|
||||||
|
faces = ("front", "rear") if face == "both" else (face,)
|
||||||
|
u = fm["rack_u"]
|
||||||
|
h = fm["u_height"]
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
for f in faces:
|
||||||
|
for uu in range(u, u + h):
|
||||||
|
key = (f, uu)
|
||||||
|
if key in occupied:
|
||||||
|
raise SchemaError(
|
||||||
|
f"U{uu} {f}: {name} overlaps {occupied[key]}"
|
||||||
|
)
|
||||||
|
occupied[key] = name
|
||||||
|
|
||||||
|
|
||||||
|
def _esc(s: object) -> str:
|
||||||
|
return str(s).replace("&", "&").replace("<", "<").replace(">", ">")
|
||||||
|
|
||||||
|
|
||||||
|
def _sorted_items(items: list[dict]) -> list[dict]:
|
||||||
|
"""Deterministic order: faced items by U then hostname, 0U items last."""
|
||||||
|
return sorted(
|
||||||
|
items,
|
||||||
|
key=lambda i: (
|
||||||
|
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
|
||||||
|
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
|
||||||
|
i.get("hostname", ""),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def render_svg(rack: str, items: list[dict]) -> str:
|
||||||
|
U_H = 20
|
||||||
|
COL_W = 240
|
||||||
|
LABEL_W = 30
|
||||||
|
RAIL_W = 16
|
||||||
|
PAD = 12
|
||||||
|
GAP = 50
|
||||||
|
TITLE_H = 28
|
||||||
|
|
||||||
|
items = _sorted_items(items)
|
||||||
|
left_items = [i for i in items if i.get("rack_face") == "left"]
|
||||||
|
right_items = [i for i in items if i.get("rack_face") == "right"]
|
||||||
|
|
||||||
|
body_h = RACK_UNITS * U_H
|
||||||
|
height = PAD + TITLE_H + body_h + PAD
|
||||||
|
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
|
||||||
|
rear_x = front_x + COL_W + GAP
|
||||||
|
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
|
||||||
|
top = PAD + TITLE_H
|
||||||
|
|
||||||
|
def u_y(u: int) -> int:
|
||||||
|
# U1 at the top; U numbers increase downward.
|
||||||
|
return top + (u - 1) * U_H
|
||||||
|
|
||||||
|
p: list[str] = []
|
||||||
|
p.append(
|
||||||
|
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||||
|
f'height="{height}" viewBox="0 0 {width} {height}" '
|
||||||
|
f'font-family="sans-serif" font-size="11">'
|
||||||
|
)
|
||||||
|
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
|
||||||
|
p.append(
|
||||||
|
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
|
||||||
|
f'font-weight="bold">Rack {_esc(rack)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
|
||||||
|
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
|
||||||
|
)
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
|
||||||
|
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
|
||||||
|
)
|
||||||
|
|
||||||
|
# U numbers in the gutter left of the front column.
|
||||||
|
for u in range(1, RACK_UNITS + 1):
|
||||||
|
y = u_y(u)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
|
||||||
|
f'fill="#999">{u}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
def draw_device(fm: dict, col_x: int) -> None:
|
||||||
|
u = fm["rack_u"]
|
||||||
|
h = fm["u_height"]
|
||||||
|
y = u_y(u)
|
||||||
|
box_h = h * U_H
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
|
||||||
|
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
|
||||||
|
f'text-anchor="middle" fill="#ffffff">'
|
||||||
|
f'{_esc(name)} ({urange})</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
def draw_rail(fm: dict, x: int) -> None:
|
||||||
|
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||||
|
name = fm.get("hostname", "?")
|
||||||
|
cx = x + RAIL_W // 2
|
||||||
|
cy = top + body_h // 2
|
||||||
|
p.append(
|
||||||
|
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
|
||||||
|
f'fill="{color}" stroke="#333"/>'
|
||||||
|
)
|
||||||
|
p.append(
|
||||||
|
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
|
||||||
|
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
|
||||||
|
)
|
||||||
|
|
||||||
|
for idx, fm in enumerate(left_items):
|
||||||
|
draw_rail(fm, PAD + idx * RAIL_W)
|
||||||
|
for idx, fm in enumerate(right_items):
|
||||||
|
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
||||||
|
|
||||||
|
p.append("</svg>")
|
||||||
|
return "\n".join(p) + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def render_page(rack: str, items: list[dict]) -> str:
|
||||||
|
items = _sorted_items(items)
|
||||||
|
lines: list[str] = []
|
||||||
|
lines.append(f"# Rack {rack}")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(
|
||||||
|
f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
|
||||||
|
f"— do not edit by hand. Run `make docs-index` after changing a "
|
||||||
|
f"source file._"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Elevation")
|
||||||
|
lines.append("")
|
||||||
|
lines.append(f"")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("## Occupancy")
|
||||||
|
lines.append("")
|
||||||
|
lines.append("| U | Device | Kind | Face | Status |")
|
||||||
|
lines.append("|---|---|---|---|---|")
|
||||||
|
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', '')} |"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
||||||
|
items = load_rack_items(hardware_dir)
|
||||||
|
|
||||||
|
errors: list[str] = []
|
||||||
|
for fm in items:
|
||||||
|
try:
|
||||||
|
validate_item(fm)
|
||||||
|
except SchemaError as e:
|
||||||
|
errors.append(str(e))
|
||||||
|
|
||||||
|
racks: dict[str, list[dict]] = {}
|
||||||
|
for fm in items:
|
||||||
|
racks.setdefault(fm["rack"], []).append(fm)
|
||||||
|
|
||||||
|
if not errors: # only check overlaps once placements are individually valid
|
||||||
|
for rack, ritems in racks.items():
|
||||||
|
try:
|
||||||
|
check_overlaps(ritems)
|
||||||
|
except SchemaError as e:
|
||||||
|
errors.append(f"{rack}: {e}")
|
||||||
|
|
||||||
|
if errors:
|
||||||
|
for err in errors:
|
||||||
|
print(f"ERROR: {err}", file=sys.stderr)
|
||||||
|
return 1
|
||||||
|
|
||||||
|
output_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
for rack in sorted(racks):
|
||||||
|
ritems = racks[rack]
|
||||||
|
(output_dir / f"{rack}-elevation.svg").write_text(
|
||||||
|
render_svg(rack, ritems), encoding="utf-8"
|
||||||
|
)
|
||||||
|
(output_dir / f"{rack}.md").write_text(
|
||||||
|
render_page(rack, ritems), encoding="utf-8"
|
||||||
|
)
|
||||||
|
print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> int:
|
||||||
|
return generate(HARDWARE_DIR, OUTPUT_DIR)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
|
|
@ -14,7 +14,7 @@ hardware:
|
||||||
- kind
|
- kind
|
||||||
- status
|
- status
|
||||||
enums:
|
enums:
|
||||||
kind: [server, laptop, sbc, switch, ap, desktop]
|
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm]
|
||||||
status: [in-use, staging, spare, broken, donated]
|
status: [in-use, staging, spare, broken, donated]
|
||||||
storage_type: [nvme, ssd, hdd, mixed]
|
storage_type: [nvme, ssd, hdd, mixed]
|
||||||
group_by: kind
|
group_by: kind
|
||||||
|
|
@ -27,6 +27,12 @@ hardware:
|
||||||
switch: Switches
|
switch: Switches
|
||||||
ap: Access points
|
ap: Access points
|
||||||
desktop: Desktops
|
desktop: Desktops
|
||||||
|
pdu: PDUs
|
||||||
|
patch-panel: Patch panels
|
||||||
|
shelf: Shelves
|
||||||
|
blank: Blank panels
|
||||||
|
ups: UPS
|
||||||
|
kvm: KVM
|
||||||
sort_by: hostname
|
sort_by: hostname
|
||||||
columns:
|
columns:
|
||||||
- { header: Hostname, kind: key-link, field: hostname }
|
- { header: Hostname, kind: key-link, field: hostname }
|
||||||
|
|
|
||||||
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
|
|
@ -0,0 +1,5 @@
|
||||||
|
import sys
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# Make scripts/ importable as top-level modules in tests.
|
||||||
|
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||||
194
tests/test_gen_rack.py
Normal file
194
tests/test_gen_rack.py
Normal file
|
|
@ -0,0 +1,194 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
import gen_rack
|
||||||
|
|
||||||
|
|
||||||
|
def item(**kw):
|
||||||
|
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
|
||||||
|
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"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_u_overflow():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_u_below_one():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_bad_face():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_zero_u_with_units():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_accepts_zero_u_rail():
|
||||||
|
gen_rack.validate_item(item(rack_face="left"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_missing_units_on_faced_item():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_validate_rejects_empty_rack():
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.validate_item(item(rack=None, rack_u=1, u_height=1, rack_face="front"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_detects_same_face_overlap():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
|
||||||
|
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
|
||||||
|
]
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.check_overlaps(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_allows_same_u_different_face():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
|
||||||
|
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
|
||||||
|
]
|
||||||
|
gen_rack.check_overlaps(items) # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_both_face_conflicts_with_front():
|
||||||
|
items = [
|
||||||
|
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
|
||||||
|
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
|
||||||
|
]
|
||||||
|
with pytest.raises(gen_rack.SchemaError):
|
||||||
|
gen_rack.check_overlaps(items)
|
||||||
|
|
||||||
|
|
||||||
|
def test_overlaps_ignores_zero_u_rails():
|
||||||
|
items = [
|
||||||
|
item(hostname="p1", rack_face="left"),
|
||||||
|
item(hostname="p2", rack_face="left"),
|
||||||
|
]
|
||||||
|
gen_rack.check_overlaps(items) # no raise
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_has_two_columns_of_48_slots():
|
||||||
|
svg = gen_rack.render_svg("rack01", [])
|
||||||
|
# one faint slot rect per U per column (front + rear)
|
||||||
|
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
|
||||||
|
assert svg.startswith("<svg")
|
||||||
|
assert svg.rstrip().endswith("</svg>")
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_includes_device_label():
|
||||||
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||||
|
svg = gen_rack.render_svg("rack01", items)
|
||||||
|
assert "mf00" in svg
|
||||||
|
assert "U1" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_is_deterministic():
|
||||||
|
items = [
|
||||||
|
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
|
||||||
|
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
|
||||||
|
]
|
||||||
|
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
|
||||||
|
"rack01", list(reversed(items))
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_page_has_banner_image_and_table():
|
||||||
|
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||||
|
page = gen_rack.render_page("rack01", items)
|
||||||
|
assert "do not edit by hand" in page
|
||||||
|
assert "" in page
|
||||||
|
assert "../../hardware/mf00.md" in page
|
||||||
|
assert "U1–U2" in page
|
||||||
|
|
||||||
|
|
||||||
|
def _write_item(d, name, body):
|
||||||
|
(d / f"{name}.md").write_text(body, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_writes_artifacts(tmp_path):
|
||||||
|
hw = tmp_path / "hardware"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
hw.mkdir()
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
"mf00",
|
||||||
|
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
||||||
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||||
|
)
|
||||||
|
# a non-rack file must be ignored
|
||||||
|
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
|
||||||
|
|
||||||
|
rc = gen_rack.generate(hw, out)
|
||||||
|
|
||||||
|
assert rc == 0
|
||||||
|
assert (out / "rack01.md").exists()
|
||||||
|
assert (out / "rack01-elevation.svg").exists()
|
||||||
|
assert "mf00" in (out / "rack01-elevation.svg").read_text()
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_draws_zero_u_rail():
|
||||||
|
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
|
||||||
|
svg = gen_rack.render_svg("rack01", items)
|
||||||
|
assert "pdu01" in svg
|
||||||
|
assert "rotate(-90" in svg
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_svg_both_face_draws_in_both_columns():
|
||||||
|
items = [item(hostname="dev", rack_u=10, u_height=1, rack_face="both")]
|
||||||
|
svg = gen_rack.render_svg("rack01", items)
|
||||||
|
assert svg.count("dev (U10)") == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_writes_one_pair_per_rack(tmp_path):
|
||||||
|
hw = tmp_path / "hardware"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
hw.mkdir()
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
"a",
|
||||||
|
"---\nhostname: a\nkind: server\nstatus: in-use\n"
|
||||||
|
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||||
|
)
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
"b",
|
||||||
|
"---\nhostname: b\nkind: server\nstatus: in-use\n"
|
||||||
|
"rack: rack02\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||||
|
)
|
||||||
|
rc = gen_rack.generate(hw, out)
|
||||||
|
assert rc == 0
|
||||||
|
assert (out / "rack01.md").exists() and (out / "rack02.md").exists()
|
||||||
|
assert (out / "rack01-elevation.svg").exists()
|
||||||
|
assert (out / "rack02-elevation.svg").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_generate_returns_1_on_overlap(tmp_path):
|
||||||
|
hw = tmp_path / "hardware"
|
||||||
|
out = tmp_path / "out"
|
||||||
|
hw.mkdir()
|
||||||
|
for n, u in (("a", 1), ("b", 1)):
|
||||||
|
_write_item(
|
||||||
|
hw,
|
||||||
|
n,
|
||||||
|
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
|
||||||
|
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\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