Compare commits

..

No commits in common. "main" and "feat/hardware-overview" have entirely different histories.

106 changed files with 551 additions and 10698 deletions

View file

@ -15,10 +15,10 @@ jobs:
volumes: volumes:
- /srv/docs-makerfloss/html:/output - /srv/docs-makerfloss/html:/output
steps: steps:
- name: Install git, rsync, nodejs for actions/checkout - name: Install git for actions/checkout
run: | run: |
apt-get update -qq apt-get update -qq
apt-get install -y --no-install-recommends git rsync nodejs apt-get install -y --no-install-recommends git rsync
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@v4 uses: actions/checkout@v4
@ -26,22 +26,14 @@ 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 - name: Regenerate hardware index
run: | run: python3 scripts/gen_overview.py --category hardware
pip install --quiet -r requirements-dev.txt
pytest -q
- name: Regenerate hardware and services indices - name: Fail on drift in docs/hardware/index.md
run: | run: |
python3 scripts/gen_overview.py --category hardware if ! git diff --exit-code docs/hardware/index.md; then
python3 scripts/gen_overview.py --category services
python3 scripts/gen_rack.py
- name: Fail on drift in generated indices
run: |
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::docs/hardware/index.md is stale."
echo "Regenerate locally via 'make docs-index' and commit the result." echo "Regenerate locally via 'make docs-index' and commit the result."
exit 1 exit 1
fi fi
@ -52,16 +44,3 @@ jobs:
- name: Publish to /output (main only) - name: Publish to /output (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push' if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: rsync -a --delete site/ /output/ run: rsync -a --delete site/ /output/
- name: Notify ntfy on failure
if: failure() && github.ref == 'refs/heads/main' && github.event_name == 'push'
run: |
curl -sf \
-H "Title: docs.makerfloss.eu build failed" \
-H "Priority: high" \
-H "Tags: warning,rotating_light" \
-d "Run #${{ github.run_number }} on ${{ github.ref_name }}@${{ github.sha }}
${{ github.event.head_commit.message }}
${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}" \
"https://ntfy.sh/${{ secrets.NTFY_TOPIC }}" || true

5
.gitignore vendored
View file

@ -2,9 +2,8 @@
*.retry *.retry
# ---> Generated slides (built by marp on server or locally via build-slides.sh) # ---> Generated slides (built by marp on server or locally via build-slides.sh)
# Deck *sources* also live under slides/, so ignore only the build output. slides/*.html
slides/**/*.html slides/*.pdf
slides/**/*.pdf
# ---> MkDocs build output # ---> MkDocs build output
site/ site/

View file

@ -8,7 +8,7 @@ This is a **documentation-only repository** for the MakerFLOSS initiative at Ora
## Working Norms ## Working Norms
From `notes/todo/2026-04-14-todo.md`: From `docs/todo/2026-04-14 TODO.md`:
- **Language**: English for code, docs, commits (meeting notes may be in Danish) - **Language**: English for code, docs, commits (meeting notes may be in Danish)
- **Git**: Trunk-based development, feature branches, simple commit messages - **Git**: Trunk-based development, feature branches, simple commit messages
@ -17,25 +17,15 @@ From `notes/todo/2026-04-14-todo.md`:
- **AI**: Allowed but reviewed; no secrets in commits - **AI**: Allowed but reviewed; no secrets in commits
- **Decisions**: Lightweight markdown decision logs - **Decisions**: Lightweight markdown decision logs
- **License**: FLOSS by default - **License**: FLOSS by default
- **Filenames**: ASCII lowercase, kebab-case (`like-this.md`), English regardless of content language. Dated docs use an ISO prefix joined with a hyphen: `YYYY-MM-DD-label.md`. Hostnames are the exception — `docs/hardware/*.md` must equal the host's `hostname` (enforced by `scripts/gen_overview.py`).
- **Hardware identifiers**: `<kind-abbrev><NN>` — 2-digit, unique per kind (`srv`, `sw`, `pp`, `pdu`, `ups`, `shf`). Grouping (`cluster:`) lives in frontmatter, not the name. Full scheme: `docs/guides/hardware-naming-scheme.md`.
## Repository Structure ## Repository Structure
``` ```
docs/ # everything here is built and shipped to docs.makerfloss.eu docs/
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu) infrastruktur/ # Infrastructure documentation (VPS, DNS, etc.)
services/ # auto-indexed per-service frontmatter (docs, forgejo, …) kommunikation/ # Communication materials (Facebook posts, announcements)
infrastructure/ # labdesign, VPS/DNS, etc. møder/ # Meeting notes
presentations/ # Marp decks, flat URLs (build-slides.sh) todo/ # Task lists and working norms
notes/ # repo-only working material, not built
meetings/ # meeting notes (Danish allowed)
todo/ # task lists, working norms, wishlist, services
dev/ # internal plans/ and specs/
communications/ # community comms artifacts (Facebook posts, etc.)
sandbox/ # scratch / pipeline fixtures (e.g. test-mermaid.md)
slides/ # build output — AND Marp deck sources whose URL is a path,
# e.g. slides/tappaas/how-to/new-module/index.md
``` ```
## Infrastructure ## Infrastructure

View file

@ -1,18 +1,15 @@
.PHONY: help docs-index docs-build docs-serve docs-check slides test .PHONY: help docs-index docs-build docs-serve docs-check slides
help: help:
@echo "Targets:" @echo "Targets:"
@echo " docs-index Regenerate docs/{hardware,services}/index.md from per-item frontmatter" @echo " docs-index Regenerate docs/hardware/index.md from per-host frontmatter"
@echo " docs-build Build the static MkDocs site into ./site (strict)" @echo " docs-build Build the static MkDocs site into ./site (strict)"
@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 index, fail if it differs from the committed copy"
@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_rack.py
docs-build: docs-build:
mkdocs build --strict mkdocs build --strict
@ -22,22 +19,7 @@ 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 git diff --exit-code docs/hardware/index.md
python3 scripts/gen_rack.py
@git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/ \
|| { \
echo; \
echo "✗ The generated docs are out of date with the source files."; \
echo " The diff above is what 'make docs-index' just regenerated."; \
echo " This is what CI checks on push. To fix it:"; \
echo " 1. run 'make docs-index'"; \
echo " 2. commit the changed files (including the generated ones)"; \
echo " Guide: https://docs.makerfloss.eu/guides/editing-hardware-docs/"; \
exit 1; \
}
slides: slides:
./build-slides.sh ./build-slides.sh
test:
pytest -q

View file

@ -1,57 +1,3 @@
# MakerFLOSS # MakerFLOSS
Documentation and working notes for **MakerFLOSS**, an Orange Makerspace initiative — a bi-weekly FLOSS jam-session community focused on self-hosted, open-source infrastructure. Første midlertidige repo til Orange Makerspaces MakerFLOSS initiativ.
- Live docs: <https://docs.makerfloss.eu>
- Slides: <https://slides.makerfloss.eu>
- Source: <https://forgejo.makerfloss.eu/sjat/MakerFLOSS>
## Repository layout
```
docs/ # built into the public site (docs.makerfloss.eu)
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
services/ # auto-indexed per-service frontmatter (docs, forgejo, …)
infrastructure/ # labdesign, VPS/DNS, etc.
presentations/ # Marp decks (also published to slides.makerfloss.eu)
notes/ # repo-only working material — not part of the site
meetings/ # meeting notes (Danish allowed)
todo/ # task lists, working norms, wishlist, services
dev/ # internal plans/ and specs/
communications/ # community comms artefacts
sandbox/ # scratch / pipeline fixtures
scripts/ # build helpers (hardware index, mkdocs hook)
```
## Building locally
Requires Python 3.10+ and, for slide builds, either `marp` on `$PATH` or Docker.
```bash
python3 -m venv .venv && source .venv/bin/activate
pip install -r requirements.txt
```
Common targets:
```bash
make docs-serve # live-reload preview at http://127.0.0.1:8000
make docs-build # strict build into ./site
make docs-index # regenerate docs/hardware/index.md from per-host frontmatter
make docs-check # CI drift-check for the hardware index
make slides # build Marp decks (docs/presentations/*.md) into ./slides
```
CI runs on the self-hosted Forgejo Actions runner (`.forgejo/workflows/docs.yml`) and rsyncs the built site to the VPS.
## Conventions
- **Filenames**: ASCII lowercase, kebab-case (`like-this.md`). Dated docs use `YYYY-MM-DD-label.md`. Hardware host pages are the exception — their filename stem must equal the `hostname` field, enforced by `scripts/gen_overview.py`.
- **Languages**: English for code and docs; Danish allowed in meeting notes and community communications.
- **Git**: Trunk-based, feature branches, simple commit messages, no secrets in commits.
See [`CLAUDE.md`](CLAUDE.md) for the full working norms and AI-collaboration guidance.
## License
MIT — see [`LICENSE`](LICENSE).

View file

@ -1,14 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# Build every Marp deck in the repository into static HTML.
#
# A deck is any *.md carrying `marp: true` in its front matter. Two source
# roots, two output conventions:
#
# docs/presentations/<name>.md -> <name>.html (flat, historical)
# slides/<path>/<name>.md -> <path>/<name>.html (path mirrors URL)
#
# So slides/tappaas/how-to/new-module/index.md is served at
# https://slides.makerfloss.eu/tappaas/how-to/new-module/ .
set -euo pipefail set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@ -18,74 +8,53 @@ REPO_WEB_URL="${REPO_WEB_URL:-https://forgejo.makerfloss.eu/sjat/MakerFLOSS}"
mkdir -p "$OUTPUT_DIR" mkdir -p "$OUTPUT_DIR"
# Collect decks as "<repo-relative source>|<output-relative target>" pairs. # Wipe previously built slides so deleted/de-tagged .md files disappear.
DECKS=() find "$OUTPUT_DIR" -maxdepth 1 -name "*.html" -delete
# Find all markdown files with marp: true frontmatter
SLIDES=()
while IFS= read -r f; do while IFS= read -r f; do
rel="${f#"$REPO_ROOT"/}" SLIDES+=("$f")
DECKS+=("$rel|$(basename "${rel%.md}").html") done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" --exclude-dir=superpowers 2>/dev/null || true)
done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" 2>/dev/null | sort || true)
if [ -d "$REPO_ROOT/slides" ]; then if [ ${#SLIDES[@]} -eq 0 ]; then
while IFS= read -r f; do echo "No marp presentations found in docs/."
rel="${f#"$REPO_ROOT"/}"
out="${rel#slides/}"
DECKS+=("$rel|${out%.md}.html")
done < <(grep -rl "^marp: true" "$REPO_ROOT/slides" --include="*.md" 2>/dev/null | sort || true)
fi
if [ ${#DECKS[@]} -eq 0 ]; then
echo "No marp presentations found."
exit 0 exit 0
fi fi
echo "Found ${#DECKS[@]} presentation(s):" echo "Found ${#SLIDES[@]} presentation(s):"
printf ' %s\n' "${DECKS[@]%%|*}" printf ' %s\n' "${SLIDES[@]}"
# Wipe previously built HTML so deleted/de-tagged decks disappear. Only *.html # Create temp output directory for Docker fallback
# is removed, so deck sources living inside slides/ are never touched. TEMP_OUTPUT=$(mktemp -d)
find "$OUTPUT_DIR" -name "*.html" -type f -delete chmod 777 "$TEMP_OUTPUT"
trap "rm -rf '$TEMP_OUTPUT'" EXIT
render() { # render <source-rel> <target-rel> if command -v marp &>/dev/null; then
local src="$1" dst="$2" for slide in "${SLIDES[@]}"; do
mkdir -p "$OUTPUT_DIR/$(dirname "$dst")" BASENAME=$(basename "${slide%.*}")
if command -v marp &>/dev/null; then marp --html --output "$OUTPUT_DIR/${BASENAME}.html" "$slide"
marp --html --output "$OUTPUT_DIR/$dst" "$REPO_ROOT/$src" done
elif command -v npx &>/dev/null; then else
npx --yes @marp-team/marp-cli --html --output "$OUTPUT_DIR/$dst" "$REPO_ROOT/$src" echo "marp not found locally — using Docker (marpteam/marp-cli)..."
else for slide in "${SLIDES[@]}"; do
# marpteam/marp-cli drops privileges to its own "marp" user, so the mounted REL_SLIDE="${slide#${REPO_ROOT}/}"
# output tree has to be writable by anyone — and --user must NOT be passed. BASENAME=$(basename "${slide%.*}")
chmod -R a+rwX "$OUTPUT_DIR"
docker run --rm \ docker run --rm \
-v "$REPO_ROOT":/home/marp/app:ro \ -v "$REPO_ROOT":/home/marp/app:ro \
-v "$OUTPUT_DIR":/home/marp/out \ -v "$TEMP_OUTPUT":/home/marp/output \
marpteam/marp-cli --html --output "/home/marp/out/$dst" "$src" marpteam/marp-cli --html --output "/home/marp/output/${BASENAME}.html" "$REL_SLIDE"
fi done
} cp "$TEMP_OUTPUT"/*.html "$OUTPUT_DIR/" 2>/dev/null || true
if ! command -v marp &>/dev/null && ! command -v npx &>/dev/null; then
if command -v docker &>/dev/null; then
echo "marp not found locally — using Docker (marpteam/marp-cli)..."
else
echo "error: need one of marp, npx or docker on PATH" >&2
exit 1
fi
fi fi
for deck in "${DECKS[@]}"; do # Inject mermaid.js into any HTML that contains mermaid code blocks.
render "${deck%%|*}" "${deck#*|}"
done
# Post-process each built page: highlight unfinished `TODO ...` markers, and
# turn mermaid code blocks into live diagrams.
#
# Marp emits fenced mermaid blocks as <pre><code class="language-mermaid">. # Marp emits fenced mermaid blocks as <pre><code class="language-mermaid">.
# The script replaces those elements with <div class="mermaid">, then loads and # The script finds those elements, replaces them with <div class="mermaid">,
# runs mermaid.js from CDN. # then loads and runs mermaid.js from CDN.
postprocess() { inject_mermaid() {
local html_file="$1" local html_file="$1"
python3 - "$html_file" << 'PYEOF' python3 - "$html_file" << 'PYEOF'
import re
import sys import sys
path = sys.argv[1] path = sys.argv[1]
@ -104,52 +73,25 @@ await mermaid.run();
with open(path, encoding='utf-8') as f: with open(path, encoding='utf-8') as f:
content = f.read() content = f.read()
new_content = content.replace('</body>', snippet + '\n</body>', 1)
# Inline <code>TODO…</code> -> <code class="todo">…</code>; decks style that class. if new_content == content:
content, todos = re.subn(r'<code>(TODO\b[^<]*)</code>', r'<code class="todo">\1</code>', content) print(f"Warning: </body> not found in {path}", file=sys.stderr)
sys.exit(1)
if 'class="language-mermaid"' in content:
if '</body>' not in content:
print(f"Warning: </body> not found in {path}", file=sys.stderr)
sys.exit(1)
content = content.replace('</body>', snippet + '\n</body>', 1)
print(f" Injected mermaid.js into {path}")
with open(path, 'w', encoding='utf-8') as f: with open(path, 'w', encoding='utf-8') as f:
f.write(content) f.write(new_content)
if todos:
print(f" {todos} unfinished TODO marker(s) in {path}")
PYEOF PYEOF
} }
if command -v python3 &>/dev/null; then if command -v python3 &>/dev/null; then
# The landing index.html is generated further down, so every *.html present for html_file in "$OUTPUT_DIR"/*.html; do
# right now is a built deck. [ -f "$html_file" ] || continue
while IFS= read -r html_file; do if grep -q 'class="language-mermaid"' "$html_file"; then
postprocess "$html_file" inject_mermaid "$html_file"
done < <(find "$OUTPUT_DIR" -name "*.html" -type f) echo " Injected mermaid.js into $(basename "$html_file")"
fi
done
else else
echo "Warning: python3 not found — skipping post-processing" echo "Warning: python3 not found — skipping mermaid injection"
fi
# Copy deck assets (images) that live next to a source under slides/. Marp keeps
# relative image URLs in the HTML, so the files must exist at the same relative
# path in the output tree. Only slides/ can carry these: docs/presentations/
# decks are flattened to <name>.html, so a relative path would not survive.
# When OUTPUT_DIR is the in-repo slides/ dir, source and target are the same
# file and there is nothing to copy.
if [ -d "$REPO_ROOT/slides" ]; then
while IFS= read -r -d '' asset; do
rel="${asset#"$REPO_ROOT"/slides/}"
dest="$OUTPUT_DIR/$rel"
[ "$asset" -ef "$dest" ] && continue
mkdir -p "$(dirname "$dest")"
cp "$asset" "$dest"
echo " Copied asset $rel"
done < <(find "$REPO_ROOT/slides" -type f \
\( -iname "*.jpg" -o -iname "*.jpeg" -o -iname "*.png" \
-o -iname "*.gif" -o -iname "*.svg" -o -iname "*.webp" \) -print0)
fi fi
# Regenerate index.html listing every built deck. # Regenerate index.html listing every built deck.
@ -171,10 +113,9 @@ INDEX="$OUTPUT_DIR/index.html"
printf '<p class="sub">Built from <a href="%s">%s</a></p>' \ printf '<p class="sub">Built from <a href="%s">%s</a></p>' \
"$REPO_WEB_URL" "${REPO_WEB_URL#https://}" "$REPO_WEB_URL" "${REPO_WEB_URL#https://}"
printf '<ul>' printf '<ul>'
for deck in "${DECKS[@]}"; do find "$OUTPUT_DIR" -maxdepth 1 -name "*.html" ! -name "index.html" | sort | while IFS= read -r html; do
href="${deck#*|}" name=$(basename "$html" .html)
label="${href%/index.html}"; label="${label%.html}" printf '<li><a href="%s">%s</a></li>' "$(basename "$html")" "$name"
printf '<li><a href="%s">%s</a></li>' "$href" "$label"
done done
printf '</ul>' printf '</ul>'
printf '<footer>Last built: %s &middot; <a href="https://marp.app">Marp</a></footer>' "$(date -Iseconds)" printf '<footer>Last built: %s &middot; <a href="https://marp.app">Marp</a></footer>' "$(date -Iseconds)"

View file

@ -1,204 +0,0 @@
---
title: Editing the hardware docs
---
# Editing the hardware docs
The hardware inventory is **one Markdown file per device** under
`docs/hardware/`. The YAML frontmatter at the top of each file is the single
source of truth. Everything you see rendered — the
[Hardware Overview](../hardware/index.md) table, the per-device **Specs** box,
the [rack elevation](../infrastructure/racks/rack01.md) SVG, and the network
graph — is **generated** from that frontmatter.
!!! warning "The golden rule"
After editing any file in `docs/hardware/`, run **`make docs-index`** and
commit the regenerated files. CI rebuilds the indices and fails the build if
they differ from what you committed. This is the single most common reason a
push goes red.
## Quick start: add a device
1. Pick a hostname following the [naming scheme](hardware-naming-scheme.md):
`<kind-abbrev><NN>` — a 2-digit number, unique per kind (`srv`, `sw`, `pp`,
`pdu`, `ups`, `shf`, …). Example: `srv06`.
2. Create `docs/hardware/<hostname>.md`. **The filename stem must equal the
`hostname` field** — `srv06.md` must contain `hostname: srv06`.
3. Fill in the frontmatter (see the reference below). Write any free-text under
`## Notes`.
4. Run `make docs-index` and commit **both** your new file and the regenerated
index / rack files.
```markdown
---
hostname: srv06
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
rack_u: 7
u_height: 2
rack_face: front
power:
- { pdu: pdu01, outlet: 5 }
links:
- { local: eth0, peer: sw01, peer_port: 6, speed_gbps: 1 }
---
## Notes
Donated tower; PSU replaced 2026-06.
```
## Frontmatter reference
### Required on every device
| Field | Notes |
|---|---|
| `hostname` | Must equal the filename stem. |
| `kind` | One of the enum below. |
| `status` | One of the enum below. |
**`kind`** — `server`, `laptop`, `sbc`, `switch`, `ap`, `desktop`, `pdu`,
`patch-panel`, `shelf`, `blank`, `ups`, `kvm`.
**`status`** — `in-use`, `staging`, `spare`, `broken`, `donated`.
### Specs (optional, shown in the table and Specs box)
| Field | Example | Notes |
|---|---|---|
| `location` | `The pile` | Free text. |
| `cpu` | `Intel Core i5-3570K @ 3.40GHz` | Model string. |
| `cpu_cores` / `cpu_threads` | `4` / `8` | Integers; threads shown only if they differ from cores. |
| `ram_gb` | `8` | Integer (rendered as `8 GB`). |
| `storage_gb` + `storage_type` | `500` + `hdd` | `storage_type` ∈ `nvme`, `ssd`, `hdd`, `mixed`. |
| `storage` | `[{gb: 500, type: ssd}, {gb: 2000, type: hdd}]` | List form for multiple drives (alternative to the two fields above). |
| `nic_gbps` | `1` or `[1, 10]` | Number or list (rendered as `GbE`). |
### Placement in a rack
Only files that declare a `rack:` appear in a rack elevation. The rack is 48U.
=== "Front / rear (U-mounted)"
```yaml
rack: rack01
rack_face: front # front | rear | both
rack_u: 7 # starting U (1–48)
u_height: 2 # height in U (≥1)
```
`both` occupies the same U range on **front and rear**. Two U-mounted
devices may not overlap on the same face.
=== "0U side rail (e.g. vertical PDU)"
```yaml
rack: rack01
rack_face: left # left | right
# no rack_u / u_height
```
Side-rail (`left`/`right`) items are 0U and **must omit** `rack_u` and
`u_height`.
=== "Shelf-mounted"
```yaml
rack: rack01
mounted_on: shf01 # an existing kind:shelf in the same rack
shelf_face: front # front | rear
shelf_slot: 2 # integer ≥1
chassis_u: 6 # optional: device height in U (how tall it stands)
# no rack_u / u_height / rack_face
```
The shelf itself must be placed (have `rack_u` + `u_height`). Two devices
can't share the same `(shelf, face, slot)`.
`chassis_u` is optional. Shelves are typically 1U trays; a device sitting on
one (e.g. a tower PC) stands `chassis_u` U's tall, rising above the shelf
line in the elevation without consuming those rack U's (so rail-mounted gear
may still occupy them). Omit it and the device just fills the shelf block.
### Power feeds
A device draws power by listing feeds. Each feed must point at a real `kind:pdu`
file, and the outlet must be within that PDU's `outlets` count.
```yaml
power:
- { pdu: pdu01, outlet: 5 }
```
A `pdu` device must declare a positive integer `outlets:` (e.g. `outlets: 8`).
### Network links
Links feed the network graph. `peer` is the hostname of a switch / patch-panel
/ peer device.
```yaml
links:
- { local: eth0, peer: sw01, peer_port: 6, speed_gbps: 1 }
```
## The make process
| Command | What it does |
|---|---|
| `make docs-index` | Regenerate the hardware/services indices and rack elevations from frontmatter. **Run this after every edit.** |
| `make docs-check` | Regenerate, then fail if the result differs from the committed copies — exactly what CI runs. |
| `make docs-build` | Build the static site with `mkdocs build --strict`. |
| `make docs-serve` | Live-reload local preview at `http://127.0.0.1:8000`. |
| `make test` | Run the Python unit tests (`pytest`). |
A typical edit loop:
```bash
$EDITOR docs/hardware/srv06.md
make docs-index # regenerate
make docs-check # confirm no drift (optional sanity check)
git add docs/hardware/srv06.md docs/hardware/index.md docs/infrastructure/racks/
git commit -m "hardware: add srv06"
git push # CI builds and publishes to docs.makerfloss.eu
```
On push to `main`, CI regenerates the indices, runs the drift check, builds the
site strictly, and publishes it. If you forgot `make docs-index`, the drift
check fails and nothing is published.
## Dos and don'ts
!!! success "Do"
- **Do** run `make docs-index` and commit the regenerated files with your change.
- **Do** keep the filename equal to the `hostname`.
- **Do** use the [`<kind-abbrev><NN>` naming scheme](hardware-naming-scheme.md),
2 digits, unique per kind.
- **Do** mark unknown values as provisional placeholders and ask before
inventing rack/power/network numbers.
- **Do** preview locally with `make docs-serve` before pushing.
!!! danger "Don't"
- **Don't** hand-edit generated files — `docs/hardware/index.md`,
`docs/services/index.md`, `docs/infrastructure/racks/*.md`, and the
`*-elevation.svg` files all carry a *"do not edit by hand"* banner and will
be overwritten.
- **Don't** put non-device Markdown into `docs/hardware/` — the generators
scan that folder and expect host frontmatter. Guides like this one live in
`docs/guides/`.
- **Don't** give a 0U side-rail item (`left`/`right`) a `rack_u`/`u_height`,
and don't give a U-mounted item a side-rail face — the generator rejects
both.
- **Don't** point `power` at a non-PDU, or use an outlet number beyond the
PDU's `outlets` count.
- **Don't** rename a file without renaming the `hostname` to match.

View file

@ -1,118 +0,0 @@
# Hardware Naming Scheme Design
**Date:** 2026-06-24
**Status:** Approved
## Goal
Settle a single, clear hardware naming scheme for the MakerFLOSS rack that the
team can grow within, before more devices are documented. The scheme assigns
every physical device a stable identifier and pushes everything that changes
over time (which cluster a node serves, its role) into frontmatter, so the
identifier never has to be renamed when hardware is repurposed.
## Context
- Hardware is documented one-file-per-device under `docs/hardware/*.md`, with the
file name equal to the device identifier (enforced by `scripts/gen_overview.py`).
- Current names are inconsistent: compute uses an org/brand prefix
(`mf00`–`mf04`), infrastructure uses kind prefixes (`pdu01/02`, `sw01`, `pp01`).
- The inventory to be documented is a mix of rack and home-office gear, grouped
into clusters (TaPPaaS production + four test clusters) plus crosscutting
power/network gear. Stationary PCs will sit on rack shelves.
- Hardware moves between test clusters over time, so cluster membership is a
changeable attribute, not an intrinsic property of a device.
## Decisions
### 1. Identifier nature (mixed)
- **Compute nodes (stationary PCs) and managed switches:** the identifier *is*
the device's real OS/DNS hostname. It must be DNS-safe (lowercase letters,
digits, hyphens) and stable — renaming costs OS/DNS/Ansible churn.
- **Passive gear (UPS, PDU/power strip, patch panel, unmanaged switch):** the
identifier is a documentation/physical-label id only; the device has no OS.
### 2. Format
`<kind-abbrev><NN>` — a kind abbreviation followed by a **2-digit zero-padded
sequence**, **globally unique within a kind**, starting at `01`. The number is
just "next free"; it encodes nothing — not cluster, rack, role, or port count.
A 10-port and a 24-port switch are still `sw01` and `sw02`.
### 3. Kind abbreviation table
The prefix equals the `kind` field, so the name is self-describing.
| Device | `kind` | abbrev | example |
|--------|--------|--------|---------|
| Stationary PC / server | `server` | `srv` | `srv01` |
| Switch (managed or dumb) | `switch` | `sw` | `sw01` |
| Patch / network panel | `patch-panel` | `pp` | `pp01` |
| PDU / power strip | `pdu` | `pdu` | `pdu01` |
| UPS | `ups` | `ups` | `ups01` |
| Shelf | `shelf` | `shf` | `shf01` |
| WAN uplink / ISP demarcation | `wan` | `wan` | `wan01` |
Existing enum kinds not yet in the rack reuse their natural short form when
first used (`ap` → `ap`, `kvm` → `kvm`, `sbc` → `sbc`, `laptop` → `lt`,
`desktop` → `dt`, `blank` → `blank`); register the chosen abbrev in this table
at that time.
Distinguishing attributes (port/outlet count, managed vs dumb, speed) live in
frontmatter (`ports:`, `outlets:`, …), never in the name.
### 4. Grouping in frontmatter, not the name
- `cluster:` — one of `tappaas`, `test-1`, `test-2`, `test-3`, `test-4`, or
`shared` (crosscutting gear that spans clusters: power strips, shared patch
panels). Editing this field is the entire cost of moving a node between
clusters.
- `role:` — optional, free-form (e.g. `control`, `worker`); added only where it
earns its keep.
A future generator step may group/table hardware by `cluster`, exactly as the
hardware index groups by `kind` today. That is **not** part of this scheme — the
scheme only fixes the field and its allowed values.
### 5. Exceptions
Cloud / externally-named hosts keep their real FQDN as the identifier (e.g.
`makerfloss.eu`) instead of `srvNN`. They are not racked and DNS owns the name.
Such hosts carry no `rack:` field and need no `cluster:`.
### 6. Growth rules
- **New device class:** add the `kind` to the `hardware` enum in
`scripts/overview_config.yml` and register a short abbrev in the table above.
- **New grouping:** add a `cluster:` value.
- **More than 99 of one kind:** widen that kind's sequence to 3 digits.
- **More racks:** already supported via the `rack:` field (Phase 1).
- **Multiple sites:** deferred until real (YAGNI); a site prefix or field can be
added without disturbing existing names.
## Migration of current names
| Current | New | Notes |
|---------|-----|-------|
| `mf00` | `srv01` | "TaPPaaS node 1" → `cluster: tappaas` |
| `mf01` | `srv02` | cluster omitted until a real assignment is given |
| `mf02` | `srv03` | |
| `mf03` | `srv04` | |
| `mf04` | `srv07` | **corrected 2026-07-17** — originally recorded as `srv05`. Verified on-site: the physical box carrying the Ansible/OS host `mf04` (i3-4360T, 16 GB, 500 GB HDD) wears the `srv07` sticker; `srv05`'s page describes a different machine (i5-3570K). OS hostname renamed to `srv07` the same day. |
| `pdu01`, `pdu02` | unchanged | already conform |
| `sw01`, `pp01` | unchanged | already conform |
| `makerfloss.eu` | unchanged | cloud FQDN exception |
The `mf0x` machines are staging placeholders, so renaming is cheap now. The
rename touches their files, the `power:`/`links:` references to them in
`pdu*`/`sw*`/`pp*` and sibling host files, and the regenerated artifacts under
`docs/hardware/index.md` and `docs/infrastructure/racks/`. The cluster
assignments for `srv02`–`srv05` are provisional until real values are given;
`srv01` = `tappaas` is the one known mapping.
## Out of scope
- Choosing the *best distribution* of hardware across clusters.
- Building a generator view that groups by `cluster` (possible future work).
- Real cluster assignments beyond `srv01` = TaPPaaS.

25
docs/hardware/fisi.md Normal file
View file

@ -0,0 +1,25 @@
---
hostname: fisi
kind: server
status: in-use
model: HP MicroServer Gen10 Plus
location: home rack
cpu: Xeon E-2226G
cpu_cores: 6
cpu_threads: 12
ram_gb: 64
storage_gb: 8000
storage_type: hdd
storage_notes: ZFS mirror 2×8 TB HDD + 1 TB NVMe cache
nic_gbps: 1
---
# fisi
Primary home server in the baobab.band homelab. Hosts the bulk of
self-hosted services: Nextcloud, Jellyfin + *arr stack, Technitium DNS,
PhotoPrism, Matrix (conduwuit + Element), Forgejo (internal), Vaultwarden,
and more.
Not part of the MakerFLOSS infrastructure proper, listed here for
Proxmox-style placement planning when we eventually share workloads.

View file

@ -2,69 +2,15 @@
_Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._ _Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
## Patch panels ## Laptops
| Hostname | Location | CPU | RAM | Storage | NIC | Status | | Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| [pp01](pp01.md) | | | | | | in-use | | [tembo](tembo.md) | ThinkPad T480 | Orange Makerspace (kiosk) | Intel Core i5-8350U · 4c/8t | 16 GB | 512 GB NVME | 1 GbE | in-use |
| [pp02](pp02.md) | | | | | | in-use |
## PDUs
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [pdu01](pdu01.md) | | | | | | in-use |
| [pdu02](pdu02.md) | | | | | | in-use |
| [pdu03](pdu03.md) | | | | | | in-use |
| [pdu04](pdu04.md) | | | | | | in-use |
## Servers ## Servers
| Hostname | Location | CPU | RAM | Storage | NIC | Status | | Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---| |---|---|---|---|---|---|---|---|
| [makerfloss.eu](makerfloss.eu.md) | Hetzner HEL1 (cloud) | AMD EPYC (shared vCPU) · 2c | 4 GB | 40 GB NVME | 1 GbE | in-use | | [fisi](fisi.md) | HP MicroServer Gen10 Plus | home rack | Xeon E-2226G · 6c/12t | 64 GB | 8 TB HDD | 1 GbE | in-use |
| [nas01](nas01.md) | The pile | ? | ? | ? | ? | staging | | [makerfloss](makerfloss.md) | Hetzner CX22 | Hetzner HEL1 (cloud) | AMD EPYC (shared vCPU) · 2c | 4 GB | 40 GB NVME | 1 GbE | in-use |
| [nas02](nas02.md) | The pile | ? | ? | ? | ? | staging |
| [srv01](srv01.md) | The pile | ? | ? | ? | ? | in-use |
| [srv02](srv02.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 8 GB | 40 GB NVME | 1 GbE | staging |
| [srv03](srv03.md) | The pile | Intel Core i5-8500 @ 3.00GHz · 6c | 16 GB | 40 GB NVME | 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 |
| [srv06](srv06.md) | The pile | ? | ? | ? | ? | staging |
| [srv07](srv07.md) | The pile | Intel Core i3-4360T @ 3.20GHz · 2c/4t | 16 GB | 500 GB HDD | 1 GbE | staging |
## Shelves
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [shf01](shf01.md) | | | | | | in-use |
| [shf02](shf02.md) | | | | | | in-use |
| [shf03](shf03.md) | | | | | | in-use |
| [shf04](shf04.md) | | | | | | in-use |
| [shf05](shf05.md) | | | | | | in-use |
## Switches
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [sw01](sw01.md) | | | | | | in-use |
| [sw02](sw02.md) | | | | | | staging |
| [sw03](sw03.md) | | | | | | staging |
| [sw04](sw04.md) | | | | | | staging |
| [sw05](sw05.md) | | | | | | staging |
| [sw06](sw06.md) | | | | | | in-use |
| [sw07](sw07.md) | | | | | | in-use |
## UPS
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [ups01](ups01.md) | | | | | | staging |
| [ups02](ups02.md) | | | | | | staging |
## WAN uplinks
| Hostname | Location | CPU | RAM | Storage | NIC | Status |
|---|---|---|---|---|---|---|
| [wan01](wan01.md) | ISP demarcation | | | | | in-use |
| [wan02](wan02.md) | ISP demarcation | | | | | staging |

View file

@ -1,7 +1,8 @@
--- ---
hostname: makerfloss.eu hostname: makerfloss
kind: server kind: server
status: in-use status: in-use
model: Hetzner CX22
location: Hetzner HEL1 (cloud) location: Hetzner HEL1 (cloud)
cpu: AMD EPYC (shared vCPU) cpu: AMD EPYC (shared vCPU)
cpu_cores: 2 cpu_cores: 2
@ -12,9 +13,12 @@ storage_type: nvme
nic_gbps: 1 nic_gbps: 1
--- ---
## Notes # makerfloss
Hetzner Cloud VPS running the public-facing MakerFLOSS stack: Forgejo Hetzner Cloud VPS running the public-facing MakerFLOSS stack: Forgejo
(self-hosted git forge), Traefik with Let's Encrypt, poste.io mail (self-hosted git forge), Traefik with Let's Encrypt, poste.io mail
server, a Forgejo Actions runner, and the nginx services that serve server, a Forgejo Actions runner, and the nginx services that serve
`slides.makerfloss.eu` and `docs.makerfloss.eu`. `slides.makerfloss.eu` and `docs.makerfloss.eu`.
Managed via the [`AnsibleBaobabV4`](https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4)
Ansible project. SSH on port 7576.

View file

@ -1,25 +0,0 @@
---
hostname: nas01
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
rack_u: 6
u_height: 1
rack_face: front
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
# links: cabling TBD — to be documented during network wiring
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

View file

@ -1,25 +0,0 @@
---
hostname: nas02
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
rack_u: 7
u_height: 1
rack_face: front
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
# links: cabling TBD — to be documented during network wiring
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

View file

@ -1,14 +0,0 @@
---
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 9
rack_u: 1
u_height: 1
---
## Notes
-

View file

@ -1,14 +0,0 @@
---
hostname: pdu02
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 5
rack_u: 12
u_height: 1
---
## Notes
-

View file

@ -1,13 +0,0 @@
---
hostname: pdu03
kind: pdu
status: in-use
rack: rack01
rack_face: rear
outlets: 11
rack_u: 34
u_height: 1
---
## Notes

View file

@ -1,13 +0,0 @@
---
hostname: pdu04
kind: pdu
status: in-use
rack: rack01
rack_face: front
outlets: 5
rack_u: 12
u_height: 1
---
## Notes

View file

@ -1,22 +0,0 @@
---
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 9
links:
- { local: "1", peer: wan01, peer_port: 1 }
- { local: "4", peer: sw06, peer_port: 3 }
---
## Notes
- Port 1 → wan01 (WAN line 1, active). Fed from pp02:1 (srv01 eth0).
- Port 2 → wan02 (WAN line 2), set up but non-active.
- Port 3 ← sw01:8 (switch management), patched out to the working table
(white cable), non-active.
- Port 4 → sw06:3 (aggregation switch on shf03), patched out to the working
table (black cable).

View file

@ -1,30 +0,0 @@
---
hostname: pp02
kind: patch-panel
status: in-use
rack: rack01
rack_u: 25
u_height: 1
rack_face: front
ports: 24
links:
- { local: "1", peer: pp01, peer_port: 1 }
- { local: "2", peer: sw01, peer_port: 1 }
- { local: "3", peer: sw01, peer_port: 2 }
- { local: "4", peer: sw01, peer_port: 3 }
- { local: "5", peer: sw01, peer_port: 4 }
- { local: "6", peer: sw01, peer_port: 5 }
- { local: "7", peer: sw01, peer_port: 6 }
- { local: "8", peer: sw06, peer_port: 4 }
---
## Notes
- 24-port patch panel; ports 1–7 are the live LAN feeds.
- Port 1 → pp01:1 → wan01 (srv01 eth0, WAN).
- Ports 2–7 → sw01:1–6 (LAN): srv01 eth1 (p2), srv02 (p3), srv03 (p4),
srv04 (p5), srv05 (p6), srv06 (p7).
- Port 8 carries srv07 eth0 (its only NIC) and is rear-patched to **sw06:4**
since 2026-07-17. History: the original rear patch to sw01:7 was removed
when sw01:7 was repurposed as the uplink to sw06, leaving srv07 with no LAN
path until the sw06:4 patch restored it.

View file

@ -1,17 +0,0 @@
---
hostname: shf01
kind: shelf
status: in-use
rack: rack01
rack_u: 46
u_height: 1
rack_face: both
cluster: tappaas
---
## Notes
- 1U full-depth tray at U46. Tower PCs stand on it and rise above U46; they are
not rail-mounted, so the U's above are not consumed in the rack model.
- Front: srv01 (10U, U37–U46), srv02 (8U, U39–U46).
- Rear: srv03 (6U, U41–U46).

View file

@ -1,17 +0,0 @@
---
hostname: shf02
kind: shelf
status: in-use
rack: rack01
rack_u: 35
u_height: 1
rack_face: both
---
## Notes
- 1U full-depth tray at U35. Tower PCs stand on it and rise above U35; they are
not rail-mounted, so the U's above are not consumed in the rack model
(e.g. pdu03 sits at U34, just above this shelf).
- Front: srv07 (6U, U30–U35), srv04 (6U, U30–U35).
- Rear: srv05 (7U, U29–U35), srv06 (7U, U29–U35).

View file

@ -1,15 +0,0 @@
---
hostname: shf03
kind: shelf
status: in-use
rack: rack01
rack_u: 22
u_height: 1
rack_face: front
---
## Notes
- Half-depth shelf at U22 (front).
- Holds two unmanaged switches standing on it: sw07 (slot 1, left) and
sw06 (slot 2, right).

View file

@ -1,14 +0,0 @@
---
hostname: shf04
kind: shelf
status: in-use
rack: rack01
rack_u: 21
u_height: 1
rack_face: rear
---
## Notes
- Half-depth shelf at U21 (rear), currently empty.
- Was the rear pair of shf03; shf03 has since moved up to U22.

View file

@ -1,13 +0,0 @@
---
hostname: shf05
kind: shelf
status: in-use
rack: rack01
rack_u: 19
u_height: 1
rack_face: front
---
## Notes
- Shelf at U19 (front), currently empty.

View file

@ -1,30 +0,0 @@
---
hostname: srv01
kind: server
status: in-use
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
mounted_on: shf01
shelf_face: front
shelf_slot: 1
chassis_u: 10
power:
- { pdu: pdu01, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 1, speed_gbps: 1 }
- { local: eth1, peer: pp02, peer_port: 2, speed_gbps: 1 }
---
## Notes
- MakerFLOSS production stack
- TaPPaaS node 1
## ToDo

View file

@ -1,34 +0,0 @@
---
hostname: srv02
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: Intel Core i5-8500 @ 3.00GHz
cpu_cores: 6
cpu_threads: 6
ram_gb: 8
ram_type: DDR4-2666 non-ECC UDIMM
storage_gb: 40
storage_type: nvme
nic_gbps: 1
rack: rack01
mounted_on: shf01
shelf_face: front
shelf_slot: 2
chassis_u: 8
power:
- { pdu: pdu01, outlet: 2 }
links:
- { local: eth0, peer: pp02, peer_port: 3, speed_gbps: 1 }
---
## Notes
- TaPPaaS node 2
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings
3. New CMOS battery

View file

@ -1,34 +0,0 @@
---
hostname: srv03
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: Intel Core i5-8500 @ 3.00GHz
cpu_cores: 6
cpu_threads: 6
ram_gb: 16
ram_type: DDR4-2666 non-ECC UDIMM
storage_gb: 40
storage_type: nvme
nic_gbps: 1
rack: rack01
mounted_on: shf01
shelf_face: rear
shelf_slot: 1
chassis_u: 6
power:
- { pdu: pdu01, outlet: 3 }
links:
- { local: eth0, peer: pp02, peer_port: 4, speed_gbps: 1 }
---
## Notes
- TaPPaaS node 3
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings
3. New CMOS battery

View file

@ -1,31 +0,0 @@
---
hostname: srv04
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
mounted_on: shf02
shelf_face: front
shelf_slot: 2
chassis_u: 6
power:
- { pdu: pdu01, outlet: 4 }
links:
- { local: eth0, peer: pp02, peer_port: 5, speed_gbps: 1 }
---
## Notes
Old Gamer style PC
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings

View file

@ -1,31 +0,0 @@
---
hostname: srv05
kind: server
status: staging
location: The pile
cpu: Intel Core i5-3570K @ 3.40GHz
cpu_cores: 4
cpu_threads: 4
ram_gb: 8
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
mounted_on: shf02
shelf_face: rear
shelf_slot: 1
chassis_u: 7
power:
- { pdu: pdu01, outlet: 5 }
links:
- { local: eth0, peer: pp02, peer_port: 6, speed_gbps: 1 }
---
## Notes
Old Gamer style PC
## ToDo
1. Update BIOS/UEFI firmware
2. Update BIOS/UEFI settings

View file

@ -1,27 +0,0 @@
---
hostname: srv06
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: "?"
cpu_cores: "?"
cpu_threads: "?"
ram_gb: "?"
storage: "?"
nic_gbps: "?"
rack: rack01
mounted_on: shf02
shelf_face: rear
shelf_slot: 2
chassis_u: 7
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 7, speed_gbps: 1 }
---
## Notes
- Reconstructed from committed rack artifacts; specs are placeholders.

View file

@ -1,40 +0,0 @@
---
hostname: srv07
kind: server
status: staging
cluster: tappaas
location: The pile
cpu: Intel Core i3-4360T @ 3.20GHz
cpu_cores: 2
cpu_threads: 4
ram_gb: 16
storage_gb: 500
storage_type: hdd
nic_gbps: 1
rack: rack01
mounted_on: shf02
shelf_face: front
shelf_slot: 1
chassis_u: 6
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
links:
- { local: eth0, peer: pp02, peer_port: 8, speed_gbps: 1 }
---
## Notes
- Specs read live 2026-07-17 (lscpu/free/lsblk): i3-4360T (2c/4t), 16 GB RAM,
500 GB HDD (Seagate ST3500418AS). Runs Debian 13 (trixie).
- This is the machine Ansible (AnsibleBaobabV4) managed as **mf04**; the OS
hostname was renamed `mf04` → `srv07` on 2026-07-17 to match the sticker.
The naming-scheme guide's migration row originally said `mf04` → `srv05`;
that was a mis-mapping (srv05's page describes an i5-3570K box).
- SSH on **:7576** (project standard), user `sjat`. WireGuard split-tunnel to
the makerfloss VPS (`wg1` peer `10.13.0.3`, unit `wg-quick@mf-srv07`) dials
out automatically on boot.
- Single NIC (eth0 → pp02:8). LAN path restored 2026-07-17 via a rear patch
pp02:8 → **sw06:4** (DHCP lease `10.0.0.183`); the original pp02:8 → sw01:7
patch had been removed when sw01:7 became the sw06 uplink, which left the
box with no LAN path for a while.

View file

@ -1,22 +0,0 @@
---
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 23
u_height: 1
rack_face: front
ports: 10
links:
- { local: "7", peer: sw06, peer_port: 1, speed_gbps: 1 }
- { local: "8", peer: pp01, peer_port: 3 }
---
## Notes
- 10 ports: p1–p8 are 1 GbE; sfp1–sfp2 are 2.5 GbE SFP+ (unused today).
- p1–p6 carry server uplinks via pp02 (pp02:2–7 → sw01:1–6).
- p7 uplinks to sw06:1 (aggregation switch on shf03) — repurposed from the
former srv07 uplink.
- p8 is the management port, patched out via pp01:3 to the working table
(white cable) — non-active.

View file

@ -1,13 +0,0 @@
---
hostname: sw02
kind: switch
status: staging
rack: rack01
rack_u: 9
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

View file

@ -1,13 +0,0 @@
---
hostname: sw03
kind: switch
status: staging
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

View file

@ -1,13 +0,0 @@
---
hostname: sw04
kind: switch
status: staging
rack: rack01
rack_u: 5
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

View file

@ -1,13 +0,0 @@
---
hostname: sw05
kind: switch
status: staging
rack: rack01
rack_u: 8
u_height: 1
rack_face: front
---
## Notes
- Reconstructed from committed rack artifacts; placeholder values.

View file

@ -1,21 +0,0 @@
---
hostname: sw06
kind: switch
status: in-use
rack: rack01
mounted_on: shf03
shelf_face: front
shelf_slot: 2
chassis_u: 1
ports: 9
---
## Notes
- 9-port unmanaged switch. Stands on shf03, right side (slot 2).
- Ports 1–8 are 2.5 GbE; port 9 is 10 GbE.
- Port 1 ← sw01:7 (LAN uplink from the rack switch, negotiates at 1 GbE).
- Port 2 ← sw07:1 (1 GbE).
- Port 3 ← pp01:4 (patched out to the working table).
- Port 4 ← pp02:8 (srv07 eth0, since 2026-07-17 — restored srv07's LAN path).
- Port 9 (10 GbE) unused today.

View file

@ -1,18 +0,0 @@
---
hostname: sw07
kind: switch
status: in-use
rack: rack01
mounted_on: shf03
shelf_face: front
shelf_slot: 1
chassis_u: 1
ports: 4
links:
- { local: "1", peer: sw06, peer_port: 2, speed_gbps: 1 }
---
## Notes
- 4-port unmanaged 1 GbE switch. Stands on shf03, left side (slot 1).
- Uplink: port 1 → sw06:2 (a 2.5 GbE port, negotiates at 1 GbE).

24
docs/hardware/tembo.md Normal file
View file

@ -0,0 +1,24 @@
---
hostname: tembo
kind: laptop
status: in-use
model: ThinkPad T480
location: Orange Makerspace (kiosk)
cpu: Intel Core i5-8350U
cpu_cores: 4
cpu_threads: 8
ram_gb: 16
storage_gb: 512
storage_type: nvme
nic_gbps: 1
---
# tembo
XFCE-based touchscreen kiosk laptop at Orange Makerspace. Runs a
rotation of dashboards (Grafana, the Marp slides at
`slides.makerfloss.eu`, the radio page) and serves as the local
display for the room. Also runs a Grafana/Loki/Prometheus stack.
Acts as an example of a re-purposed laptop being treated as a fixed
piece of infrastructure rather than a personal device.

View file

@ -1,13 +0,0 @@
---
hostname: ups01
kind: ups
status: staging
rack: rack01
rack_u: 4
u_height: 1
rack_face: front
---
## Notes
-

View file

@ -1,13 +0,0 @@
---
hostname: ups02
kind: ups
status: staging
rack: rack01
rack_u: 3
u_height: 1
rack_face: front
---
## Notes
-

View file

@ -1,19 +0,0 @@
---
hostname: wan01
kind: wan
status: in-use
location: ISP demarcation
ports: 1
---
## Notes
- External WAN uplink — the upstream/ISP side of the WAN cable, where the
MakerFLOSS network meets the provider.
- Not racked (no `rack:`), like the cloud-FQDN exception in the naming scheme.
It exists so the WAN cable has a real peer to terminate on.
- Path into the rack: `wan01:1 ← pp01:1 ← pp02:1 ← srv01 eth0`.
## ToDo
- Confirm provider / circuit details for this handoff.

View file

@ -1,13 +0,0 @@
---
hostname: wan02
kind: wan
status: staging
location: ISP demarcation
ports: 1
---
## Notes
- Second WAN uplink line ("wan 2"), set up but not yet active.
- Patched from pp01:2 (non-active), so it does not yet appear in the live
network diagram.

View file

@ -6,14 +6,9 @@ jam-session community focused on self-hosted, open-source infrastructure.
## Sections ## Sections
- [Project ideas](project-ideas.md) — the menu of things we could build, fix, or
figure out at a jam session. Unclaimed ideas are up for grabs.
- [Hardware](hardware/index.md) — every machine in the lab, auto-indexed from per-host - [Hardware](hardware/index.md) — every machine in the lab, auto-indexed from per-host
frontmatter blocks. Use this when planning where to deploy a new service. frontmatter blocks. Use this when planning where to deploy a new service.
- [Services](services/index.md) — the things we run on (or around) that - [House rules](makerFLOSS_house_rules.md) — working norms, governance, and
hardware: the docs site, slides, Forgejo, DNS, the slide-builder
toolchain, and so on.
- [House rules](house-rules.md) — working norms, governance, and
what we do (and don't) do. what we do (and don't) do.
## Working norms (summary) ## Working norms (summary)
@ -23,5 +18,5 @@ jam-session community focused on self-hosted, open-source infrastructure.
- **Environments:** containerised and reproducible. - **Environments:** containerised and reproducible.
- **Hardware:** all setups documented (this site) and physically labelled. - **Hardware:** all setups documented (this site) and physically labelled.
- **Decisions:** lightweight markdown decision logs under - **Decisions:** lightweight markdown decision logs under
[`notes/dev/`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/notes/dev). [`docs/superpowers/`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/docs/superpowers).
- **License:** FLOSS by default; MIT for what we build. - **License:** FLOSS by default; MIT for what we build.

View file

@ -1,371 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="614" height="1068" viewBox="0 0 614 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="614" height="1068" 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>
<text x="576" y="54" text-anchor="start" fill="#999">1</text>
<text x="576" y="74" text-anchor="start" fill="#999">2</text>
<text x="576" y="94" text-anchor="start" fill="#999">3</text>
<text x="576" y="114" text-anchor="start" fill="#999">4</text>
<text x="576" y="134" text-anchor="start" fill="#999">5</text>
<text x="576" y="154" text-anchor="start" fill="#999">6</text>
<text x="576" y="174" text-anchor="start" fill="#999">7</text>
<text x="576" y="194" text-anchor="start" fill="#999">8</text>
<text x="576" y="214" text-anchor="start" fill="#999">9</text>
<text x="576" y="234" text-anchor="start" fill="#999">10</text>
<text x="576" y="254" text-anchor="start" fill="#999">11</text>
<text x="576" y="274" text-anchor="start" fill="#999">12</text>
<text x="576" y="294" text-anchor="start" fill="#999">13</text>
<text x="576" y="314" text-anchor="start" fill="#999">14</text>
<text x="576" y="334" text-anchor="start" fill="#999">15</text>
<text x="576" y="354" text-anchor="start" fill="#999">16</text>
<text x="576" y="374" text-anchor="start" fill="#999">17</text>
<text x="576" y="394" text-anchor="start" fill="#999">18</text>
<text x="576" y="414" text-anchor="start" fill="#999">19</text>
<text x="576" y="434" text-anchor="start" fill="#999">20</text>
<text x="576" y="454" text-anchor="start" fill="#999">21</text>
<text x="576" y="474" text-anchor="start" fill="#999">22</text>
<text x="576" y="494" text-anchor="start" fill="#999">23</text>
<text x="576" y="514" text-anchor="start" fill="#999">24</text>
<text x="576" y="534" text-anchor="start" fill="#999">25</text>
<text x="576" y="554" text-anchor="start" fill="#999">26</text>
<text x="576" y="574" text-anchor="start" fill="#999">27</text>
<text x="576" y="594" text-anchor="start" fill="#999">28</text>
<text x="576" y="614" text-anchor="start" fill="#999">29</text>
<text x="576" y="634" text-anchor="start" fill="#999">30</text>
<text x="576" y="654" text-anchor="start" fill="#999">31</text>
<text x="576" y="674" text-anchor="start" fill="#999">32</text>
<text x="576" y="694" text-anchor="start" fill="#999">33</text>
<text x="576" y="714" text-anchor="start" fill="#999">34</text>
<text x="576" y="734" text-anchor="start" fill="#999">35</text>
<text x="576" y="754" text-anchor="start" fill="#999">36</text>
<text x="576" y="774" text-anchor="start" fill="#999">37</text>
<text x="576" y="794" text-anchor="start" fill="#999">38</text>
<text x="576" y="814" text-anchor="start" fill="#999">39</text>
<text x="576" y="834" text-anchor="start" fill="#999">40</text>
<text x="576" y="854" text-anchor="start" fill="#999">41</text>
<text x="576" y="874" text-anchor="start" fill="#999">42</text>
<text x="576" y="894" text-anchor="start" fill="#999">43</text>
<text x="576" y="914" text-anchor="start" fill="#999">44</text>
<text x="576" y="934" text-anchor="start" fill="#999">45</text>
<text x="576" y="954" text-anchor="start" fill="#999">46</text>
<text x="576" y="974" text-anchor="start" fill="#999">47</text>
<text x="576" y="994" text-anchor="start" fill="#999">48</text>
<rect x="42" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="332" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv01/">
<title>srv01 · server · in-use · cluster: tappaas · shf01/front/slot 1</title>
<rect x="43" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
</a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="163" y="801" width="118" height="152" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="881" text-anchor="middle" fill="#ffffff">srv02</text>
</a>
<a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="333" y="841" width="238" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="452" y="901" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U46</title>
<rect x="42" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<a href="/hardware/srv07/">
<title>srv07 · server · staging · cluster: tappaas · shf02/front/slot 1</title>
<rect x="43" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="102" y="681" text-anchor="middle" fill="#ffffff">srv07</text>
</a>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · shf02/front/slot 2</title>
<rect x="163" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="681" text-anchor="middle" fill="#ffffff">srv04</text>
</a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · shf02/rear/slot 1</title>
<rect x="333" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="392" y="671" text-anchor="middle" fill="#ffffff">srv05</text>
</a>
<a href="/hardware/srv06/">
<title>srv06 · server · staging · cluster: tappaas · shf02/rear/slot 2</title>
<rect x="453" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="512" y="671" text-anchor="middle" fill="#ffffff">srv06</text>
</a>
<a href="/hardware/shf02/">
<title>shf02 · shelf · in-use · cluster: — · U35</title>
<rect x="42" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="739" text-anchor="middle" fill="#333" font-size="9">shf02</text>
</a>
<a href="/hardware/sw07/">
<title>sw07 · switch · in-use · cluster: — · shf03/front/slot 1</title>
<rect x="43" y="461" width="118" height="12" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="471" text-anchor="middle" fill="#ffffff">sw07</text>
</a>
<a href="/hardware/sw06/">
<title>sw06 · switch · in-use · cluster: — · shf03/front/slot 2</title>
<rect x="163" y="461" width="118" height="12" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="222" y="471" text-anchor="middle" fill="#ffffff">sw06</text>
</a>
<a href="/hardware/shf03/">
<title>shf03 · shelf · in-use · cluster: — · U22</title>
<rect x="42" y="474" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="474" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="479" text-anchor="middle" fill="#333" font-size="9">shf03</text>
</a>
<a href="/hardware/shf04/">
<title>shf04 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf04</text>
</a>
<a href="/hardware/shf05/">
<title>shf05 · shelf · in-use · cluster: — · U19</title>
<rect x="42" y="414" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="414" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="419" text-anchor="middle" fill="#333" font-size="9">shf05</text>
</a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · U1</title>
<rect x="333" y="41" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="54" text-anchor="middle" fill="#ffffff">pdu01 (U1)</text>
</a>
<a href="/hardware/ups02/">
<title>ups02 · ups · staging · cluster: — · U3</title>
<rect x="43" y="81" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="94" text-anchor="middle" fill="#ffffff">ups02 (U3)</text>
</a>
<a href="/hardware/ups01/">
<title>ups01 · ups · staging · cluster: — · U4</title>
<rect x="43" y="101" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="114" text-anchor="middle" fill="#ffffff">ups01 (U4)</text>
</a>
<a href="/hardware/sw04/">
<title>sw04 · switch · staging · cluster: — · U5</title>
<rect x="43" y="121" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="134" text-anchor="middle" fill="#ffffff">sw04 (U5)</text>
</a>
<a href="/hardware/nas01/">
<title>nas01 · server · staging · cluster: tappaas · U6</title>
<rect x="43" y="141" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="154" text-anchor="middle" fill="#ffffff">nas01 (U6)</text>
</a>
<a href="/hardware/nas02/">
<title>nas02 · server · staging · cluster: tappaas · U7</title>
<rect x="43" y="161" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="174" text-anchor="middle" fill="#ffffff">nas02 (U7)</text>
</a>
<a href="/hardware/sw05/">
<title>sw05 · switch · staging · cluster: — · U8</title>
<rect x="43" y="181" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="194" text-anchor="middle" fill="#ffffff">sw05 (U8)</text>
</a>
<a href="/hardware/sw02/">
<title>sw02 · switch · staging · cluster: — · U9</title>
<rect x="43" y="201" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="214" text-anchor="middle" fill="#ffffff">sw02 (U9)</text>
</a>
<a href="/hardware/sw03/">
<title>sw03 · switch · staging · cluster: — · U10</title>
<rect x="43" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="234" text-anchor="middle" fill="#ffffff">sw03 (U10)</text>
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · U12</title>
<rect x="333" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="274" text-anchor="middle" fill="#ffffff">pdu02 (U12)</text>
</a>
<a href="/hardware/pdu04/">
<title>pdu04 · pdu · in-use · cluster: — · U12</title>
<rect x="43" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="274" text-anchor="middle" fill="#ffffff">pdu04 (U12)</text>
</a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U23</title>
<rect x="43" y="481" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="494" text-anchor="middle" fill="#ffffff">sw01 (U23)</text>
</a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="43" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
</a>
<a href="/hardware/pp02/">
<title>pp02 · patch-panel · in-use · cluster: — · U25</title>
<rect x="43" y="521" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="534" text-anchor="middle" fill="#ffffff">pp02 (U25)</text>
</a>
<a href="/hardware/pdu03/">
<title>pdu03 · pdu · in-use · cluster: — · U34</title>
<rect x="333" y="701" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="714" text-anchor="middle" fill="#ffffff">pdu03 (U34)</text>
</a>
<text x="42" y="1020" font-weight="bold">Legend</text>
<rect x="42" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="58" y="1038">patch-panel</text>
<rect x="147" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="163" y="1038">pdu</text>
<rect x="196" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="212" y="1038">server</text>
<rect x="266" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="282" y="1038">shelf</text>
<rect x="329" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="345" y="1038">switch</text>
<rect x="399" y="1028" width="12" height="12" fill="#edc948" stroke="#333"/>
<text x="415" y="1038">ups</text>
<rect x="42" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="58" y="1056">in-use</text>
<rect x="112" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="128" y="1056">staging</text>
<rect x="189" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="205" y="1056">broken</text>
<rect x="259" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="275" y="1056">spare</text>
</svg>

Before

Width:  |  Height:  |  Size: 24 KiB

View file

@ -1,538 +0,0 @@
# 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
<div class="rack-elevation">
<svg xmlns="http://www.w3.org/2000/svg" width="614" height="1068" viewBox="0 0 614 1068" style="max-width:100%;height:auto" font-family="sans-serif" font-size="11">
<rect width="614" height="1068" 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>
<text x="576" y="54" text-anchor="start" fill="#999">1</text>
<text x="576" y="74" text-anchor="start" fill="#999">2</text>
<text x="576" y="94" text-anchor="start" fill="#999">3</text>
<text x="576" y="114" text-anchor="start" fill="#999">4</text>
<text x="576" y="134" text-anchor="start" fill="#999">5</text>
<text x="576" y="154" text-anchor="start" fill="#999">6</text>
<text x="576" y="174" text-anchor="start" fill="#999">7</text>
<text x="576" y="194" text-anchor="start" fill="#999">8</text>
<text x="576" y="214" text-anchor="start" fill="#999">9</text>
<text x="576" y="234" text-anchor="start" fill="#999">10</text>
<text x="576" y="254" text-anchor="start" fill="#999">11</text>
<text x="576" y="274" text-anchor="start" fill="#999">12</text>
<text x="576" y="294" text-anchor="start" fill="#999">13</text>
<text x="576" y="314" text-anchor="start" fill="#999">14</text>
<text x="576" y="334" text-anchor="start" fill="#999">15</text>
<text x="576" y="354" text-anchor="start" fill="#999">16</text>
<text x="576" y="374" text-anchor="start" fill="#999">17</text>
<text x="576" y="394" text-anchor="start" fill="#999">18</text>
<text x="576" y="414" text-anchor="start" fill="#999">19</text>
<text x="576" y="434" text-anchor="start" fill="#999">20</text>
<text x="576" y="454" text-anchor="start" fill="#999">21</text>
<text x="576" y="474" text-anchor="start" fill="#999">22</text>
<text x="576" y="494" text-anchor="start" fill="#999">23</text>
<text x="576" y="514" text-anchor="start" fill="#999">24</text>
<text x="576" y="534" text-anchor="start" fill="#999">25</text>
<text x="576" y="554" text-anchor="start" fill="#999">26</text>
<text x="576" y="574" text-anchor="start" fill="#999">27</text>
<text x="576" y="594" text-anchor="start" fill="#999">28</text>
<text x="576" y="614" text-anchor="start" fill="#999">29</text>
<text x="576" y="634" text-anchor="start" fill="#999">30</text>
<text x="576" y="654" text-anchor="start" fill="#999">31</text>
<text x="576" y="674" text-anchor="start" fill="#999">32</text>
<text x="576" y="694" text-anchor="start" fill="#999">33</text>
<text x="576" y="714" text-anchor="start" fill="#999">34</text>
<text x="576" y="734" text-anchor="start" fill="#999">35</text>
<text x="576" y="754" text-anchor="start" fill="#999">36</text>
<text x="576" y="774" text-anchor="start" fill="#999">37</text>
<text x="576" y="794" text-anchor="start" fill="#999">38</text>
<text x="576" y="814" text-anchor="start" fill="#999">39</text>
<text x="576" y="834" text-anchor="start" fill="#999">40</text>
<text x="576" y="854" text-anchor="start" fill="#999">41</text>
<text x="576" y="874" text-anchor="start" fill="#999">42</text>
<text x="576" y="894" text-anchor="start" fill="#999">43</text>
<text x="576" y="914" text-anchor="start" fill="#999">44</text>
<text x="576" y="934" text-anchor="start" fill="#999">45</text>
<text x="576" y="954" text-anchor="start" fill="#999">46</text>
<text x="576" y="974" text-anchor="start" fill="#999">47</text>
<text x="576" y="994" text-anchor="start" fill="#999">48</text>
<rect x="42" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<rect x="332" y="40" width="240" height="960" fill="none" stroke="#999" stroke-width="1.5"/>
<a href="/hardware/srv01/">
<title>srv01 · server · in-use · cluster: tappaas · shf01/front/slot 1</title>
<rect x="43" y="761" width="118" height="192" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="861" text-anchor="middle" fill="#ffffff">srv01</text>
</a>
<a href="/hardware/srv02/">
<title>srv02 · server · staging · cluster: tappaas · shf01/front/slot 2</title>
<rect x="163" y="801" width="118" height="152" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="881" text-anchor="middle" fill="#ffffff">srv02</text>
</a>
<a href="/hardware/srv03/">
<title>srv03 · server · staging · cluster: tappaas · shf01/rear/slot 1</title>
<rect x="333" y="841" width="238" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="452" y="901" text-anchor="middle" fill="#ffffff">srv03</text>
</a>
<a href="/hardware/shf01/">
<title>shf01 · shelf · in-use · cluster: tappaas · U46</title>
<rect x="42" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="954" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="959" text-anchor="middle" fill="#333" font-size="9">shf01</text>
</a>
<a href="/hardware/srv07/">
<title>srv07 · server · staging · cluster: tappaas · shf02/front/slot 1</title>
<rect x="43" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="102" y="681" text-anchor="middle" fill="#ffffff">srv07</text>
</a>
<a href="/hardware/srv04/">
<title>srv04 · server · staging · cluster: — · shf02/front/slot 2</title>
<rect x="163" y="621" width="118" height="112" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="222" y="681" text-anchor="middle" fill="#ffffff">srv04</text>
</a>
<a href="/hardware/srv05/">
<title>srv05 · server · staging · cluster: — · shf02/rear/slot 1</title>
<rect x="333" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="392" y="671" text-anchor="middle" fill="#ffffff">srv05</text>
</a>
<a href="/hardware/srv06/">
<title>srv06 · server · staging · cluster: tappaas · shf02/rear/slot 2</title>
<rect x="453" y="601" width="118" height="132" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="512" y="671" text-anchor="middle" fill="#ffffff">srv06</text>
</a>
<a href="/hardware/shf02/">
<title>shf02 · shelf · in-use · cluster: — · U35</title>
<rect x="42" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="734" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="739" text-anchor="middle" fill="#333" font-size="9">shf02</text>
</a>
<a href="/hardware/sw07/">
<title>sw07 · switch · in-use · cluster: — · shf03/front/slot 1</title>
<rect x="43" y="461" width="118" height="12" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="102" y="471" text-anchor="middle" fill="#ffffff">sw07</text>
</a>
<a href="/hardware/sw06/">
<title>sw06 · switch · in-use · cluster: — · shf03/front/slot 2</title>
<rect x="163" y="461" width="118" height="12" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="222" y="471" text-anchor="middle" fill="#ffffff">sw06</text>
</a>
<a href="/hardware/shf03/">
<title>shf03 · shelf · in-use · cluster: — · U22</title>
<rect x="42" y="474" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="474" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="479" text-anchor="middle" fill="#333" font-size="9">shf03</text>
</a>
<a href="/hardware/shf04/">
<title>shf04 · shelf · in-use · cluster: — · U21</title>
<rect x="42" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="454" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="459" text-anchor="middle" fill="#333" font-size="9">shf04</text>
</a>
<a href="/hardware/shf05/">
<title>shf05 · shelf · in-use · cluster: — · U19</title>
<rect x="42" y="414" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<rect x="332" y="414" width="240" height="6" fill="#bab0ac" stroke="#333"/>
<text x="162" y="419" text-anchor="middle" fill="#333" font-size="9">shf05</text>
</a>
<a href="/hardware/pdu01/">
<title>pdu01 · pdu · in-use · cluster: — · U1</title>
<rect x="333" y="41" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="54" text-anchor="middle" fill="#ffffff">pdu01 (U1)</text>
</a>
<a href="/hardware/ups02/">
<title>ups02 · ups · staging · cluster: — · U3</title>
<rect x="43" y="81" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="94" text-anchor="middle" fill="#ffffff">ups02 (U3)</text>
</a>
<a href="/hardware/ups01/">
<title>ups01 · ups · staging · cluster: — · U4</title>
<rect x="43" y="101" width="238" height="18" rx="3" fill="#edc948" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="114" text-anchor="middle" fill="#ffffff">ups01 (U4)</text>
</a>
<a href="/hardware/sw04/">
<title>sw04 · switch · staging · cluster: — · U5</title>
<rect x="43" y="121" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="134" text-anchor="middle" fill="#ffffff">sw04 (U5)</text>
</a>
<a href="/hardware/nas01/">
<title>nas01 · server · staging · cluster: tappaas · U6</title>
<rect x="43" y="141" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="154" text-anchor="middle" fill="#ffffff">nas01 (U6)</text>
</a>
<a href="/hardware/nas02/">
<title>nas02 · server · staging · cluster: tappaas · U7</title>
<rect x="43" y="161" width="238" height="18" rx="3" fill="#4c78a8" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="174" text-anchor="middle" fill="#ffffff">nas02 (U7)</text>
</a>
<a href="/hardware/sw05/">
<title>sw05 · switch · staging · cluster: — · U8</title>
<rect x="43" y="181" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="194" text-anchor="middle" fill="#ffffff">sw05 (U8)</text>
</a>
<a href="/hardware/sw02/">
<title>sw02 · switch · staging · cluster: — · U9</title>
<rect x="43" y="201" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="214" text-anchor="middle" fill="#ffffff">sw02 (U9)</text>
</a>
<a href="/hardware/sw03/">
<title>sw03 · switch · staging · cluster: — · U10</title>
<rect x="43" y="221" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="162" y="234" text-anchor="middle" fill="#ffffff">sw03 (U10)</text>
</a>
<a href="/hardware/pdu02/">
<title>pdu02 · pdu · in-use · cluster: — · U12</title>
<rect x="333" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="274" text-anchor="middle" fill="#ffffff">pdu02 (U12)</text>
</a>
<a href="/hardware/pdu04/">
<title>pdu04 · pdu · in-use · cluster: — · U12</title>
<rect x="43" y="261" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="274" text-anchor="middle" fill="#ffffff">pdu04 (U12)</text>
</a>
<a href="/hardware/sw01/">
<title>sw01 · switch · in-use · cluster: — · U23</title>
<rect x="43" y="481" width="238" height="18" rx="3" fill="#59a14f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="494" text-anchor="middle" fill="#ffffff">sw01 (U23)</text>
</a>
<a href="/hardware/pp01/">
<title>pp01 · patch-panel · in-use · cluster: — · U24</title>
<rect x="43" y="501" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="514" text-anchor="middle" fill="#ffffff">pp01 (U24)</text>
</a>
<a href="/hardware/pp02/">
<title>pp02 · patch-panel · in-use · cluster: — · U25</title>
<rect x="43" y="521" width="238" height="18" rx="3" fill="#9c755f" stroke="#333333" stroke-width="1.5"/>
<text x="162" y="534" text-anchor="middle" fill="#ffffff">pp02 (U25)</text>
</a>
<a href="/hardware/pdu03/">
<title>pdu03 · pdu · in-use · cluster: — · U34</title>
<rect x="333" y="701" width="238" height="18" rx="3" fill="#e15759" stroke="#333333" stroke-width="1.5"/>
<text x="452" y="714" text-anchor="middle" fill="#ffffff">pdu03 (U34)</text>
</a>
<text x="42" y="1020" font-weight="bold">Legend</text>
<rect x="42" y="1028" width="12" height="12" fill="#9c755f" stroke="#333"/>
<text x="58" y="1038">patch-panel</text>
<rect x="147" y="1028" width="12" height="12" fill="#e15759" stroke="#333"/>
<text x="163" y="1038">pdu</text>
<rect x="196" y="1028" width="12" height="12" fill="#4c78a8" stroke="#333"/>
<text x="212" y="1038">server</text>
<rect x="266" y="1028" width="12" height="12" fill="#bab0ac" stroke="#333"/>
<text x="282" y="1038">shelf</text>
<rect x="329" y="1028" width="12" height="12" fill="#59a14f" stroke="#333"/>
<text x="345" y="1038">switch</text>
<rect x="399" y="1028" width="12" height="12" fill="#edc948" stroke="#333"/>
<text x="415" y="1038">ups</text>
<rect x="42" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5"/>
<text x="58" y="1056">in-use</text>
<rect x="112" y="1046" width="12" height="12" fill="#ffffff" stroke="#333333" stroke-width="1.5" stroke-dasharray="4 2"/>
<text x="128" y="1056">staging</text>
<rect x="189" y="1046" width="12" height="12" fill="#ffffff" stroke="#e15759" stroke-width="3"/>
<text x="205" y="1056">broken</text>
<rect x="259" y="1046" width="12" height="12" fill="#ffffff" stroke="#bbbbbb" stroke-width="1.5"/>
<text x="275" y="1056">spare</text>
</svg>
</div>
[Download SVG](rack01-elevation.svg)
## Power
```mermaid
flowchart LR
pdu01["pdu01<br/>9 outlets"]
pdu02["pdu02<br/>5 outlets"]
pdu03["pdu03<br/>11 outlets"]
pdu04["pdu04<br/>5 outlets"]
srv01["srv01"]
srv02["srv02"]
srv03["srv03"]
srv04["srv04"]
srv05["srv05"]
srv06["srv06"]
srv07["srv07"]
nas01["nas01"]
nas02["nas02"]
pdu01 -->|outlet 1| nas01
pdu01 -->|outlet 1| nas02
pdu01 -->|outlet 1| srv01
pdu01 -->|outlet 1| srv06
pdu01 -->|outlet 1| srv07
pdu01 -->|outlet 2| srv02
pdu01 -->|outlet 3| srv03
pdu01 -->|outlet 4| srv04
pdu01 -->|outlet 5| srv05
pdu02 -->|outlet 1| nas01
pdu02 -->|outlet 1| nas02
pdu02 -->|outlet 1| srv06
pdu02 -->|outlet 1| srv07
style nas01 fill:#4c78a8,stroke:#333,color:#ffffff
click nas01 "/hardware/nas01/"
style nas02 fill:#4c78a8,stroke:#333,color:#ffffff
click nas02 "/hardware/nas02/"
style pdu01 fill:#e15759,stroke:#333,color:#ffffff
click pdu01 "/hardware/pdu01/"
style pdu02 fill:#e15759,stroke:#333,color:#ffffff
click pdu02 "/hardware/pdu02/"
style pdu03 fill:#e15759,stroke:#333,color:#ffffff
click pdu03 "/hardware/pdu03/"
style pdu04 fill:#e15759,stroke:#333,color:#ffffff
click pdu04 "/hardware/pdu04/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
style srv06 fill:#4c78a8,stroke:#333,color:#ffffff
click srv06 "/hardware/srv06/"
style srv07 fill:#4c78a8,stroke:#333,color:#ffffff
click srv07 "/hardware/srv07/"
```
## Network
```mermaid
flowchart LR
pp01["pp01<br/>patch-panel"]
pp02["pp02<br/>patch-panel"]
srv01["srv01"]
srv02["srv02"]
srv03["srv03"]
srv04["srv04"]
srv05["srv05"]
srv06["srv06"]
srv07["srv07"]
sw01["sw01<br/>switch"]
sw06["sw06<br/>switch"]
sw07["sw07<br/>switch"]
wan01["wan01"]
pp01 -->|1 → p1| wan01
pp01 -->|4 → p3| sw06
pp02 -->|1 → p1| pp01
pp02 -->|2 → p1| sw01
pp02 -->|3 → p2| sw01
pp02 -->|4 → p3| sw01
pp02 -->|5 → p4| sw01
pp02 -->|6 → p5| sw01
pp02 -->|7 → p6| sw01
pp02 -->|8 → p4| sw06
srv01 -->|eth0 → p1 · 1G| pp02
srv01 -->|eth1 → p2 · 1G| pp02
srv02 -->|eth0 → p3 · 1G| pp02
srv03 -->|eth0 → p4 · 1G| pp02
srv04 -->|eth0 → p5 · 1G| pp02
srv05 -->|eth0 → p6 · 1G| pp02
srv06 -->|eth0 → p7 · 1G| pp02
srv07 -->|eth0 → p8 · 1G| pp02
sw01 -->|7 → p1 · 1G| sw06
sw01 -->|8 → p3| pp01
sw07 -->|1 → p2 · 1G| sw06
style pp01 fill:#9c755f,stroke:#333,color:#ffffff
click pp01 "/hardware/pp01/"
style pp02 fill:#9c755f,stroke:#333,color:#ffffff
click pp02 "/hardware/pp02/"
style srv01 fill:#4c78a8,stroke:#333,color:#ffffff
click srv01 "/hardware/srv01/"
style srv02 fill:#4c78a8,stroke:#333,color:#ffffff
click srv02 "/hardware/srv02/"
style srv03 fill:#4c78a8,stroke:#333,color:#ffffff
click srv03 "/hardware/srv03/"
style srv04 fill:#4c78a8,stroke:#333,color:#ffffff
click srv04 "/hardware/srv04/"
style srv05 fill:#4c78a8,stroke:#333,color:#ffffff
click srv05 "/hardware/srv05/"
style srv06 fill:#4c78a8,stroke:#333,color:#ffffff
click srv06 "/hardware/srv06/"
style srv07 fill:#4c78a8,stroke:#333,color:#ffffff
click srv07 "/hardware/srv07/"
style sw01 fill:#59a14f,stroke:#333,color:#ffffff
click sw01 "/hardware/sw01/"
style sw06 fill:#59a14f,stroke:#333,color:#ffffff
click sw06 "/hardware/sw06/"
style sw07 fill:#59a14f,stroke:#333,color:#ffffff
click sw07 "/hardware/sw07/"
style wan01 fill:#888888,stroke:#333,color:#ffffff
```
## Occupancy
| U | Device | Kind | Face | Status |
|---|---|---|---|---|
| U1 | [pdu01](../../hardware/pdu01.md) | pdu | rear | in-use |
| U3 | [ups02](../../hardware/ups02.md) | ups | front | staging |
| U4 | [ups01](../../hardware/ups01.md) | ups | front | staging |
| U5 | [sw04](../../hardware/sw04.md) | switch | front | staging |
| U6 | [nas01](../../hardware/nas01.md) | server | front | staging |
| U7 | [nas02](../../hardware/nas02.md) | server | front | staging |
| U8 | [sw05](../../hardware/sw05.md) | switch | front | staging |
| U9 | [sw02](../../hardware/sw02.md) | switch | front | staging |
| U10 | [sw03](../../hardware/sw03.md) | switch | front | staging |
| U12 | [pdu02](../../hardware/pdu02.md) | pdu | rear | in-use |
| U12 | [pdu04](../../hardware/pdu04.md) | pdu | front | in-use |
| U19 | [shf05](../../hardware/shf05.md) | shelf | front | in-use |
| U21 | [shf04](../../hardware/shf04.md) | shelf | rear | in-use |
| U22 | [shf03](../../hardware/shf03.md) | shelf | front | in-use |
| U22 | [sw07](../../hardware/sw07.md) | switch | front · shf03/1 | in-use |
| U22 | [sw06](../../hardware/sw06.md) | switch | front · shf03/2 | in-use |
| U23 | [sw01](../../hardware/sw01.md) | switch | front | in-use |
| U24 | [pp01](../../hardware/pp01.md) | patch-panel | front | in-use |
| U25 | [pp02](../../hardware/pp02.md) | patch-panel | front | in-use |
| U34 | [pdu03](../../hardware/pdu03.md) | pdu | rear | in-use |
| U35 | [shf02](../../hardware/shf02.md) | shelf | both | in-use |
| U30–U35 | [srv07](../../hardware/srv07.md) | server | front · shf02/1 | staging |
| U30–U35 | [srv04](../../hardware/srv04.md) | server | front · shf02/2 | staging |
| U29–U35 | [srv05](../../hardware/srv05.md) | server | rear · shf02/1 | staging |
| U29–U35 | [srv06](../../hardware/srv06.md) | server | rear · shf02/2 | staging |
| U46 | [shf01](../../hardware/shf01.md) | shelf | both | in-use |
| U37–U46 | [srv01](../../hardware/srv01.md) | server | front · shf01/1 | in-use |
| U39–U46 | [srv02](../../hardware/srv02.md) | server | front · shf01/2 | staging |
| U41–U46 | [srv03](../../hardware/srv03.md) | server | rear · shf01/1 | staging |

View file

@ -0,0 +1,178 @@
---
marp: true
pagination: true
size: 16:9
---
<!--
Option 2 (larger canvas — NOT applied here): the `size:` frontmatter
only accepts sizes the active theme has declared via @size. The
bundled `default`/`gaia` themes only declare 16:9 (1280x720) and 4:3
(960x720). To get a 1920x1080 canvas you must register a custom
theme via marp.config.mjs / --theme that contains
@size fhd 1920px 1080px;
and then reference it as `size: fhd`. Inline <style> blocks cannot
declare theme metadata, so a one-file demo of option 2 isn't possible
without touching the build pipeline.
-->
<style>
/* Option 5: make each slide a query container so children can size
themselves relative to the slide (1cqh = 1% of slide height). */
section {
container-type: size;
font-size: clamp(0.9rem, 2.4cqh, 1.6rem);
}
section h1 { font-size: clamp(1.8rem, 5.5cqh, 3.6rem); }
section h2 { font-size: clamp(1.4rem, 4.2cqh, 2.8rem); }
section h3 { font-size: clamp(1.2rem, 3.4cqh, 2.2rem); }
/* Option 4: cap mermaid by BOTH width and height so tall diagrams
shrink to fit instead of overflowing the slide vertically.
85cqh = at most 85% of slide height, leaving room for the heading. */
.mermaid svg {
max-width: 100% !important;
max-height: 85cqh !important;
width: auto !important;
height: auto !important;
}
</style>
# Introduction
This is assorted notes on what could go into the MakerFLOSS lab
---
# Requirements
- A space to experiment with new software
- A place where software could be "test run" for some time
- A place where errors are not causing IP loss
- even if errors are real big !!
## More details
- Firewalled off from the production network
- Accessible from outside
- Potential for exposing services externally
---
## Constraints
- Cost conscious
- Support constant change
---
# Proposals
## Short term
A VPS in a (European) cloud with one public IP
---
## Midterm
Complement the VPS with some local hardware:
- Firewall with zones (VLANs, DNS/DHCP)
- Netbird access to services in Lab
- Switching infrastructure
- A primary "stable" Proxmox host
- A secondary experimentation machine
- A backup server
- Tunnel for external access via VPS public IP
---
### Basic Services in Lab
- Git: Forgejo
- ...
---
### Lab Diagram
```mermaid
graph LR
subgraph External
Internet[🌐 Internet]
VPS[FLOSS VPS<br/>88.99.32.236]
end
subgraph OrangeMaker["Orange Makerspace"]
OMFirewall[OrangeMaker Firewall]
ProdNet[Production Network]
end
subgraph FLOSSLab["MakerFLOSS Lab"]
Switch[Switch]
Proxmox1[LabZone 1<br/>Test Proxmox]
Proxmox2[LabZone 2<br/>Experimental]
subgraph TAPPaaS
FLOSSFirewall[MakerFLOSS Firewall<br/>DNS/DHCP/VLANs]
PreProd[Pre-production Zone]
Backup[Backup Server]
end
end
Internet --> VPS
Internet --> OMFirewall
VPS -.->|Tunnel| FLOSSFirewall
VPS -.->|Netbird| FLOSSFirewall
OMFirewall --> ProdNet
OMFirewall --> FLOSSFirewall
FLOSSFirewall --> Switch
FLOSSFirewall --> PreProd
Switch --> Proxmox1
Switch --> Proxmox2
Switch --> Backup
```
---
### TAPPaaS Diagram
```mermaid
graph TB
subgraph TAPPaaS
subgraph Firewall["Firewall"]
Zones[Zones]
Caddy[Caddy]
Certs[Certificates]
DHCPDNS[DHCP/DNS]
end
subgraph PreProd["Pre-Production"]
Proxmox[Proxmox]
Authentik[Authentik]
CICD[CI/CD]
Forgejo[Forgejo]
More[...]
end
subgraph BackupSrv["Backup"]
BackupService[PBS Backup Service]
end
end
Firewall --> PreProd
Firewall --> BackupSrv
```
---
## Long term
replace VPS with a direct IP pinhole access
replace the "stable" FLOSS services running on VPS with modules runing on "stable" machine locally
# Design of Mid term solution

View file

@ -0,0 +1,188 @@
---
marp: true
theme: gaia
class: invert
paginate: true
size: 16:9
---
<!--
Option 2 (larger canvas — NOT applied here): the `size:` directive
only accepts sizes the theme has declared via @size. Gaia ships only
16:9 (1280x720) and 4:3 (960x720). To get a 1920x1080 canvas you
must register a custom theme via marp.config.mjs / --theme containing
@size fhd 1920px 1080px;
and then set `size: fhd`. Inline <style> blocks cannot declare theme
metadata, so this can't be demoed in a single file.
-->
<style>
/* Option 5: each slide becomes a query container; tables size against
slide height (1cqh = 1% of slide height) instead of the root font. */
section {
container-type: size;
}
table { font-size: clamp(0.55rem, 1.9cqh, 1.1rem); }
th, td { padding: 0.25em 0.6em; }
section.dense table { font-size: clamp(0.45rem, 1.4cqh, 0.9rem); }
section.dense th, section.dense td { padding: 0.2em 0.5em; }
/* Option 4: cap mermaid by BOTH dimensions so tall diagrams shrink. */
.mermaid svg {
max-width: 100% !important;
max-height: 80cqh !important;
width: auto !important;
height: auto !important;
}
</style>
# Messaging Without Big Tech
### Free & Open Alternatives to WhatsApp and Messenger
MakerFLOSS · May 2026
---
## Why Are We Here?
Most people use WhatsApp, Messenger, or iMessage.
- **WhatsApp** — owned by Meta; metadata harvested
- **Messenger** — no E2EE by default in groups; ad tracking
- **Telegram** — _not_ E2EE by default; closed server
- **iMessage** — Apple lock-in; no Android or Linux
These apps are _convenient_ — but the cost is your data.
---
## Wish-list
| Property | Why it matters |
| ------------------------- | ------------------------------------------- |
| End-to-end encryption | Only sender and recipient can read messages |
| Open source | Anyone can audit the code |
| Self-hostable | You control the server and the data |
| No phone number required | Less identity linkage |
| Cross-platform | Linux, Android, iOS, Windows |
| Federated / decentralized | No single point of failure or control |
---
## The Landscape at a Glance
_All apps below support end-to-end encryption._
| App | Open source | Self-host | No phone# | Federation |
| -------------------- | ----------- | --------- | --------- | ---------- |
| **Signal** | Partial | ✗ | ✗ | ✗ |
| **Matrix / Element** | ✓ | ✓ | ✓ | ✓ |
| **XMPP + OMEMO** | ✓ | ✓ | ✓ | ✓ |
| **Briar** | ✓ | N/A | ✓ | N/A |
| **Session** | ✓ | Partial | ✓ | Partial |
---
## Signal — The Gold Standard for E2EE
Non-profit Signal Foundation. The Signal Protocol powers WhatsApp, Google RCS, and Messenger secret chats.
**Pros**
- Simplest UX — works like a normal messaging app
- Audited, battle-tested cryptography; no ads, no tracking
**Cons**
- Phone number required — links identity to account
- Centralized — Signal's servers, Signal's rules
---
## Signal — Under the Hood
```mermaid
sequenceDiagram
participant A as Alice's phone
participant S as Signal Server
participant B as Bob's phone
A->>S: encrypted message
Note over S: sees: who, when, how often<br/>does NOT see: content
S->>B: encrypted message
Note over B: decrypts with private key
```
Metadata still matters — [Signal subpoena responses](https://signal.org/bigbrother/)
---
## Matrix — The Federated Open Standard
Matrix is a **protocol**, not an app — like email for real-time chat.
```mermaid
graph LR
EC[Element client] --> YH[your homeserver]
YH <-->|federation| OH[another homeserver]
FC[FluffyChat] --> OH
```
- **Servers**: Synapse (Python), Conduit (Rust), Dendrite (Go)
- **Clients**: Element, FluffyChat, Cinny, Fractal, Nheko
- **Bridges**: WhatsApp, Telegram, Signal, IRC, Discord…
---
## Matrix — Pros and Cons
**Pros**
- Fully open source, top to bottom
- Self-host your server — you own your data
- Federated — no single company controls the network
- Bridges consolidate all your chats in one place
**Cons**
- E2EE key management is clunky (cross-signing, key backup)
- Synapse is resource-hungry (~1 GB RAM)
- The UX of Element is still maturing
---
## Matrix Bridges — Stay Connected During the Transition
A bridge relays messages between Matrix and another network — both ways.
| Bridge | Network | Notes |
| ------------------------- | ---------- | ------------------------------------------ |
| `mautrix-whatsapp` | WhatsApp | Puppeting — your real WA account |
| `mautrix-telegram` | Telegram | Puppeting — very stable |
| `mautrix-signal` | Signal | Fragile — Signal actively breaks 3rd-party |
| `meshtastic-matrix-relay` | Meshtastic | LoRa mesh ↔ Matrix — off-grid messaging |
**Catch:** Puppeting bridges hold your credentials. WhatsApp's ToS prohibits it — occasional bans occur.
---
## XMPP (Jabber)
The _original_ federated chat standard — 1999. Still alive and kicking.
- Extremely mature and lightweight
- E2EE via OMEMO
- Good clients: **Conversations** (Android), **Monal** (iOS/macOS), **Gajim** (desktop)
- Con: fragmented client quality; less beginner-friendly than Signal or Matrix
---
## Briar
Peer-to-peer messaging — _no server at all_.
- Works over Tor, local WiFi, or Bluetooth (offline!)
- Censorship-resistant by design
- Con: Android-first; no desktop client; both parties must be online to first connect
**For:** activists, disaster scenarios, high-censorship environments

View file

@ -1,184 +0,0 @@
---
marp: true
theme: gaia
class: invert
paginate: true
---
<style>
section { font-size: 25px; line-height: 1.28; }
h1 { font-size: 1.55em; margin-bottom: 0.3em; }
h3 { font-size: 1.05em; }
ul, ol { margin: 0.2em 0; }
li { margin: 0.12em 0; }
li > ul, li > ol { margin: 0.04em 0; }
pre { margin: 0.25em 0; }
pre code { font-size: 0.72em; line-height: 1.25; }
code { font-size: 0.82em; }
table { font-size: 0.62em; }
th, td { padding: 0.22em 0.55em; }
section.dense { font-size: 21px; }
section.dense table { font-size: 0.5em; }
.mermaid svg { max-width: 100% !important; height: auto !important; }
</style>
# Routing TaPPaaS through the VPS
### Split-horizon DNS · public exposure on a static IP
MakerFLOSS · June 2026 · _technical review_
---
## The goal
Publish selected **TaPPaaS** web services under `*.tappaas.makerfloss.eu`.
- **Public exposure** — A records point at the VPS static IP `88.99.32.236`,
so the cluster's services are reachable from anywhere.
- **Split-horizon DNS** — the _same_ hostname resolves to a **local** address
for internal clients, so they never round-trip to the VPS.
- **Isolation preserved** — nothing on the makerspace side egresses through
the VPS or reaches the homelab.
> We are not inventing anything — we clone a pattern already running.
---
## Building block: the proven `mf01` pattern
`mf01` already publishes `*.mf01.makerfloss.eu` exactly this way, live since
2026-06-09:
- TLS terminates on the **VPS** (wildcard cert, Gandi DNS-01).
- Plain HTTP rides the **`wg1`** WireGuard tunnel to an internal reverse proxy.
- The internal proxy routes by **Host** to the right container.
**TaPPaaS = the same shape**, with Caddy as the internal proxy instead of an
internal Traefik. Low risk, known gotchas already solved.
---
## External request flow
```mermaid
flowchart LR
C["Browser<br/>(off-site)"] -->|"https · *.tappaas<br/>DNS → 88.99.32.236"| T["VPS Traefik :443<br/>wildcard cert<br/>TLS ends here"]
T -->|"plain HTTP<br/>inside wg1"| K["FLOSSFirewall / Caddy<br/>10.13.0.9:80"]
K -->|"route by Host"| S["TaPPaaS service<br/>srv01 / srv02 / srv03"]
```
TLS terminates at the VPS; the backend hop is plain HTTP but **encrypted on the
wire inside WireGuard**.
---
## Internal flow — two views, one name
```mermaid
flowchart TD
N["&lt;svc&gt;.tappaas.makerfloss.eu"]
N -->|"public DNS (Gandi)"| V["VPS 88.99.32.236<br/>VPS wildcard cert"]
N -->|"internal DNS (FLOSSFirewall)"| L["Caddy local IP<br/>Caddy's own LE cert"]
```
A client only ever sees the cert for the IP it resolved — **VPS wildcard
externally, Caddy's own cert internally**. The two views never collide.
---
<!-- _class: invert dense -->
## Key decisions
| Decision | Choice | Why |
|---|---|---|
| Transport | New **`wg1`** WireGuard peer | Mirrors mf01; both ends self-owned; static hub-and-spoke |
| TLS (external) | **Terminate at the VPS** | Cert issuance while isolated kills passthrough; Gandi DNS-write key stays on the VPS; keeps edge routing/middleware |
| Naming | **Wildcard** `*.tappaas.makerfloss.eu` | One cert + one route; add services with zero VPS change; no apex clash (`forgejo.tappaas…` ≠ `forgejo.makerfloss.eu`) |
| Internal DNS | **FLOSSFirewall** (already runs DNS), cluster first | LAN view deferred — depends on third-party router |
---
## Components — VPS side
All additive in `AnsibleBaobabV4/host_vars/makerfloss.yml`, mirroring mf01:
1. **wg1 peer** — FLOSSFirewall, `allowed_ips: 10.13.0.9/32`.
2. **Wildcard cert** — add `tappaas.makerfloss.eu` + `*.tappaas` to
`traefik_wildcard_sets` → ACME anchor issues it via Gandi DNS-01.
3. **Catch-all route** — `tappaas-delegate.yml`:
`HostRegexp(...tappaas...)` → `http://10.13.0.9:80`, `passHostHeader: true`.
4. **Public DNS** — `tappaas` + `*.tappaas` A → `88.99.32.236`.
After this, **new services need zero VPS change** — exposure is decided at Caddy.
---
## Components — TaPPaaS / Caddy side
1. **WireGuard client** — peer to the VPS hub, interface `10.13.0.9/32`,
`AllowedIPs = 10.13.0.0/24` only → **split-tunnel** (no general egress
through the VPS).
2. **Caddy plain-HTTP backend** — Host-routed listener on the wg interface
(`10.13.0.9:80`), **HTTPS-redirect OFF**. Caddy keeps serving `:443` with its
own certs to internal clients, unchanged.
3. **Firewall** — allow `tcp/80` **only from `10.13.0.1`** (the VPS) on the wg
interface.
---
## Phasing — VPS edge (steps 1–3)
1. **Tunnel** — FLOSSFirewall up as `wg1` peer; ping
`10.13.0.1 ↔ 10.13.0.9`.
2. **Caddy backend** — from the VPS,
`curl -H 'Host: …tappaas…' 10.13.0.9:80`.
3. **VPS edge** — add cert + route + DNS; off-site
`curl https://<svc>.tappaas.makerfloss.eu` returns a valid cert.
---
## Phasing — internal & later (steps 4–5)
4. **Internal DNS** — add the `*.tappaas` override on the FLOSSFirewall; a
cluster node resolves to Caddy's local IP and gets Caddy's own cert (no
VPS round-trip).
5. **(Later)** makerspace LAN view — conditional-forward + firewall pinhole
on the OrangeMakers router.
---
## Isolation — the hard requirement
- **Split-tunnel** (`AllowedIPs = 10.13.0.0/24`) — the cluster never egresses
through the VPS; nothing on `wg1` reaches the makerspace or homelab.
- VPS→Caddy locked to `tcp/80 from 10.13.0.1` on the wg interface.
- Backend hop encrypted inside WireGuard.
- **No public-DNS-write credential leaves the VPS** — the reason TLS terminates
there, not via passthrough.
---
## Risks & open items
- **Caddy redirect** — the wg-interface listener must _not_ force HTTPS-redirect,
or the VPS hits a loop (mf01's known gotcha).
- **YAML safety** — a syntax error in `tappaas-delegate.yml` breaks the whole
VPS file provider; keep the regex single-quoted.
- **`10.13.0.9` free?** — confirm before assigning.
- **TaPPaaS config repo TBD** — the FLOSSFirewall/Caddy/DNS config home is not
yet identified.
- **Phase 5** depends on OrangeMakers router cooperation.
---
## Summary & next steps
- Reuse the **proven mf01 pattern** — terminate TLS at the VPS, proxy over
`wg1` to TaPPaaS Caddy, split-horizon DNS for the internal view.
- VPS side is a small, additive, **zero-new-tech** change.
- **Next:** identify the TaPPaaS config repo, then write the implementation plan
and execute phases 1–4.
_Design: `MakerFLOSS_Troubleshooting/docs/superpowers/specs/2026-06-28-tappaas-vps-publishing-design.md`_

View file

@ -1,352 +0,0 @@
---
marp: true
theme: gaia
class: invert
paginate: true
---
<style>
table { font-size: 0.65em; }
th, td { padding: 0.25em 0.6em; }
section.dense table { font-size: 0.5em; }
section.dense th, section.dense td { padding: 0.2em 0.5em; }
</style>
# VPN Without Vendor Lock-in
### FLOSS Solutions for Secure Networking
MakerFLOSS · June 2026
---
## Why VPNs?
**Remote Access** — Securely connect to your home/office network from anywhere
**Site-to-Site** — Link multiple locations into one virtual network
**Zero Trust** — Replace traditional perimeter security with identity-based access
**Privacy** — Encrypt traffic on untrusted networks
---
## Traditional vs Modern VPNs
| Aspect | Traditional (IPSec, OpenVPN) | Modern (WireGuard-based) |
|--------|------------------------------|--------------------------|
| Codebase | 100k+ lines | ~4,000 lines |
| Speed | Good | Excellent |
| Configuration | Complex | Simple |
| Cryptography | Configurable (risk) | Fixed, modern |
| NAT traversal | Tricky | Built-in (UDP) |
| Battery/CPU | Higher overhead | Minimal |
WireGuard changed everything in 2020 when it was merged into Linux kernel.
---
## The Landscape at a Glance
| Solution | Type | Self-host | Fully FLOSS | NAT punch | UI |
|----------|------|-----------|-------------|-----------|-----|
| **WireGuard** | Protocol | N/A | ✓ | Manual | ✗ |
| **Pangolin** | Reverse proxy | ✓ | ✓ | Via Gerbil | ✓ |
| **Tailscale** | Mesh VPN | Partial | ✗ | DERP | ✓ |
| **Netbird** | Mesh VPN | ✓ | ✓ | STUN/TURN | ✓ |
---
## NAT Traversal Techniques
**Gerbil** (Pangolin)
Public-facing reverse proxy that accepts incoming connections and forwards them through WireGuard tunnels to internal Newt agents. Clients connect *out* to Gerbil.
**DERP** (Tailscale)
Designated Encrypted Relay for Packets — Tailscale's proprietary relay servers. Used when direct peer-to-peer fails. Traffic is encrypted end-to-end; relays see only ciphertext.
---
## NAT Traversal Techniques
**STUN/TURN** (Netbird, standard)
- **STUN**: Discovers your public IP and port mapping — enables direct connections
- **TURN**: Relay fallback when direct connection impossible (strict NAT/firewall)
---
## WireGuard — The Foundation
WireGuard is a **protocol**, not a product. It's the building block the others use.
**Key properties:**
- In-kernel since Linux 5.6 (2020)
- ~4,000 lines of code — auditable
- Cryptographically opinionated: Curve25519, ChaCha20, Poly1305
- Silent by default — no response to unauthenticated packets
- Roaming — endpoints can change IP seamlessly
---
## WireGuard — How It Works
```mermaid
graph LR
subgraph "Peer A (10.0.0.1)"
A[wg0 interface]
end
subgraph "Peer B (10.0.0.2)"
B[wg0 interface]
end
A <-->|"encrypted UDP"| B
```
Each peer has:
- A **private key** (never leaves the device)
- A **public key** (shared with peers)
- An **allowed IPs** list (what traffic goes through the tunnel)
No central server required — but someone has to distribute configs.
---
## WireGuard — Pros and Cons
**Pros**
- Blazing fast, low latency
- Simple config files
- Kernel-level performance
- Battle-tested cryptography
**Cons**
- No built-in key distribution
- No NAT traversal coordination
- No access control policies
- No management UI
**Best for:** sysadmins who want full control, site-to-site links
---
## Pangolin — Self-Hosted Reverse Proxy
Pangolin is a **reverse proxy** and tunneling solution, not a traditional VPN.
**Architecture:**
- **Pangolin** — Central server with web UI and proxy
- **Gerbil** — Public-facing proxy (handles NAT traversal)
- **Newt** — Agent on each client (creates WireGuard tunnel)
**Use case:** Expose internal services to the internet securely without opening ports.
---
## Pangolin — Architecture
```mermaid
graph TB
Internet[Internet] --> Gerbil[Gerbil Proxy]
Gerbil --> Pangolin[Pangolin Server]
Pangolin --> Newt1[Newt Agent]
Pangolin --> Newt2[Newt Agent]
Newt1 --> Service1[Internal Service]
Newt2 --> Service2[Internal Service]
```
Traffic flows: Internet → Gerbil → Pangolin → Newt → Your service
No port forwarding needed on the client side.
---
## Pangolin — Pros and Cons
**Pros**
- Fully self-hosted and FLOSS (Apache 2.0)
- Web UI for managing sites and users
- Automatic HTTPS via Let's Encrypt
- Works behind any NAT
- SSO integration (OIDC)
**Cons**
- Not a mesh VPN — hub-and-spoke only
- Relatively new project
- Requires a public-facing server
**Best for:** exposing self-hosted services, homelab access
---
## Tailscale — The Polished Option
Tailscale builds a **mesh VPN** on top of WireGuard with zero configuration.
**How it works:**
- Coordination server distributes keys and handles NAT traversal
- Devices connect directly when possible (peer-to-peer)
- Falls back to DERP relays when direct connection fails
- MagicDNS provides automatic DNS for all devices
---
## Tailscale — Architecture
```mermaid
graph TB
Coord[Coordination Server] -.->|key exchange| A
Coord -.->|key exchange| B
Coord -.->|key exchange| C
A[Device A] <-->|"direct WireGuard"| B[Device B]
A <-->|"via DERP relay"| C[Device C]
DERP[DERP Relay] --> C
```
Direct connections when possible, relayed when behind strict NAT.
---
## Tailscale — Pros and Cons
**Pros**
- Zero-config setup — just install and sign in
- Excellent NAT traversal
- Cross-platform (Linux, macOS, Windows, iOS, Android)
- MagicDNS and HTTPS certificates
- ACLs and SSO
**Cons**
- Coordination server is **not open source**
- Free tier limited; business features require subscription
- Vendor lock-in concern
**Alternative:** Headscale — FLOSS coordination server (community project)
---
## Netbird — Self-Hosted Mesh VPN
Netbird is a **fully FLOSS** alternative to Tailscale with self-hosting support.
**Components:**
- **Management Server** — handles key distribution, ACLs
- **Signal Server** — coordinates peer connections
- **STUN/TURN** — NAT traversal (coturn)
- **Netbird Agent** — runs on each device
---
## Netbird — Architecture
```mermaid
graph TB
Mgmt[Management Server] -.->|policies, keys| A
Mgmt -.->|policies, keys| B
Signal[Signal Server] -.->|peer discovery| A
Signal -.->|peer discovery| B
A[Device A] <-->|"direct WireGuard"| B[Device B]
TURN[TURN Relay] -.->|fallback| A
```
Self-host everything or use their managed service.
---
## Netbird — Pros and Cons
**Pros**
- Fully FLOSS (BSD-3-Clause)
- Self-hostable control plane
- Web UI for management
- SSO integration (OIDC, SAML)
- Network policies and ACLs
- **Built-in reverse proxy** (v0.65+) — expose services publicly like Pangolin
- Active development
**Cons**
- More complex to self-host than Tailscale to use
- Younger project than Tailscale
- Smaller community
**Best for:** organizations wanting Tailscale-like UX with full control
---
<!-- _class: dense -->
## Detailed Comparison
| Feature | WireGuard | Pangolin | Tailscale | Netbird |
|---------|-----------|----------|-----------|---------|
| **License** | GPL | Apache 2.0 | Proprietary* | BSD-3 |
| **Self-host control plane** | N/A | ✓ | Via Headscale | ✓ |
| **Mesh networking** | Manual | ✗ | ✓ | ✓ |
| **NAT traversal** | Manual | ✓ (Gerbil) | ✓ (DERP) | ✓ (TURN) |
| **Web UI** | ✗ | ✓ | ✓ | ✓ |
| **SSO (OIDC/SAML)** | ✗ | ✓ | ✓ | ✓ |
| **ACLs / Policies** | ✗ | ✓ | ✓ | ✓ |
| **Reverse proxy mode** | ✗ | ✓ | ✗ | ✓ (v0.65+) |
*Tailscale clients are open source, coordination server is not.
---
## Which Should You Choose?
**WireGuard directly** — Full control, simple site-to-site, technical users
**Pangolin** — Expose services publicly, homelab, reverse proxy use case
**Tailscale** — Easiest setup, don't mind some vendor dependency
**Tailscale + Headscale** — Tailscale UX with self-hosted control plane
**Netbird** — Full FLOSS, mesh VPN + reverse proxy, organization with SSO needs
---
## MakerFLOSS Lab Context
From our lab design, we plan to use **Netbird** for:
- Remote access to lab services from outside
- Connecting VPS to local infrastructure via tunnel
- Zero-trust access to pre-production zone
```
VPS -.->|Netbird| FLOSSFirewall
```
Self-hosted on our infrastructure, integrated with Authentik for SSO.
---
## Resources
| Resource | Link |
|----------|------|
| WireGuard | [wireguard.com](https://www.wireguard.com) |
| Pangolin | [github.com/fosrl/pangolin](https://github.com/fosrl/pangolin) |
| Tailscale | [tailscale.com](https://tailscale.com) |
| Headscale | [github.com/juanfont/headscale](https://github.com/juanfont/headscale) |
| Netbird | [netbird.io](https://netbird.io) |
| Netbird GitHub | [github.com/netbirdio/netbird](https://github.com/netbirdio/netbird) |
---
# Questions?
_Slides made with [Marp](https://marp.app)_

View file

@ -1,93 +0,0 @@
---
title: Project ideas
---
# Project ideas
Things we could build, fix, or figure out at a MakerFLOSS jam session. This is a
**menu, not a backlog** — nothing here is promised to anyone, and picking
something up is as simple as putting your name in the Owner column.
!!! tip "Add your own idea"
Edit this page in Forgejo (pencil icon, top right) or send a pull request.
See [Contributing](#contributing) at the bottom.
## Status legend
| Status | Meaning |
|---|---|
| 💡 **Idea** | Proposed, nobody has started. Up for grabs. |
| 🔨 **In progress** | Somebody is actively working on it. |
| ✅ **Done** | Finished — usually with a page on this site to show for it. |
| ⏸️ **Parked** | Deliberately on hold. Reason noted in the row. |
An empty **Owner** means the idea is unclaimed. Claim one by adding your name.
## Lab & cluster foundations
Getting the lab clusters to a state where we can actually build on them.
| # | Idea | Done when… | Status | Owner |
|---|---|---|---|---|
| 1 | **Ansible control node on lab cluster 1** | A working control node exists from which the lab cluster can be configured declaratively. | 💡 Idea | |
| 2 | **User management for lab cluster 1** | We have decided on and set up an approach to accounts and access on the lab cluster. | 🔨 In progress | Holger |
| 3 | **Remote power on/off of rack equipment** | Rack hardware can be power-cycled without being physically in the room. | 💡 Idea | |
| 4 | **UPS strategy** | We have a decision: do we need a UPS, can we cover it with gear we already own, and should the clusters run networked NUT so hosts shut down cleanly? | 💡 Idea | |
| 5 | **Lab monitoring / dashboard** | One place to see what is up and what is down across the [hardware](hardware/index.md) we have. | 💡 Idea | |
## Running things on TaPPaaS (production)
The production cluster. Careful, deliberate work — see the
[house rules](house-rules.md): mistakes belong in the labs, not here.
| # | Idea | Done when… | Status | Owner |
|---|---|---|---|---|
| 6 | **Guide: deploy a Docker container on TaPPaaS** | A reproducible walk-through in `docs/guides/` that a new maker can follow end to end. | 💡 Idea | |
| 7 | **Migrate Klaus' display project to TaPPaaS** | Evaluated first — then migrated if it fits. | 💡 Idea | |
| 8 | **Host Bo's QR-box-content project on TaPPaaS** | The project runs on TaPPaaS, including the DNS its QR codes depend on. | 💡 Idea | |
| 9 | **Backup & restore story for MakerFLOSS services** | Forgejo and the docs site have a documented, *tested* restore path. | 💡 Idea | |
## Tools for Orange Makerspace
MakerFLOSS is not the Orange Makerspace IT setup — but we help out where we can.
| # | Idea | Done when… | Status | Owner |
|---|---|---|---|---|
| 10 | **Event management system** | OM events are organised in a system we host. Decided: we are **building** it rather than adopting an existing tool. | 🔨 In progress | Marcus |
| 11 | **Todo-gizmo for OM** | A shared task system with both an online part and a physical part in the makerspace (status board / display). | 💡 Idea | |
| 12 | **Newsletter service** | OM can send a newsletter from a system we host, including content ingestion and managed recipient lists. | 🔨 In progress | Tais |
## Documentation & housekeeping
Unglamorous, high-value, and easy to pick up for an evening.
| # | Idea | Done when… | Status | Owner |
|---|---|---|---|---|
| 13 | **Nicer hardware labels** | Every device carries a printed label matching the [naming scheme](guides/hardware-naming-scheme.md). | 💡 Idea | |
| 14 | **Complete hardware specs on every host page** | Every device in [Hardware](hardware/index.md) has full frontmatter, with network details (ports, VLANs, patch-panel positions) double-checked. | 💡 Idea | |
| 15 | **Follow up on a dedicated ISP line** | We have a decision and a quote. | 💡 Idea | |
| 16 | **"First 30 minutes" onboarding page** | A new maker can get a Forgejo account, clone the repo, and land their first change by following one page. | 💡 Idea | |
## Contributing
Anyone can add an idea, claim one, or move a status.
1. **Get a Forgejo account** at [forgejo.makerfloss.eu](https://forgejo.makerfloss.eu)
and ask in the MakerFLOSS Matrix room for contributor access to the repo.
2. **Small change?** Use the pencil icon at the top of this page — it opens the
file straight in Forgejo's editor.
3. **Bigger change?** Clone and work on a branch:
```bash
git clone ssh://git@forgejo.makerfloss.eu:7577/sjat/MakerFLOSS.git
cd MakerFLOSS
git switch -c my-idea
# edit docs/project-ideas.md
git commit -am "docs(ideas): add <your idea>"
git push -u origin my-idea
```
Then open a pull request. Editing *this* page needs no extra build steps —
but if you touch anything under `docs/hardware/`, run `make docs-index`
first and commit the regenerated files (see
[Editing the hardware docs](guides/editing-hardware-docs.md)).

View file

@ -1,14 +0,0 @@
---
name: docs
kind: static-site
status: in-use
host: makerfloss.eu
url: https://docs.makerfloss.eu
upstream: https://www.mkdocs.org
tech: MkDocs Material
tls: letsencrypt
---
## Notes
Public-facing documentation site. Built from this repo's `docs/` tree on every push to `main` by the Forgejo Actions runner (`.forgejo/workflows/docs.yml`), then rsynced to the VPS and served by nginx behind Traefik.

View file

@ -1,14 +0,0 @@
---
name: forgejo
kind: web-app
status: in-use
host: makerfloss.eu
url: https://forgejo.makerfloss.eu
upstream: https://codeberg.org/forgejo/forgejo
tech: Go
tls: letsencrypt
---
## Notes
Self-hosted git forge. Hosts this repository similar MakerFLOSS projects. SSH access on port 7577 (`ssh://git@forgejo.makerfloss.eu:7577/<user>/<repo>.git`). Also runs the Forgejo Actions runner that builds [docs](docs.md) and [slides](slides.md) on push.

View file

@ -1,12 +0,0 @@
---
name: gandi-dns
kind: dns
status: in-use
host: Gandi.net (external)
url: https://www.gandi.net
tech: Gandi LiveDNS API
---
## Notes
External DNS provider for the `makerfloss.eu` zone. Records are managed declaratively from the [AnsibleBaobabV4](https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4) project (`play_dns.yml --limit makerfloss`); **never edit DNS records directly in the Gandi web UI** — Ansible will overwrite them on the next run.

View file

@ -1,35 +0,0 @@
# Services Overview
_Auto-generated from `docs/services/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
## DNS
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [gandi-dns](gandi-dns.md) | [www.gandi.net](https://www.gandi.net) | Gandi.net (external) | Gandi LiveDNS API | in-use |
## Libraries
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [mermaid](mermaid.md) | [cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs](https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs) | CDN (jsdelivr) | JavaScript (ESM) | in-use |
## Slide builders
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [marp](marp.md) | | CI runner (Forgejo Actions) | Node.js (marp-cli) / Docker | in-use |
## Static sites
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [docs](docs.md) | [docs.makerfloss.eu](https://docs.makerfloss.eu) | makerfloss.eu | MkDocs Material | in-use |
| [slides](slides.md) | [slides.makerfloss.eu](https://slides.makerfloss.eu) | makerfloss.eu | Marp + Mermaid.js | in-use |
## Web applications
| Name | URL | Host | Tech | Status |
|---|---|---|---|---|
| [forgejo](forgejo.md) | [forgejo.makerfloss.eu](https://forgejo.makerfloss.eu) | makerfloss.eu | Go | in-use |
| [laser](laser.md) | [laser.makerfloss.eu](https://laser.makerfloss.eu) | makerfloss.eu | Python (Flask + Gunicorn) | staging |

View file

@ -1,37 +0,0 @@
---
name: laser
kind: web-app
status: staging
host: makerfloss.eu
url: https://laser.makerfloss.eu
upstream: https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4
tech: Python (Flask + Gunicorn)
tls: letsencrypt
---
## Notes
Course site for the laser cutting intro course ("Introkursus til lille laser"),
in Danish. Serves the course materials (slides, plancher, worked examples) for
download and takes participant file uploads during the course.
Behind Traefik basicAuth — the credentials are handed out in class. Uploads are
restricted to laser-relevant formats (SVG, DXF, PDF, common raster, archives),
capped at 20 MB, and stored as `<participant>_<timestamp>_<filename>` under
`/srv/laser_course/uploads`.
Deployed from the `baobab.laser-course` role in AnsibleBaobabV4 (the app source
and the course materials both live in that role):
```bash
ansible-playbook play_containers.yml --limit makerfloss -t laser-course
```
Two things worth knowing before a course:
- **Uploads are not backed up.** `backup_enabled` is false on this VPS, and
`laser_course_clean_uploads_on_deploy` is set to false here so that an
unrelated deploy does not delete them. Collect anything worth keeping and
clear the directory by hand between courses.
- The site moved here from `laser.baobab.band` on the home estate in August
2026; `laser.makerfloss.eu` is the canonical address.

View file

@ -1,12 +0,0 @@
---
name: marp
kind: slide-builder
status: in-use
host: CI runner (Forgejo Actions)
upstream: https://marp.app
tech: Node.js (marp-cli) / Docker
---
## Notes
Markdown-to-HTML slide compiler. Invoked by `build-slides.sh` against every `docs/presentations/*.md` (and any other `.md` with `marp: true` in frontmatter), either via a local `marp` binary or the `marpteam/marp-cli` Docker image. Output feeds the [slides](slides.md) site. See [`notes/dev/marp-mermaid-setup.md`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/notes/dev/marp-mermaid-setup.md) for the [mermaid](mermaid.md) integration design.

View file

@ -1,13 +0,0 @@
---
name: mermaid
kind: library
status: in-use
host: CDN (jsdelivr)
url: https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs
upstream: https://mermaid.js.org
tech: JavaScript (ESM)
---
## Notes
Diagram-rendering JS library, injected into built slide HTML by the `inject_mermaid` step of `build-slides.sh`. Each `<pre><code class="language-mermaid">…</code></pre>` block emitted by [marp](marp.md) is rewritten into `<div class="mermaid">`, then `mermaid.run()` renders it client-side on page load.

View file

@ -1,34 +0,0 @@
---
name: slides
kind: static-site
status: in-use
host: makerfloss.eu
url: https://slides.makerfloss.eu
upstream: https://marp.app
tech: Marp + Mermaid.js
tls: letsencrypt
---
## Notes
Slide-deck site. Decks are authored as Marp markdown and compiled to HTML by
`build-slides.sh` (CI invokes it via the [marp](marp.md) toolchain). Built output is rsynced to
the VPS and served alongside [docs](docs.md).
Two source roots, two output conventions:
| Source | Published at |
| --- | --- |
| `docs/presentations/<name>.md` | `https://slides.makerfloss.eu/<name>.html` |
| `slides/<path>/<name>.md` | `https://slides.makerfloss.eu/<path>/<name>.html` |
Use `slides/` when a deck belongs to a named series and the URL should read like a path — for
example `slides/tappaas/how-to/new-module/index.md` is served at
`https://slides.makerfloss.eu/tappaas/how-to/new-module/`. Only the markdown is committed; the
generated HTML under `slides/` is git-ignored.
**Images** belong next to the deck source (`slides/<path>/<name>.jpg`) and are referenced with a
plain relative path. Marp keeps relative URLs as-is, so `build-slides.sh` copies image files from
`slides/` into the output tree at the same relative path — unlike the HTML, these *are* committed.
Only path-mirrored `slides/` decks can carry images; `docs/presentations/` decks are flattened to
`<name>.html`, so a relative path would not survive.

View file

@ -0,0 +1,5 @@
# ToDos efter første møde
- [ ] Beskriv ønsker til hardware og spørg ud i makerspace-gruppen om nogen har noget de vil donere
- [ ] Få et underdomæne fra bestyrelsen (fx makerfloss.orangemakerspace.com) og sæt relevant DNS api op.
- [ ] Konkretiser netværksbehov til bestyrelsen

7
docs/todo/2026-05-05.md Normal file
View file

@ -0,0 +1,7 @@
# ToDo
ø Facebook rekleme
- Indkøbsliste
g Netværk inden mandag
- Skaf penge

View file

@ -5,4 +5,3 @@
- Bo's invetory system and DNS for his QR-codes - Bo's invetory system and DNS for his QR-codes
- Task management system for BuildDays - Task management system for BuildDays
- Display systemet Konrad - Display systemet Konrad
- touch display med kalender etc.

View file

@ -0,0 +1,5 @@
# Indkøbsliste
- Rack-studs
- 3 =< 3B+
- Kølepasta

View file

@ -36,11 +36,7 @@ markdown_extensions:
- tables - tables
- attr_list - attr_list
- md_in_html - md_in_html
- pymdownx.superfences: - pymdownx.superfences
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
- pymdownx.highlight: - pymdownx.highlight:
anchor_linenums: true anchor_linenums: true
- pymdownx.inlinehilite - pymdownx.inlinehilite
@ -52,20 +48,8 @@ markdown_extensions:
plugins: plugins:
- search - search
hooks:
- scripts/mkdocs_hooks.py
nav: nav:
- Home: index.md - Home: index.md
- Project ideas: project-ideas.md
- Hardware: - Hardware:
- hardware/index.md - hardware/index.md
- Editing the hardware docs: guides/editing-hardware-docs.md - House rules: makerFLOSS_house_rules.md
- Naming scheme: guides/hardware-naming-scheme.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

View file

@ -1,8 +0,0 @@
# notes/
Repo-only working material — meeting notes, todos, internal design docs, and community-communications artefacts. **Nothing here is built into the public site**; if you want a page to appear on <https://docs.makerfloss.eu>, move it under `docs/`.
- `meetings/` — meeting minutes (Danish allowed)
- `todo/` — task lists, working norms, wishlist, OM services
- `dev/` — internal plans and specs (pre-implementation thinking)
- `communications/` — Facebook posts and other community artefacts

View file

@ -1,79 +0,0 @@
---
name: coursemanagement
kind: web-app
status: planned
---
# Problem statement
Not the ad-space - but the event management, sign-up space.
# Operative user requirements
- Overview of events and their repetition
- ID-management
- Only the needed data
- ID = email, and so we can let you know if we cancel
- Name - To give the instructor a ledger to tick off
- Comment field
- Profile picture
- People who are signed up, can see others who are signed up for the same event
- Members are automatically set up
- Login via OM-key-chip?
- Communication system
- COM = email
- Receipt for sign-up
- event materials
- Reminders...
- Open standards
- Vcard or similiardstandard
- JSON or XML
- FLOSS (MIT or lighter)
- SSO
- Authentik
- Oauth
- Security
- Own hosting
- 3-2-1 backup
- Uptime: 99.9 (but we try for more)
- HA
- Loki+Grafana Alloy+Grafana alerts
- Supply-chain / dependencies
- GIT
- Separation of concerns
- Sign-up and things directly related to each event
- event catalogue/program
- Advert
- SoMe
- Newsletter
- MeshCore
- Who graduated
- Admin stuff
- Wait lists
- Max number of participants
- SSoT: Where is the event described
- Here or on Facebook?
- Adding new events
- Templates
- Repeatable
- Idea: All can add new events
- Depreciated for now: Is English an option?
- How are people updated on what happens
- RSS
- Dedicated newsletter
- Bot on Matrix
- Shareable - so no login unless required. Short links/SoMe/e-mail/chat...
- Application in Danish
- Responsive
- GDPR compliant
- High trust (readable links), HTTPS
- Do one (a few) things well
- Modular - so we can drop or add things
- Very clear for the user to understand if they did the right thing
- Easy for the user - and for the admin and the AI. Proper headers so that when you post about in on SoMe it picks up the rights graphics
- REST-API
# PoA
- Marcus - Talk with Kjeld about moving over to GIT
- PR FTW

View file

@ -1,207 +0,0 @@
# Hardware Naming Migration 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:** Apply the approved hardware naming scheme by renaming the placeholder compute nodes `mf00`–`mf04` to `srv01`–`srv05`, recording cluster membership in frontmatter, and syncing the prose references — leaving all generated artifacts and the drift guard green.
**Architecture:** Pure data + docs migration; **no generator logic changes**. The compute files are renamed (`git mv`), their `hostname:` field updated to match (the repo enforces filename-stem == `hostname`), `cluster: tappaas` added to the one known node, then `make docs-index` regenerates the hardware index and rack artifacts. Because `mf00`–`mf04` are referenced only by themselves (their `power:`/`links:` point outward to `pdu*`/`sw*`/`pp*`, with no reverse references), the rename cascades cleanly through regeneration.
**Tech Stack:** Markdown frontmatter, Python generators (`gen_overview.py`, `gen_rack.py`) run via `make`, MkDocs Material, pytest.
**Spec:** `docs/guides/hardware-naming-scheme.md`.
## Global Constraints
- **No code changes** to `scripts/*.py`, `tests/*`, `Makefile`, CI, `mkdocs.yml`, or `scripts/overview_config.yml`. The `cluster:` field is free-form frontmatter the generators ignore by design; it needs no enum or validation in this migration (the spec defers generator/grouping work).
- Identifier format per the scheme: `<kind-abbrev><NN>`, 2-digit zero-padded, unique within a kind, starting at `01`. Compute = `srv`.
- A renamed file's `hostname:` field MUST equal its new filename stem, or `gen_overview.py` fails (`filename stem != hostname`).
- Preserve each file's body and all other frontmatter unchanged — only `hostname:` changes (plus the one added `cluster:` line).
- `cluster:` values are provisional except the one known mapping: `srv01` (was `mf00`, "TaPPaaS node 1") → `cluster: tappaas`. Do **not** invent cluster values for `srv02`–`srv05`; leave the field off them until real assignments are given.
- `makerfloss.eu` (cloud FQDN) is untouched — it keeps its real hostname and gets no `cluster:`.
- Test-fixture hostnames in `tests/test_gen_rack.py` (synthetic `mf00`/`mf01` strings) and the historical phase specs/plans under `notes/dev/` are **out of scope** — they are illustrative/historical, not references to the renamed files.
- After each task: `make test` passes (49 tests, unaffected — fixtures are synthetic), `mkdocs build --strict` passes, and `make docs-check` exits 0.
## Name mapping (this migration)
| Old file | New file | `hostname:` | `cluster:` |
|----------|----------|-------------|------------|
| `mf00.md` | `srv01.md` | `srv01` | `tappaas` |
| `mf01.md` | `srv02.md` | `srv02` | (omit) |
| `mf02.md` | `srv03.md` | `srv03` | (omit) |
| `mf03.md` | `srv04.md` | `srv04` | (omit) |
| `mf04.md` | `srv05.md` | `srv05` | (omit) |
---
### Task 1: Rename `mf00`–`mf04` → `srv01`–`srv05`, add cluster, regenerate
**Files:**
- Rename: `docs/hardware/mf00.md`→`srv01.md`, `mf01.md`→`srv02.md`, `mf02.md`→`srv03.md`, `mf03.md`→`srv04.md`, `mf04.md`→`srv05.md`
- Modify (each renamed file): `hostname:` line; add `cluster: tappaas` to `srv01.md`
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `make docs-index` (runs `gen_overview.py` + `gen_rack.py`), `make test`, `make docs-check`, `mkdocs build --strict`.
- [ ] **Step 1: Rename the five files with `git mv` (preserves history + body)**
```bash
cd /home/sjat/Projects/MakerFLOSS
git mv docs/hardware/mf00.md docs/hardware/srv01.md
git mv docs/hardware/mf01.md docs/hardware/srv02.md
git mv docs/hardware/mf02.md docs/hardware/srv03.md
git mv docs/hardware/mf03.md docs/hardware/srv04.md
git mv docs/hardware/mf04.md docs/hardware/srv05.md
```
- [ ] **Step 2: Update the `hostname:` field in each renamed file**
In each file, change the `hostname:` line (line 2) to match its new stem. Make exactly these five edits:
- `docs/hardware/srv01.md`: `hostname: mf00` → `hostname: srv01`
- `docs/hardware/srv02.md`: `hostname: mf01` → `hostname: srv02`
- `docs/hardware/srv03.md`: `hostname: mf02` → `hostname: srv03`
- `docs/hardware/srv04.md`: `hostname: mf03` → `hostname: srv04`
- `docs/hardware/srv05.md`: `hostname: mf04` → `hostname: srv05`
Do not change any other line in these files.
- [ ] **Step 3: Add `cluster: tappaas` to `srv01.md` only**
In `docs/hardware/srv01.md`, insert a `cluster: tappaas` line immediately after the `status: staging` line, so the top of the frontmatter reads:
```yaml
---
hostname: srv01
kind: server
status: staging
cluster: tappaas
location: The pile
```
Leave `srv02.md`–`srv05.md` without a `cluster:` field.
- [ ] **Step 4: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing `srv01`–`srv05` under Servers, no `mf0x`); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (9 item(s))`. No `gen_overview` schema error (each stem now matches its `hostname`).
- [ ] **Step 5: Confirm no `mf0x` remains anywhere under `docs/`**
Run: `grep -rn -E "mf0[0-4]" docs/ ; echo "exit=$?"`
Expected: no matches, `exit=1` (grep found nothing).
Run: `ls docs/hardware/srv0*.md | wc -l`
Expected: `5`.
Run: `grep -n "cluster: tappaas" docs/hardware/srv01.md`
Expected: one match.
- [ ] **Step 6: Run the full test suite**
Run: `make test`
Expected: PASS (49 tests — the suite uses synthetic fixtures and is unaffected by the rename).
- [ ] **Step 7: Build the site strictly**
Run: `mkdocs build --strict` (if `mkdocs` is not on PATH, use `python3 -m mkdocs build --strict`)
Expected: build succeeds with no warnings-as-errors. The rack page and hardware index now show `srv01`–`srv05`.
- [ ] **Step 8: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 9: Commit**
```bash
git add docs/hardware/ docs/infrastructure/racks/
git commit -m "refactor(hardware): rename mf00-mf04 to srv01-srv05, add cluster field"
```
---
### Task 2: Sync prose references and record the scheme in CLAUDE.md
**Files:**
- Modify: `CLAUDE.md` (stale hardware hint + new naming-scheme note)
- Modify: `README.md` (stale hardware hint)
**Interfaces:**
- Consumes: `grep`, `make test`, `make docs-check` (sanity).
- [ ] **Step 1: Fix the stale hardware hint in `CLAUDE.md`**
In `CLAUDE.md`, change the repository-structure comment line:
```
hardware/ # auto-indexed per-host frontmatter (mf00..mf03, makerfloss.eu)
```
to:
```
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
```
- [ ] **Step 2: Add a naming-scheme note to the `CLAUDE.md` working norms**
In `CLAUDE.md`, immediately after the existing `- **Filenames**: …` bullet, add this new bullet:
```markdown
- **Hardware identifiers**: `<kind-abbrev><NN>` — 2-digit, unique per kind (`srv`, `sw`, `pp`, `pdu`, `ups`, `shf`). Grouping (`cluster:`) lives in frontmatter, not the name. Full scheme: `docs/guides/hardware-naming-scheme.md`.
```
- [ ] **Step 3: Fix the stale hardware hint in `README.md`**
In `README.md`, change the repository-layout comment line:
```
hardware/ # auto-indexed per-host frontmatter (mf00..mf03, makerfloss.eu)
```
to:
```
hardware/ # auto-indexed per-host frontmatter (srv01..srv05, makerfloss.eu)
```
- [ ] **Step 4: Confirm no stale `mf0x` hint remains in the two files**
Run: `grep -n -E "mf0[0-4]" CLAUDE.md README.md ; echo "exit=$?"`
Expected: no matches, `exit=1`.
Run: `grep -c "Hardware identifiers" CLAUDE.md`
Expected: `1`.
- [ ] **Step 5: Sanity-check tests and drift still pass**
Run: `make test`
Expected: PASS (49 tests).
Run: `make docs-check`
Expected: exit 0 (CLAUDE.md/README.md are not generated artifacts, so this only confirms nothing regressed).
- [ ] **Step 6: Commit**
```bash
git add CLAUDE.md README.md
git commit -m "docs: record hardware naming scheme, refresh stale mf0x hints"
```
---
## Self-Review
**Spec coverage (`docs/guides/hardware-naming-scheme.md`):**
- Format `<kind-abbrev><NN>`, `srv` for compute — Task 1 (rename to `srv01`–`srv05`). ✔
- Stable identifier + `cluster:` in frontmatter — Task 1 (rename + add `cluster: tappaas` to the one known node). ✔
- `srv01` = `tappaas`; others provisional/omitted — Task 1 Step 3 + Global Constraints. ✔
- `makerfloss.eu` FQDN exception untouched — Global Constraints (not in the rename set). ✔
- No generator/grouping/validation work (deferred by spec) — Global Constraints (no `*.py`/config changes). ✔
- Scheme discoverable to contributors — Task 2 (CLAUDE.md note + spec pointer). ✔
- Stale prose hints corrected — Task 2 (CLAUDE.md + README.md). ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Cluster values for `srv02`–`srv05` are intentionally omitted (real-data dependency, explicitly bounded), not a placeholder.
**Type consistency:** No code interfaces in this migration. The name mapping table (Task 1) and the `hostname:` edits (Step 2) are internally consistent: `mf00→srv01`, `mf01→srv02`, `mf02→srv03`, `mf03→srv04`, `mf04→srv05`, with `cluster: tappaas` on `srv01` only. Verification greps reference the same `srv0x` names produced by the edits.

View file

@ -1,925 +0,0 @@
# 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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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 "![Rack rack01 elevation](rack01-elevation.svg)" 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"![Rack {rack} elevation]({rack}-elevation.svg)")
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.

View file

@ -1,593 +0,0 @@
# Rack Network (Phase 3) 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:** Add network-cabling data (`links:` feeds + switch/patch-panel peer files) to the rack pipeline, validate it (rule 4), and render a mermaid network graph on the generated rack page — reusing every Phase 1/2 mechanism.
**Architecture:** Extend the existing `scripts/gen_rack.py` with `load_hardware_index` (global hostname→frontmatter map for peer resolution), `validate_links` (rule 4), and `render_network` (a `flowchart LR` with local interface, peer port, and speed on each edge label); insert a `## Network` section into `render_page` between Power and Occupancy. Switch/patch-panel files are normal placed items that Phase 1 already draws and `gen_overview.py` already lists. Mermaid is already enabled.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-network-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.
- `_node_id` (Phase 2) is reused for mermaid node ids — do not redefine it.
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure (existing behaviour).
- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner (already emitted by `render_page`).
- **Peer resolution is global** (against all `docs/hardware/*.md` hostnames), not per-rack — rule 4 says "resolves to a real file".
- `peer_port` range is checked **only when the peer declares an integer `ports`**.
- Edge label format: `{local} → p{peer_port} · {speed}G`, with the ` · {speed}G` suffix omitted when `speed_gbps` is absent. Use the unicode arrow `→` (not `->`) to avoid clashing with mermaid's `-->` syntax.
- A node whose kind is `switch` or `patch-panel` renders as `{name}<br/>{kind}`; all other nodes render as the bare hostname.
- Network data added here is **provisional placeholder data** (like the mfNN positions and the Phase 2 power data), not real values.
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`switch`/`patch-panel`/`ap` already in the enum; drift already covers `racks/`).
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
---
### Task 1: `load_hardware_index` + `validate_links` — rule 4 (TDD)
Add the global peer index and link validation, and wire `validate_links` into `generate`. Testable on validation alone.
**Files:**
- Modify: `scripts/gen_rack.py` (add `load_hardware_index`, `validate_links`; build the index and call `validate_links` in `generate`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `SchemaError`, `parse_frontmatter`, the `item()`/`_write_item` test helpers, `generate`.
- Produces:
- `load_hardware_index(hardware_dir: Path) -> dict[str, dict]` — `{hostname: frontmatter}` for every `*.md` (excluding `index.md`).
- `validate_links(items: list[dict], hw_index: dict[str, dict]) -> None` — raises `SchemaError` on a malformed/dangling link.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_load_hardware_index_maps_all_hostnames(tmp_path):
hw = tmp_path / "hardware"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\nports: 24\n---\n",
)
_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",
)
idx = gen_rack.load_hardware_index(hw)
assert set(idx) == {"sw01", "mf00"}
assert idx["sw01"]["ports"] == 24
def test_validate_links_accepts_valid_link():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_unknown_peer():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "ghost", "peer_port": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_validate_links_rejects_peer_port_over_count():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 25}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_accepts_peer_without_ports():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "rtr01", "peer_port": 99}])]
hw_index = {"rtr01": item(hostname="rtr01", kind="server")}
gen_rack.validate_links(items, hw_index) # no ports -> range check skipped
def test_validate_links_rejects_missing_local():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"peer": "sw01", "peer_port": 1}])]
hw_index = {"sw01": item(hostname="sw01", kind="switch", ports=24)}
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, hw_index)
def test_validate_links_rejects_malformed_entry():
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=["sw01"])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_links(items, {})
def test_generate_returns_1_on_bad_link_peer(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"
"links:\n - { local: eth0, peer: ghost, peer_port: 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 'load_hardware_index'`.
- [ ] **Step 3: Add `load_hardware_index` and `validate_links` after `check_overlaps` in `scripts/gen_rack.py`**
Add these two functions (place them just after `check_overlaps`, before `_pdu_index`):
```python
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {}
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None:
continue
name = fm.get("hostname")
if isinstance(name, str) and name:
index[name] = fm
return index
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
"""Validate `links` cable declarations (rule 4).
Every links[].peer must resolve to a real hardware file (global lookup via
hw_index); peer_port must fall within the peer's declared `ports` when it
declares an integer count.
"""
for fm in items:
links = fm.get("links")
if links is None:
continue
name = fm.get("hostname", "?")
if not isinstance(links, list):
raise SchemaError(f"{name}: links must be a list")
for link in links:
if not isinstance(link, dict):
raise SchemaError(f"{name}: links entry must be a mapping")
local = link.get("local")
peer = link.get("peer")
peer_port = link.get("peer_port")
if not isinstance(local, str) or not local:
raise SchemaError(f"{name}: links entry needs a non-empty 'local'")
if not isinstance(peer, str) or not peer:
raise SchemaError(f"{name}: links entry needs a non-empty 'peer'")
if not isinstance(peer_port, int):
raise SchemaError(
f"{name}: links entry for {peer} needs an integer 'peer_port'"
)
target = hw_index.get(peer)
if target is None:
raise SchemaError(
f"{name}: links peer={peer!r} is not a known hardware file"
)
ports = target.get("ports")
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
raise SchemaError(
f"{name}: peer_port {peer_port} out of range 1..{ports} on {peer}"
)
```
- [ ] **Step 4: Wire `validate_links` into `generate` in `scripts/gen_rack.py`**
`generate` currently begins:
```python
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
errors: list[str] = []
```
Add the global index right after `items` is loaded:
```python
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
hw_index = load_hardware_index(hardware_dir)
errors: list[str] = []
```
Then extend the per-rack validation loop. Replace:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
with:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
validate_links(ritems, hw_index)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 8 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): validate network links against peer files and ports"
```
---
### Task 2: `render_network` + page section (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (add `render_network`; edit `render_page`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `_node_id` (Phase 2), `render_page`, `generate`.
- Produces: `render_network(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `links` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_render_network_has_nodes_and_edge_labels():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "sw01<br/>switch" in out
assert "mf00" in out
assert "eth0" in out
assert "p1" in out
assert "1G" in out
def test_render_network_patch_panel_subtitle():
items = [
item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1,
rack_face="front", ports=24),
item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "pp01",
"peer_port": 1, "speed_gbps": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "pp01<br/>patch-panel" in out
def test_render_network_empty_when_no_links():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_network("rack01", items) == ""
def test_render_network_omits_speed_when_absent():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "eth0" in out and "p1" in out
assert "·" not in out # no speed suffix rendered
def test_render_network_is_deterministic():
a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 2, "speed_gbps": 1}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01",
"peer_port": 1, "speed_gbps": 1}])
assert gen_rack.render_network("rack01", [a, b, c]) == \
gen_rack.render_network("rack01", [c, b, a])
def test_generate_includes_network_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw, "sw01",
"---\nhostname: sw01\nkind: switch\nstatus: in-use\n"
"rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n",
)
_write_item(
hw, "mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Network" in page
assert "```mermaid" in page
assert "eth0" in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_network'`.
- [ ] **Step 3: Add `render_network` after `render_power` in `scripts/gen_rack.py`**
```python
def render_network(rack: str, items: list[dict]) -> str:
"""Return a mermaid network-cabling flowchart, or '' if no links.
Assumes `validate_links` has already passed: every link has a non-empty
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
hardware file. `generate` validates before any render call.
"""
linked = [fm for fm in items if fm.get("links")]
if not linked:
return ""
by_host = {fm.get("hostname"): fm for fm in items}
edges: list[tuple[str, str, str, int, object]] = []
nodes: set[str] = set()
for fm in linked:
source = fm.get("hostname", "?")
nodes.add(source)
for link in fm["links"]:
peer = link["peer"]
nodes.add(peer)
edges.append(
(source, link["local"], peer, link["peer_port"],
link.get("speed_gbps"))
)
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
def node_label(name: str) -> str:
fm = by_host.get(name)
kind = fm.get("kind") if fm else None
if kind in ("switch", "patch-panel"):
return f"{name}<br/>{kind}"
return name
lines: list[str] = ["```mermaid", "flowchart LR"]
for name in sorted(nodes):
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Insert the `## Network` section in `render_page` in `scripts/gen_rack.py`**
`render_page` currently has this block (the Power section followed directly by Occupancy):
```python
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
lines.append("## Occupancy")
```
Insert the Network section between the Power block and the Occupancy line:
```python
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
network = render_network(rack, items)
if network:
lines.append("## Network")
lines.append("")
lines.append(network.rstrip())
lines.append("")
lines.append("## Occupancy")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 6 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render mermaid network graph into the rack page"
```
---
### Task 3: Populate provisional network data, regenerate
**Files:**
- Create: `docs/hardware/sw01.md`, `docs/hardware/pp01.md`
- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `links:`)
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
> **Operator note — provisional data.** The switch/patch-panel placements and the cable assignments below are placeholders proving the feature, matching the existing fictional mfNN positions and Phase 2 power data. Replace with real values when known; `validate_links` rejects dangling peers and over-count ports loudly. sw01/pp01 deliberately get no `power:` feeds in this phase.
- [ ] **Step 1: Create the switch and patch-panel files**
Create `docs/hardware/sw01.md`:
```markdown
---
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
ports: 24
---
## Notes
- Provisional placeholder switch. Port assignments are not yet real.
```
Create `docs/hardware/pp01.md`:
```markdown
---
hostname: pp01
kind: patch-panel
status: in-use
rack: rack01
rack_u: 24
u_height: 1
rack_face: front
ports: 24
links:
- { local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }
---
## Notes
- Provisional placeholder patch panel. Devices patch in here; rear uplink to sw01.
```
- [ ] **Step 2: Add `links:` to the five host files**
These files already carry rack-placement and `power:` frontmatter. ADD a `links:` block to each (before the closing `---`); do not remove anything.
In `docs/hardware/mf00.md` add:
```yaml
links:
- { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }
```
In `docs/hardware/mf01.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
```
In `docs/hardware/mf02.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 2, speed_gbps: 1 }
```
In `docs/hardware/mf03.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 3, speed_gbps: 1 }
```
In `docs/hardware/mf04.md` add:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 4, speed_gbps: 1 }
```
- [ ] **Step 3: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing sw01 under "Switches" and pp01 under "Patch panels"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (9 item(s))`.
- [ ] **Step 4: Confirm the generated page has a network graph and the new boxes**
Run: `grep -c "→ p" docs/infrastructure/racks/rack01.md`
Expected: `6` (one network edge per link: mf00→sw01, mf01..mf04→pp01, pp01→sw01).
Run: `grep -q "sw01" docs/infrastructure/racks/rack01-elevation.svg && grep -q "pp01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
Expected: `OK` (switch and patch-panel drawn as boxes in the elevation).
- [ ] **Step 5: Run the full test suite**
Run: `make test`
Expected: PASS (all tests).
- [ ] **Step 6: Build the site strictly**
Run: `mkdocs build --strict` (if `mkdocs` is not on PATH, use `python3 -m mkdocs build --strict`)
Expected: build succeeds with no warnings-as-errors.
Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html`
Expected: `≥ 2` (a power block and a network block both render as mermaid diagrams).
- [ ] **Step 7: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 8: Commit**
```bash
git add docs/hardware/ docs/infrastructure/racks/
git commit -m "feat(rack): populate provisional network topology (sw01, pp01, links)"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-network-design.md`):**
- `links:` frontmatter on devices/peers — Task 3 (populate); validated Task 1. ✔
- Switch + patch-panel peer files (`ports`, placed 1U front) — Task 3; appear via Phase 1 SVG + gen_overview, no new code. ✔
- Validation rule 4 (peer resolves to a real file globally; peer_port within `ports` when declared; malformed/missing fields) — Task 1 (`validate_links` + `load_hardware_index`), wired into `generate`. ✔
- Global peer resolution (not per-rack) — Task 1 (`load_hardware_index` over all files; `generate` passes `hw_index`). ✔
- Mermaid network graph, full edge label (local → port · speed), kind subtitle for switch/patch-panel, omit-when-empty, deterministic — Task 2 (`render_network`), inserted in `render_page` between Power and Occupancy. ✔
- Node-id sanitization reused (`_node_id`) — Task 2. ✔
- Speed omitted when absent; unicode `→` — Task 2 (label build), tested. ✔
- No mkdocs/Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 3/7. ✔
- Provisional data (mf01–mf04 → pp01 1–4; pp01 uplink → sw01:24; mf00 → sw01:1) — Task 3 Steps 1–2. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional network values, explicitly bounded and guarded by `validate_links`.
**Type consistency:** `load_hardware_index` → `dict[str, dict]`; `validate_links(items, hw_index)`/`check_overlaps`/`validate_power` → `None` (raise `SchemaError`); `render_network`/`render_power`/`render_page`/`_node_id` → `str`; `generate` → `int` (0/1). `validate_links(ritems, hw_index)` is called per-rack alongside `check_overlaps`/`validate_power`, with `hw_index` built once at the top of `generate`. `render_network` consumes `_node_id` and feeds `render_page`. Names match across tasks and tests.

View file

@ -1,553 +0,0 @@
# Rack Power (Phase 2) 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:** Add power-distribution data (`power:` feeds + PDU files) to the rack pipeline, validate it, and render a mermaid power graph on the generated rack page — reusing every Phase 1 mechanism.
**Architecture:** Extend the existing `scripts/gen_rack.py` with `validate_power` (rule 3 from the spec) and `render_power` (a `flowchart LR` with the outlet number as the edge label); insert a `## Power` section into `render_page`. PDU files are 0U `left`/`right` items that Phase 1 already renders as side-rails and `gen_overview.py` already lists. Pull the mermaid superfences fence forward into `mkdocs.yml` so the graph renders.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-power-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy `gen_rack.py`/`gen_overview.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 (existing Phase 1 behaviour).
- Generated files keep the existing `_Auto-generated … do not edit by hand_` banner.
- PDU files are **0U**: `rack_face: left|right`, **no** `rack_u`/`u_height`, and a positive-int `outlets`.
- Power data added here is **provisional placeholder data** (like the existing `mfNN` U positions), not real values.
- The Makefile `docs-check` and CI drift step already diff the whole `docs/infrastructure/racks/` dir — **do not edit** `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
---
### Task 1: `validate_power` — rule 3 validation (TDD)
Add PDU lookup + power-feed validation and wire it into `generate`. This task is testable on validation alone (no rendering needed).
**Files:**
- Modify: `scripts/gen_rack.py` (add `_pdu_index`, `validate_power`; call in `generate`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `SchemaError`, the `item()`/`_write_item` test helpers, `generate`.
- Produces:
- `_pdu_index(items: list[dict]) -> dict[str, dict]` — `{hostname: fm}` for `kind == "pdu"` items.
- `validate_power(items: list[dict]) -> None` — raises `SchemaError` on a bad PDU `outlets` declaration or a bad `power` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_validate_power_accepts_valid_feed():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
gen_rack.validate_power(items)
def test_validate_power_rejects_unknown_pdu():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "ghost", "outlet": 1}])]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_non_pdu_target():
items = [
item(hostname="sw01", kind="switch", rack_u=1, u_height=1,
rack_face="front"),
item(hostname="mf00", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "sw01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_over_count():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 9}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_outlet_zero():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 0}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_malformed_entry():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=["pdu01"]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_validate_power_rejects_pdu_without_outlets():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left"),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
with pytest.raises(gen_rack.SchemaError):
gen_rack.validate_power(items)
def test_generate_returns_1_on_bad_power_ref(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"
"power:\n - { pdu: ghost, outlet: 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 'validate_power'`.
- [ ] **Step 3: Add `_pdu_index` and `validate_power` after `check_overlaps` in `scripts/gen_rack.py`**
```python
def _pdu_index(items: list[dict]) -> dict[str, dict]:
"""Map hostname -> frontmatter for every kind:pdu item."""
return {
fm.get("hostname"): fm
for fm in items
if fm.get("kind") == "pdu"
}
def validate_power(items: list[dict]) -> None:
"""Validate PDU outlet declarations and `power` feeds within one rack.
Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is
within that PDU's `outlets` count.
"""
pdus = _pdu_index(items)
for name, fm in pdus.items():
outlets = fm.get("outlets")
if not isinstance(outlets, int) or outlets < 1:
raise SchemaError(
f"{name}: kind:pdu must declare a positive integer 'outlets'"
)
for fm in items:
feeds = fm.get("power")
if feeds is None:
continue
name = fm.get("hostname", "?")
if not isinstance(feeds, list):
raise SchemaError(f"{name}: power must be a list")
for feed in feeds:
if not isinstance(feed, dict):
raise SchemaError(f"{name}: power entry must be a mapping")
pdu = feed.get("pdu")
outlet = feed.get("outlet")
if not isinstance(pdu, str) or not pdu:
raise SchemaError(f"{name}: power entry needs a non-empty 'pdu'")
if not isinstance(outlet, int):
raise SchemaError(
f"{name}: power entry for {pdu} needs an integer 'outlet'"
)
target = pdus.get(pdu)
if target is None:
raise SchemaError(
f"{name}: power pdu={pdu!r} is not a known kind:pdu file"
)
count = target["outlets"]
if outlet < 1 or outlet > count:
raise SchemaError(
f"{name}: outlet {outlet} out of range 1..{count} on {pdu}"
)
```
- [ ] **Step 4: Wire `validate_power` into `generate` in `scripts/gen_rack.py`**
Change the overlap loop so it also validates power. Replace:
```python
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}")
```
with:
```python
if not errors: # only check overlaps once placements are individually valid
for rack, ritems in racks.items():
try:
check_overlaps(ritems)
validate_power(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 8 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): validate power feeds against PDU outlets"
```
---
### Task 2: `render_power` + page section (TDD)
**Files:**
- Modify: `scripts/gen_rack.py` (add `_node_id`, `render_power`; edit `render_page`)
- Modify: `tests/test_gen_rack.py` (append tests)
**Interfaces:**
- Consumes: `_pdu_index` (Task 1), `render_page`, `generate`.
- Produces:
- `_node_id(name: str) -> str` — hostname with non-alphanumeric chars replaced by `_`.
- `render_power(rack: str, items: list[dict]) -> str` — a fenced `mermaid` `flowchart LR` ending in a newline, or `""` when no item has a `power` feed.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_render_power_has_nodes_and_edge_labels():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 3}]),
]
out = gen_rack.render_power("rack01", items)
assert "```mermaid" in out
assert "flowchart LR" in out
assert "pdu01" in out
assert "8 outlets" in out
assert "outlet 3" in out
assert "mf00" in out
def test_render_power_redundant_device_has_two_edges():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8),
item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1},
{"pdu": "pdu02", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert out.count("-->|outlet") == 2
def test_render_power_empty_when_no_feeds():
items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")]
assert gen_rack.render_power("rack01", items) == ""
def test_render_power_is_deterministic():
a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8)
b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 2}])
c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}])
assert gen_rack.render_power("rack01", [a, b, c]) == \
gen_rack.render_power("rack01", [c, b, a])
def test_generate_includes_power_section(tmp_path):
hw = tmp_path / "hardware"
out = tmp_path / "out"
hw.mkdir()
_write_item(
hw,
"pdu01",
"---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n"
"rack: rack01\nrack_face: left\noutlets: 8\n---\n",
)
_write_item(
hw,
"mf00",
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n"
"power:\n - { pdu: pdu01, outlet: 1 }\n---\n",
)
rc = gen_rack.generate(hw, out)
assert rc == 0
page = (out / "rack01.md").read_text()
assert "## Power" in page
assert "```mermaid" in page
assert "outlet 1" in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_power'`.
- [ ] **Step 3: Add `_node_id` and `render_power` after `render_svg` in `scripts/gen_rack.py`**
```python
def _node_id(name: str) -> str:
"""A mermaid-safe node id derived from a hostname."""
return re.sub(r"[^0-9A-Za-z]", "_", str(name))
def render_power(rack: str, items: list[dict]) -> str:
"""Return a mermaid power-distribution flowchart, or '' if no feeds."""
powered = [fm for fm in items if fm.get("power")]
if not powered:
return ""
pdus = _pdu_index(items)
edges: list[tuple[str, int, str]] = []
for fm in powered:
device = fm.get("hostname", "?")
for feed in fm["power"]:
edges.append((feed["pdu"], feed["outlet"], device))
edges.sort()
lines: list[str] = ["```mermaid", "flowchart LR"]
for pdu in sorted(pdus):
outlets = pdus[pdu].get("outlets")
lines.append(f' {_node_id(pdu)}["{pdu}<br/>{outlets} outlets"]')
devices = sorted(
powered,
key=lambda i: (
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
for fm in devices:
device = fm.get("hostname", "?")
lines.append(f' {_node_id(device)}["{device}"]')
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Insert the `## Power` section in `render_page` in `scripts/gen_rack.py`**
In `render_page`, replace this block:
```python
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
lines.append("## Occupancy")
```
with:
```python
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
lines.append("## Occupancy")
```
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 5 new).
- [ ] **Step 6: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py
git commit -m "feat(rack): render mermaid power graph into the rack page"
```
---
### Task 3: Enable mermaid, populate provisional power data, regenerate
**Files:**
- Modify: `mkdocs.yml` (mermaid superfences fence)
- Create: `docs/hardware/pdu01.md`, `docs/hardware/pdu02.md`
- Modify: `docs/hardware/mf00.md`..`mf04.md` (add `power:`)
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
**Interfaces:**
- Consumes: `python3 scripts/gen_rack.py` / `make docs-index`, `mkdocs build --strict`, `make docs-check`.
> **Operator note — provisional data.** The PDU placements and outlet assignments below are placeholders proving the feature, matching the existing fictional `mfNN` U positions. Replace with real values when known; `validate_power` will reject dangling/over-count feeds loudly.
- [ ] **Step 1: Enable the mermaid custom fence in `mkdocs.yml`**
In `mkdocs.yml`, replace the bare superfences line:
```yaml
- pymdownx.superfences
```
with:
```yaml
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
```
- [ ] **Step 2: Create the two PDU files**
Create `docs/hardware/pdu01.md`:
```markdown
---
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: left
outlets: 8
---
## Notes
- Provisional placeholder PDU (left rail). Outlet assignments are not yet real.
```
Create `docs/hardware/pdu02.md`:
```markdown
---
hostname: pdu02
kind: pdu
status: in-use
rack: rack01
rack_face: right
outlets: 8
---
## Notes
- Provisional placeholder PDU (right rail). Provides redundant feeds.
```
- [ ] **Step 3: Add `power:` to the five host files**
In `docs/hardware/mf00.md`, add to the frontmatter (before the closing `---`) — note mf00 has **two** feeds (the redundant demonstration):
```yaml
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 }
```
In `docs/hardware/mf01.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 2 }
```
In `docs/hardware/mf02.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 3 }
```
In `docs/hardware/mf03.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 4 }
```
In `docs/hardware/mf04.md` add:
```yaml
power:
- { pdu: pdu01, outlet: 5 }
```
- [ ] **Step 4: Regenerate all indices and rack artifacts**
Run: `make docs-index`
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing pdu01/pdu02 under "PDUs"); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (7 item(s))`.
- [ ] **Step 5: Confirm the generated page has a rendered power graph and PDU rails**
Run: `grep -c "outlet" docs/infrastructure/racks/rack01.md`
Expected: ≥ 6 (one edge per feed: mf00 ×2, mf01..mf04 ×1).
Run: `grep -c "pdu0" docs/infrastructure/racks/rack01-elevation.svg`
Expected: ≥ 2 (pdu01 + pdu02 drawn as side-rails).
- [ ] **Step 6: Run the full test suite**
Run: `make test`
Expected: PASS (all tests).
- [ ] **Step 7: Build the site strictly**
Run: `mkdocs build --strict`
Expected: build succeeds with no warnings-as-errors; `site/infrastructure/racks/rack01/index.html` contains a `<pre class="mermaid">` (or `<div class="mermaid">`) block rather than a plain `<code>` fence.
Verify: `grep -c "mermaid" site/infrastructure/racks/rack01/index.html`
Expected: ≥ 1.
- [ ] **Step 8: Confirm the drift guard is satisfied**
Run: `make docs-check`
Expected: exit 0 — committed artifacts match a fresh regeneration.
- [ ] **Step 9: Commit**
```bash
git add mkdocs.yml docs/hardware/ docs/infrastructure/racks/
git commit -m "feat(rack): enable mermaid, populate provisional power data"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-power-design.md`):**
- `power:` frontmatter on devices — Task 3 (populate); validated Task 1. ✔
- PDU files (`kind: pdu`, `outlets`, 0U `left`/`right`) — Task 3; outlets validated Task 1. ✔
- Validation rule 3 (pdu resolves to kind:pdu; outlet in range; pdu declares outlets) — Task 1 (`validate_power`), wired into `generate`. ✔
- Mermaid power graph, outlet as edge label, redundancy as two edges, omit-when-empty, deterministic — Task 2 (`render_power`), inserted in `render_page`. ✔
- Node-id sanitization — Task 2 (`_node_id`). ✔
- Mermaid pulled forward in `mkdocs.yml` — Task 3 Step 1. ✔
- No Makefile/CI/overview_config changes — honored (Global Constraints); drift covered by existing `racks/` diff — Task 3 Steps 4/8. ✔
- Provisional data (pdu01 left → mf00..mf04 o1..o5; pdu02 right → mf00 o1) — Task 3 Steps 2–3. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The only operator-judgement item is provisional power values, explicitly bounded and guarded by `validate_power`.
**Type consistency:** `_pdu_index` → `dict[str, dict]`; `validate_power`/`check_overlaps` → `None` (raise `SchemaError`); `render_power`/`render_page`/`render_svg`/`_node_id` → `str`; `generate` → `int` (0/1). `validate_power(ritems)` is called per-rack alongside `check_overlaps(ritems)`. `render_power` consumes `_pdu_index` and feeds `render_page`. Names match across tasks and tests.

View file

@ -1,702 +0,0 @@
# Rack Presentation Improvements 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:** Make the generated rack elevation interactive (inline SVG, clickable boxes, hover tooltips) with status-encoded borders and a legend, and theme the mermaid power/network graphs (colour-by-kind + clickable nodes).
**Architecture:** Pure rendering upgrade in `scripts/gen_rack.py`. New module-level helpers (`_host_url`, `_status_stroke`, `_stroke_attrs`, `_placement`, `_tooltip`); `render_svg` wraps each device in `<a>`+`<title>`, encodes status via border, adds a legend, both-gutter U-numbers, and column frames; `render_page` inlines the SVG (via `md_in_html`) with a download link; `render_power`/`render_network` add per-node `style` + `click`. Each task regenerates the artifacts so the drift guard stays green.
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material (`md_in_html`, mermaid), Forgejo Actions CI.
**Spec:** `notes/dev/specs/2026-06-24-rack-presentation-design.md`.
## Global Constraints
- Scripts use **stdlib + PyYAML only**; deterministic and offline. `re`/`yaml` already imported — no new imports.
- Links/clicks use **root-relative final URLs** `/hardware/<host>/` (mkdocs does not rewrite raw hrefs inside inline SVG or mermaid; `use_directory_urls` defaults true; site at domain root).
- Status border mapping (verbatim): in-use `#333333`/1.5/solid; staging `#333333`/1.5/dash `4 2`; broken `#e15759`/3/solid; spare `#bbbbbb`/1.5/solid; donated `#bbbbbb`/1.5/solid; other → `#333333`/1.5/solid. Fill stays the kind colour.
- The inline elevation block is `<div class="rack-elevation">` + the SVG markup (no blank lines inside) + `</div>`; **no** `markdown` attribute (raw passthrough). The standalone `rack01-elevation.svg` is still generated, plus a `[Download SVG](rack01-elevation.svg)` link.
- Tooltip text: `"<host> · <kind> · <status> · cluster: <cluster|—> · <placement>"`, `_esc`-applied; placement = U-range / `0U <face>` / `<shelf>/<face>/slot <slot>`.
- Reuse `item()`/`_write_item()`/`shelf()` test helpers; do not redefine them.
- Each task ends with regenerated `docs/infrastructure/racks/rack01.{md,svg}`, `make test` green, `mkdocs build --strict` passing, and `make docs-check` exit 0.
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml`.
---
### Task 1: Helpers + interactive elevation (inline, links, tooltips, status borders)
**Files:**
- Modify: `scripts/gen_rack.py` (helpers; `render_svg` `<a>`/`<title>`/status borders + `style=` on `<svg>`; `render_page` inline + download link)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Produces (module-level, used by Task 2): `_host_url(host) -> str`, `_status_stroke(status) -> tuple[str, float, str]`, `_stroke_attrs(status) -> str`, `_placement(fm) -> str`, `_tooltip(fm) -> str`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_boxes_link_to_host_pages():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert '<a href="/hardware/srv04/">' in svg
assert "<title>" in svg
def test_svg_status_border_styles():
staging = gen_rack.render_svg("rack01", [
item(hostname="a", rack_u=1, u_height=1, rack_face="front",
status="staging")])
broken = gen_rack.render_svg("rack01", [
item(hostname="b", rack_u=1, u_height=1, rack_face="front",
status="broken")])
assert 'stroke-dasharray="4 2"' in staging
assert 'stroke="#e15759"' in broken and 'stroke-width="3"' in broken
def test_svg_tooltip_has_cluster_and_placement():
items = [item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
status="staging", cluster="tappaas")]
svg = gen_rack.render_svg("rack01", items)
assert "cluster: tappaas" in svg
assert "U1" in svg
def test_svg_has_responsive_style():
svg = gen_rack.render_svg("rack01", [])
assert "max-width:100%" in svg
def test_render_page_inlines_svg_with_download_link():
items = [item(hostname="srv04", rack_u=5, u_height=1, rack_face="front")]
page = gen_rack.render_page("rack01", items)
assert '<div class="rack-elevation">' in page
assert "<svg" in page
assert "[Download SVG](rack01-elevation.svg)" in page
assert "![Rack rack01 elevation]" not in page
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — the new assertions fail (`<a href` / `<title>` / `max-width` / inline `<div>` not present yet).
- [ ] **Step 3: Add the helper functions after `_esc` in `scripts/gen_rack.py`**
Insert immediately after the `_esc` function (before `_sorted_items`):
```python
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '—')} · {_placement(fm)}"
)
```
- [ ] **Step 4: Add the responsive `style` to the `<svg>` tag in `render_svg`**
Replace:
```python
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">'
)
```
with:
```python
p.append(
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
f'height="{height}" viewBox="0 0 {width} {height}" '
f'style="max-width:100%;height:auto" '
f'font-family="sans-serif" font-size="11">'
)
```
- [ ] **Step 5: Wrap `draw_device` in a link + title and use the status border**
Replace the whole `draw_device` function with:
```python
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
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>"
)
p.append("</a>")
```
- [ ] **Step 6: Wrap `draw_rail` in a link + title and use the status border**
Replace the whole `draw_rail` function with:
```python
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
```
- [ ] **Step 7: Make shelf occupants and the shelf strip links in `draw_shelf`**
Replace the whole `draw_shelf` function with this version (occupant boxes each link to their host; the two shelf strips + label are one link to the shelf host):
```python
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'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
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("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
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>'
)
p.append("</a>")
```
- [ ] **Step 8: Inline the SVG in `render_page` with a download link**
In `render_page`, replace:
```python
lines.append("## Elevation")
lines.append("")
lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)")
lines.append("")
```
with:
```python
lines.append("## Elevation")
lines.append("")
lines.append('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
```
- [ ] **Step 9: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 5 new).
- [ ] **Step 10: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c 'href="/hardware/' docs/infrastructure/racks/rack01.md`
Expected: ≥ 10 (one link per device box + the shelf).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds, no warnings-as-errors.
Run: `grep -c '<svg' site/infrastructure/racks/rack01/index.html && grep -c 'href="/hardware/' site/infrastructure/racks/rack01/index.html`
Expected: both ≥ 1 — confirms the inline SVG and its links survived `md_in_html` into the built HTML (not escaped).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 11: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): inline interactive elevation with links, tooltips, status borders"
```
---
### Task 2: Legend, both-gutter U-numbers, column frames
**Files:**
- Modify: `scripts/gen_rack.py` (`render_svg` layout + legend)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg`
**Interfaces:**
- Consumes: `_status_stroke` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`, `_esc`.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_svg_legend_shows_present_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">Legend<" in svg
assert ">switch<" in svg
def test_svg_legend_omits_absent_kinds():
items = [item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front")]
svg = gen_rack.render_svg("rack01", items)
assert ">ups<" not in svg
def test_svg_u_numbers_in_both_gutters():
svg = gen_rack.render_svg("rack01", [])
assert 'text-anchor="end"' in svg # left gutter
assert 'text-anchor="start"' in svg # right gutter
def test_svg_has_column_frames():
svg = gen_rack.render_svg("rack01", [])
assert svg.count('fill="none"') >= 2 # one frame per column
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `>Legend<`, right-gutter `text-anchor="start"`, and `fill="none"` frames are not present yet.
- [ ] **Step 3: Add the `LEGEND_H` constant and grow the canvas**
In `render_svg`, the constants block currently ends with `TITLE_H = 28`. Add a legend-band constant right after it:
```python
TITLE_H = 28
LEGEND_H = 56
```
Then replace the height line:
```python
height = PAD + TITLE_H + body_h + PAD
```
with:
```python
height = PAD + TITLE_H + body_h + PAD + LEGEND_H
```
- [ ] **Step 4: Add a right-side U-number gutter to the layout**
Replace:
```python
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
```
with:
```python
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_W + len(right_items) * RAIL_W + PAD
```
And replace the right-rail drawing loop:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
```
with:
```python
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
```
- [ ] **Step 5: Draw the column frames and the right-gutter U-numbers**
The left-gutter U-number loop currently reads:
```python
# 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>'
)
```
Immediately after that loop, add the right-gutter numbers and the two column frames:
```python
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
```
- [ ] **Step 6: Draw the legend before `</svg>`**
The function currently ends:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
p.append("</svg>")
return "\n".join(p) + "\n"
```
Insert the legend between the shelf loop and `p.append("</svg>")`:
```python
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
return "\n".join(p) + "\n"
```
- [ ] **Step 7: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 4 new).
- [ ] **Step 8: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c ">Legend<" docs/infrastructure/racks/rack01-elevation.svg`
Expected: `1`.
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 9: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): add elevation legend, both-gutter U-numbers, column frames"
```
---
### Task 3: Mermaid node colours + clickable nodes
**Files:**
- Modify: `scripts/gen_rack.py` (`render_power`, `render_network`)
- Modify: `tests/test_gen_rack.py` (append tests)
- Regenerate: `docs/infrastructure/racks/rack01.md`
**Interfaces:**
- Consumes: `_node_id`, `_host_url` (Task 1), `KIND_COLORS`, `DEFAULT_COLOR`.
> **Note on mermaid `click` interactivity.** Node **colouring** (`style …`) always
> works. Whether a `click` actually navigates depends on Material's mermaid
> `securityLevel` (default `strict` may render the link inert) — and this phase
> makes **no `mkdocs.yml` change**. The `click` directive still parses and the
> graph still renders either way; the tests only assert the directives are
> emitted. In Step 6, confirm the graphs still render as **diagrams** (not broken
> code blocks). If clicks turn out inert in the built site, that is acceptable for
> this phase (the SVG elevation already provides clickable navigation) — note it
> in the report rather than adding config.
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
```python
def test_power_graph_colors_and_links_nodes():
items = [
item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
power=[{"pdu": "pdu01", "outlet": 1}]),
]
out = gen_rack.render_power("rack01", items)
assert "style srv01 fill:" in out
assert "style pdu01 fill:" in out
assert 'click srv01 "/hardware/srv01/"' in out
def test_network_graph_colors_and_links_nodes():
items = [
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
rack_face="front", ports=24),
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]),
]
out = gen_rack.render_network("rack01", items)
assert "style sw01 fill:" in out
assert 'click sw01 "/hardware/sw01/"' in out
assert 'click srv01 "/hardware/srv01/"' in out
```
- [ ] **Step 2: Run to verify failure**
Run: `pytest tests/test_gen_rack.py -q`
Expected: FAIL — `style …`/`click …` lines are not emitted yet.
- [ ] **Step 3: Emit node colours + clicks in `render_power`**
In `render_power`, the function currently ends:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
- [ ] **Step 4: Emit node colours + clicks in `render_network`**
In `render_network`, the function currently ends:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
lines.append("```")
return "\n".join(lines) + "\n"
```
Insert a styling/click block between the edge loop and `lines.append("```")`:
```python
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
```
(`by_host` already exists in `render_network`; off-rack peers — not in `by_host` — get a default-coloured node and no click, per the spec.)
- [ ] **Step 5: Run to verify pass**
Run: `pytest tests/test_gen_rack.py -q`
Expected: PASS (all prior tests + 2 new).
- [ ] **Step 6: Regenerate and verify build + drift**
Run: `make docs-index`
Expected: `Wrote rack01.md + rack01-elevation.svg (10 item(s))`.
Run: `grep -c "click srv01" docs/infrastructure/racks/rack01.md`
Expected: `2` (one in the power graph, one in the network graph).
Run: `make test`
Expected: PASS.
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict`)
Expected: build succeeds.
Run: `grep -c 'class="mermaid"' site/infrastructure/racks/rack01/index.html`
Expected: `≥ 2` — confirms both graphs still render as **diagrams** after adding `style`/`click` (i.e. the directives did not break mermaid parsing).
Run: `make docs-check`
Expected: exit 0.
- [ ] **Step 7: Commit**
```bash
git add scripts/gen_rack.py tests/test_gen_rack.py docs/infrastructure/racks/
git commit -m "feat(rack): colour and link mermaid power/network nodes by kind"
```
---
## Self-Review
**Spec coverage (`2026-06-24-rack-presentation-design.md`):**
- A. Inline SVG + clickable boxes + tooltips + standalone .svg + download link + responsive style — Task 1. ✔
- B. Status border encoding (table verbatim) — Task 1 (`_status_stroke`/`_stroke_attrs`, applied in draw_device/draw_rail/draw_shelf). ✔
- C. Legend (present kinds + status key) — Task 2. ✔
- D. U-numbers both gutters, column frame — Task 2; mermaid node colours + clicks — Task 3. ✔
- Root-relative `/hardware/<host>/` URLs — Task 1 (`_host_url`), used in SVG links and mermaid clicks. ✔
- md_in_html raw passthrough (`<div class="rack-elevation">`, no `markdown` attr) — Task 1 Step 8, verified Task 1 Step 10 (built HTML contains `<svg`/`href`). ✔
- No mkdocs/Makefile/CI/overview_config changes; occupancy + validation unchanged — honored. ✔
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". Legend spacing uses a deterministic width heuristic (`28 + 7*len(label)`), not a placeholder.
**Type consistency:** `_status_stroke -> tuple[str, float, str]`; `_stroke_attrs`/`_host_url`/`_placement`/`_tooltip` -> `str`; `render_svg`/`render_page`/`render_power`/`render_network` -> `str`. `_status_stroke` defined in Task 1 is reused by Task 2's legend; `_host_url` defined in Task 1 is reused by Task 3's clicks. `by_host`/`nodes`/`pdus`/`powered` names in the mermaid edits match the existing function locals. Each task regenerates the artifacts so drift stays clean at every boundary.

View file

@ -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.

View file

@ -1,168 +0,0 @@
# 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 (`![Rack rack01 elevation](rack01-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.

View file

@ -1,178 +0,0 @@
# Rack Network (Phase 3) Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 3)
## Goal
Add network-cabling data to the rack documentation pipeline and render it as a
mermaid network graph on the generated rack page, so the published page at
`docs.makerfloss.eu` shows which interface/port connects each device to its
switch or patch panel. This reuses every Phase 1/2 mechanism: the same
`scripts/gen_rack.py` generator, the same generated files under
`docs/infrastructure/racks/`, and the same CI drift guard. Mermaid is already
enabled (pulled forward in Phase 2), so no `mkdocs.yml` change is needed.
## Context
- Phase 1 (elevation) and Phase 2 (power) are merged. `scripts/gen_rack.py`
reads `docs/hardware/*.md` files carrying a `rack:` field, validates
placement and power, and writes `<rack>-elevation.svg` + `<rack>.md` per rack.
The page already has `## Elevation`, `## Power`, `## Occupancy` sections.
- The `switch`, `patch-panel`, and `ap` values are already in the `hardware`
`kind` enum (`scripts/overview_config.yml`), so peer files already validate
and already appear in the hardware index. **No `overview_config.yml` change.**
- Phase 1 already renders placed (`rack_u`/`u_height`) items as colored boxes in
the elevation, with `switch` green and `patch-panel` brown in `KIND_COLORS`,
so the new peer files need **no new SVG code** to appear.
- The Makefile `docs-check` and CI drift step already diff the entire
`docs/infrastructure/racks/` directory — **no Makefile/CI edits required**.
- `_node_id` (Phase 2) sanitizes hostnames into mermaid-safe node ids and is
reused here.
- The `mfNN` rack positions and the Phase 2 power data are fictional
placeholders proving the pipeline. The network data added here is **similarly
provisional** until real values are given.
## Data model
### Devices/peers — `links:` frontmatter
Each cable end is declared **once**, on the originating end:
```yaml
links:
- { local: eth0, peer: pp01, peer_port: 1, speed_gbps: 1 }
```
`links` is optional — an item with no `links` contributes no edges. Any rack
item may declare links, not just servers (e.g. `pp01` declares its uplink).
Fields per entry:
- `local` — the local interface/port label (non-empty string), the cable end on
this item.
- `peer` — the hostname of the device at the other end (non-empty string).
- `peer_port` — the port number on the peer (integer).
- `speed_gbps` — link speed in Gbps (optional integer; shown on the edge when
present).
**Declare-once convention:** a physical cable is declared on exactly one end to
avoid duplicate edges. The generator renders whatever is declared; it does not
infer the reverse direction.
### New peer files
```yaml
# docs/hardware/sw01.md
hostname: sw01
kind: switch
status: in-use
rack: rack01
rack_u: 10
u_height: 1
rack_face: front
ports: 24
```
```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
```
`ports` is a new field, validated by `gen_rack.py` only as the bound for
`peer_port`. `gen_overview.py` ignores `ports` and `links`.
### Provisional data populated by this phase (full pass-through)
- `sw01` — `kind: switch`, U10 front, `ports: 24`.
- `pp01` — `kind: patch-panel`, U24 front, `ports: 24`, with one uplink link
`{ local: uplink, peer: sw01, peer_port: 24, speed_gbps: 1 }`.
- `mf01..mf04` — `eth0 → pp01` ports 1..4 respectively, `speed_gbps: 1`.
- `mf00` — `eth0 → sw01` port 1, `speed_gbps: 1`.
sw01 and pp01 deliberately get **no** `power:` feeds in this phase, to keep the
Phase 3 diff network-focused; the power graph is unchanged.
## Validation (rule 4 from the parent spec)
A new `load_hardware_index(hardware_dir) -> dict[str, dict]` returns
`{hostname: frontmatter}` for **every** `docs/hardware/*.md` (excluding
`index.md`), giving global peer resolution.
A new `validate_links(items, hw_index) -> None` in `gen_rack.py`, called from
`generate()` per rack after placement/power validation, raises `SchemaError`
(→ stderr + exit 1, nothing written) when:
1. A `links` value is not a list, or an entry is not a mapping.
2. An entry lacks a non-empty string `local`, a non-empty string `peer`, or an
integer `peer_port`.
3. An entry's `peer` does not resolve to a hostname in `hw_index` (rule 4:
"resolves to a real file" — global, not per-rack).
4. The peer declares an integer `ports` and `peer_port` is outside
`1..ports`. (Peers without a declared `ports` skip the range check.)
Peer resolution is intentionally **global** (against all hardware files),
diverging from power's per-rack PDU resolution, so a link may target non-racked
gear (e.g. an upstream router) in future.
## Rendering
### `render_network(rack, items) -> str`
Returns a fenced mermaid block, or `""` when no item in the rack has any
`links` entry (so the `## Network` section is omitted for link-less racks).
- ` ```mermaid ` + `flowchart LR`.
- One node per host that appears as a link source or peer. A node whose kind
(resolved from the rack's items) is `switch` or `patch-panel` gets a
`<br/>kind` subtitle (e.g. `sw01<br/>switch`); all other nodes render as the
bare hostname. Peers not present in the rack's items render as the bare
hostname.
- One edge per link: `sourceNode -->|{local} → p{peer_port} · {speed}G| peerNode`.
The ` · {speed}G` suffix is omitted when `speed_gbps` is absent. The unicode
arrow `→` avoids any clash with mermaid's `-->` edge syntax.
- Node ids via `_node_id` (Phase 2). Deterministic: nodes sorted by hostname,
edges sorted by `(source, local, peer, peer_port)`.
### `render_page` change
Insert a `## Network` section containing `render_network(...)` **between** the
Power and Occupancy sections — only when `render_network` returns non-empty.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | Add `load_hardware_index`, `validate_links`; call `validate_links` in `generate`; add `render_network`; insert `## Network` in `render_page` |
| `tests/test_gen_rack.py` | Add `validate_links` + `render_network` + `generate` network cases |
| `docs/hardware/sw01.md`, `pp01.md` | New peer files (`switch`/`patch-panel`, `ports: 24`); `pp01` carries the uplink `links` entry |
| `docs/hardware/mf00.md`..`mf04.md` | Add `links:` lists |
| `docs/hardware/index.md` | Regenerated (switch + patch panel now listed) |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | Regenerated (network section + sw01/pp01 boxes in the elevation) |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes.
## Test plan
- **Unit — `validate_links`:** accept a valid link; reject an unknown `peer`, a
`peer_port` above the peer's `ports`, a malformed entry (non-mapping / missing
`local`/`peer`/`peer_port`). Accept a link whose peer declares no `ports`
(range check skipped).
- **Unit — `render_network`:** source/peer nodes present; peer kind subtitle for
switch/patch-panel; edge label carries local interface, peer port, and speed;
returns `""` when no item has links; deterministic for reordered input.
- **Integration — `generate`:** with valid fixtures the page contains the
`## Network` section and the mermaid fence; with a dangling `peer` it returns
`1` and writes nothing.
- **Drift:** `make docs-check` exits 0 after regeneration (existing guard).
- **Visual:** `mkdocs build --strict` succeeds and the rack page shows the
network graph as a rendered diagram, with the pass-through chain
mf01..mf04 → pp01 → sw01 and mf00 → sw01 visible.

View file

@ -1,163 +0,0 @@
# Rack Power (Phase 2) Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (Phase 2)
## Goal
Add power-distribution data to the rack documentation pipeline and render it as a
mermaid graph on the generated rack page, so the published page at
`docs.makerfloss.eu` shows which PDU/outlet feeds each device and which devices
have redundant (dual-PSU) feeds. This reuses every Phase 1 mechanism: the same
`scripts/gen_rack.py` generator, the same generated files under
`docs/infrastructure/racks/`, and the same CI drift guard.
## Context
- Phase 1 (rack elevation) is merged. `scripts/gen_rack.py` reads
`docs/hardware/*.md` files carrying a `rack:` field, validates placement
(U range, overlap, 0U rules), and writes `<rack>-elevation.svg` +
`<rack>.md` per rack. Tests in `tests/test_gen_rack.py`.
- The `pdu` value is already in the `hardware` `kind` enum
(`scripts/overview_config.yml`), so PDU files already validate and already
appear in the hardware index under "PDUs".
- Phase 1 already renders 0U `rack_face: left|right` items as side-rails in the
SVG, so PDU files need **no new SVG code** to appear in the elevation.
- The Makefile `docs-check` target and CI `Fail on drift` step already diff the
**entire** `docs/infrastructure/racks/` directory, so a regenerated page with
a power graph is already drift-covered — **no Makefile/CI edits required**.
- Mermaid is **not yet enabled** in `mkdocs.yml` (superfences has no mermaid
custom fence). Enabling it was nominally a Phase 3 item; Phase 2's graph needs
it, so it is **pulled forward** into this phase (decided during brainstorming).
- The `mfNN` rack positions are fictional placeholders proving the pipeline. The
power data added here is **similarly provisional** until real values are given.
## Data model
### Powered devices — `power:` frontmatter
Each powered device gains a `power` list; each entry is one feed:
```yaml
power:
- { pdu: pdu01, outlet: 1 }
- { pdu: pdu02, outlet: 1 } # a second entry = a redundant PSU feed
```
`power` is optional — a device with no `power` field simply contributes no power
edges.
### PDU files — new lightweight hardware items
```yaml
# docs/hardware/pdu01.md
hostname: pdu01
kind: pdu
status: in-use
rack: rack01
rack_face: left
outlets: 8
```
PDUs are 0U side-rail items (`rack_face: left|right`, no `rack_u`/`u_height`),
exactly the shape Phase 1's validator and SVG already handle. `outlets` is a new
field, validated by `gen_rack.py` (below). No `overview_config.yml` change is
needed: `kind: pdu` is already an enum value, and `outlets` is an extra field
that `gen_overview.py` ignores.
### Provisional data populated by this phase
- `pdu01` — `rack_face: left`, `outlets: 8`.
- `pdu02` — `rack_face: right`, `outlets: 8`.
- `mf00..mf04` — fed from `pdu01` outlets 1..5 respectively.
- `mf00` — additionally fed from `pdu02` outlet 1 (the redundant demonstration).
## Validation (rule 3 from the parent spec)
A new `validate_power(items: list[dict]) -> None` in `gen_rack.py`, called from
`generate()` after per-item placement validation and before/with overlap
checking. It raises `SchemaError` (→ stderr + exit 1, nothing written) when:
1. A `kind: pdu` file does not declare `outlets` as a positive integer.
2. A `power` value is not a list, or an entry is not a mapping.
3. An entry lacks a non-empty string `pdu` or an integer `outlet`.
4. An entry's `pdu` does not resolve to a loaded file whose `kind == pdu`.
5. An entry's `outlet` is outside `1..outlets` of the referenced PDU.
PDU resolution is by `hostname` against all loaded rack items (a PDU lookup map
`{hostname: fm for fm in items if kind == pdu}`).
## Rendering
### `render_power(rack, items) -> str`
Returns a fenced mermaid block, or `""` when no device in the rack has any
`power` entry (so the `## Power` section is omitted for power-less racks).
- ` ```mermaid ` + `flowchart LR`.
- One node per PDU that is referenced or placed in the rack:
label `pdu01<br/>8 outlets`.
- One node per powered device: label = hostname.
- One edge per feed: `pduNode -->|outlet N| deviceNode`.
- Node ids are the hostname with non-alphanumeric characters replaced by `_`
(display text keeps the real hostname via the quoted label), guarding against
ids that mermaid would reject.
- Deterministic: PDU nodes sorted by hostname, device nodes by
`(rack_u, hostname)`, edges sorted by `(pdu, outlet, device)`.
Redundant feeds render naturally as two edges into one device node, from two
different PDUs.
### `render_page` change
Insert a `## Power` section containing `render_power(...)` **between** the
Elevation and Occupancy sections — only when `render_power` returns non-empty.
## mkdocs (pulled forward)
Add the mermaid custom fence to the existing `pymdownx.superfences` entry in
`mkdocs.yml`:
```yaml
- pymdownx.superfences:
custom_fences:
- name: mermaid
class: mermaid
format: !!python/name:pymdownx.superfences.fence_code_format
```
Material ships `mermaid.js` and activates it on this fence, so
`mkdocs build --strict` renders the graph as a diagram.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | Add `validate_power`; call it in `generate`; add `render_power`; insert `## Power` in `render_page` |
| `tests/test_gen_rack.py` | Add `validate_power` + `render_power` + `generate` power cases |
| `mkdocs.yml` | Enable mermaid via superfences custom fence |
| `docs/hardware/pdu01.md`, `pdu02.md` | New 0U PDU files (`kind: pdu`, `outlets: 8`) |
| `docs/hardware/mf00.md`..`mf04.md` | Add `power:` lists |
| `docs/hardware/index.md` | Regenerated (PDUs now listed) |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | Regenerated (power section + PDU side-rails) |
No `Makefile`, `.forgejo/workflows/docs.yml`, or `overview_config.yml` changes.
## Test plan
- **Unit — `validate_power`:** accept a valid feed; reject unknown `pdu`,
`pdu` pointing at a non-`pdu` kind, `outlet` of 0 / above `outlets`, a
malformed entry (non-mapping / missing keys), and a `pdu` file with
missing/zero/non-int `outlets`.
- **Unit — `render_power`:** PDU and device nodes present; a redundant device
has two incoming edges; returns `""` when no device has power; deterministic
for reordered input.
- **Integration — `generate`:** with valid fixtures the page contains the
`## Power` section and the mermaid fence; with a dangling `pdu` reference it
returns `1` and writes nothing.
- **Drift:** `make docs-check` exits 0 after regeneration (existing guard,
unchanged).
- **Visual:** `mkdocs build --strict` succeeds and the rack page shows the power
graph as a rendered diagram (not a raw code block), with mf00 showing two
incoming feeds.

View file

@ -1,136 +0,0 @@
# Rack Presentation Improvements Design
**Date:** 2026-06-24
**Status:** Approved
**Parent spec:** `notes/dev/specs/2026-06-24-rack-documentation-design.md` (extension)
## Goal
Improve the graphical presentation of the generated rack page: make the
elevation **interactive** (inline SVG, clickable boxes, hover tooltips), encode
device **status** visually, add a **legend**, and **polish** both the elevation
and the mermaid power/network graphs. No new data is modelled; this is a
rendering upgrade of what `gen_rack.py` already produces.
## Context
- `render_svg` writes a standalone `rack01-elevation.svg`, embedded in
`rack01.md` as `![Rack rack01 elevation](rack01-elevation.svg)` — i.e. an
`<img>`, which is a **flat image**: links and tooltips inside it do not work.
- `render_power`/`render_network` emit default-themed mermaid flowcharts with
uncoloured, non-clickable nodes.
- The occupancy table already links to host pages; the SVG and graphs do not.
- `md_in_html` is enabled in `mkdocs.yml`, so raw inline HTML/SVG in a Markdown
page is rendered. Mermaid is enabled (Phase 2).
- `use_directory_urls` is unset → defaults to **true**: pages build to
`/<path>/index.html`, so a host page is served at `/hardware/<host>/` and the
rack page at `/infrastructure/racks/rack01/`. The site is served at the domain
root (`docs.makerfloss.eu`).
- `KIND_COLORS` already maps kinds → fill colours. There is no status encoding,
legend, link, or tooltip today.
## Decisions
### A. Inline, interactive elevation
- `render_page` embeds the SVG markup **inline** in `rack01.md`, inside a
`<div class="rack-elevation"> … </div>` block, replacing the
`![…](rack01-elevation.svg)` image line. (`md_in_html` passes the raw block
through.)
- The standalone `rack01-elevation.svg` is **still generated** (identical markup,
still drift-checked) and offered below the diagram as a
`[Download SVG](rack01-elevation.svg)` link.
- The `<svg>` opening tag gains `style="max-width:100%;height:auto"` so it scales
on narrow screens.
- **Clickable boxes:** every device drawing (rail box, shelf-occupant box, 0U
rail bar, shelf strip) is wrapped in
`<a href="/hardware/<hostname>/"> … </a>`. URLs are **root-relative final
URLs** — mkdocs does **not** rewrite `.md`/source-relative hrefs inside raw
inline SVG, and `use_directory_urls` + domain-root hosting make
`/hardware/<host>/` correct and robust.
- **Tooltips:** the `<a>` wrapper's first child is an SVG `<title>` carrying full
details: `"<hostname> · <kind> · <status> · cluster: <cluster|—> · <placement>"`,
where `<placement>` is the U-range (rail item), `0U <left|right>` (0U rail), or
`<shelf>/<face>/slot <slot>` (mounted item). Text run through `_esc`.
### B. Status encoded as border
Fill stays the kind colour; the box **border** encodes `status` via a
`_status_stroke(status) -> (stroke, stroke_width, dash)` helper:
| status | stroke | width | dash |
|--------|--------|-------|------|
| in-use | `#333` | 1.5 | none |
| staging | `#333` | 1.5 | `4 2` (dashed) |
| broken | `#e15759` | 3 | none |
| spare | `#bbbbbb` | 1.5 | none |
| donated | `#bbbbbb` | 1.5 | none |
| (other/missing) | `#333` | 1.5 | none |
Applied to every device rect (rail `draw_device` and shelf-occupant boxes). The
shelf strip and the faint empty-U slots keep their existing styling.
### C. Legend
A legend strip is drawn at the **bottom of the SVG canvas**, below the columns;
the canvas height grows by a fixed `LEGEND_H` band:
- **Kind swatches:** for each `kind` actually present in the rack (sorted), a
small colour rect + the kind name.
- **Status key:** four small sample borders (solid, dashed, red-thick, grey) with
labels `in-use`, `staging`, `broken`, `spare`.
### D. Aesthetic polish
- **U-numbers in both gutters:** the existing left-gutter U-number column is
mirrored to the right of the rear column.
- **Column frame:** a thin outer stroke rectangle around each column's full body
(front and rear).
- **Mermaid graphs** (`render_power`/`render_network`):
- **Colour nodes by kind:** emit a `style <id> fill:<kind-colour>,stroke:#333,color:#ffffff`
line per node, looking the kind up from the rack items (`KIND_COLORS`,
falling back to `DEFAULT_COLOR`).
- **Clickable nodes:** emit `click <id> "/hardware/<host>/"` for every node whose
hostname resolves to a rack item; off-rack nodes get no click.
- Edge labels and layout are unchanged.
## Technical constraints (rationale carried into implementation)
- `<img>`-embedded SVG is inert → inlining is required for links/tooltips.
- mkdocs rewrites Markdown `.md` links but **not** raw `href`/`click` URLs inside
inline SVG or mermaid → emit final root-relative URLs `/hardware/<host>/`.
- `md_in_html` (already enabled) renders the raw `<div><svg>…</svg></div>` block.
## Integration
| File | Change |
|------|--------|
| `scripts/gen_rack.py` | helpers `_host_url`, `_status_stroke`, `_tooltip`; `render_svg` (status borders, `<a>`+`<title>` wrapping, both-gutter U-numbers, column frame, legend, `style=` on `<svg>`); `render_page` (inline SVG `<div>` + download link); `render_power`/`render_network` (per-node `style` + `click`) |
| `tests/test_gen_rack.py` | assertions for links, titles, status strokes, legend, both-gutter numbers, inline page block, mermaid style/click |
| `docs/infrastructure/racks/rack01.md`, `rack01-elevation.svg` | regenerated |
No `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or
`overview_config.yml` changes. Occupancy table and all validation unchanged.
## Test plan
- **`render_svg`:** output contains `<a href="/hardware/srv04/"`, a `<title>` with
the host details, a dashed stroke for a `staging` fixture and a thick red stroke
for a `broken` fixture, kind swatches for the kinds present, U-number text on
both sides; deterministic for reordered input.
- **`render_page`:** contains `<div class="rack-elevation">` and `<svg`, the
`[Download SVG](rack01-elevation.svg)` link, and **no** `![`-image embed of the
elevation.
- **`render_power`/`render_network`:** contain `style <id> fill:` lines coloured
by kind and `click <id> "/hardware/<host>/"` lines; still start with
` ```mermaid ` + `flowchart LR`.
- **Drift / build / visual:** `make docs-check` exits 0; `mkdocs build --strict`
passes; on the rendered page the elevation boxes are clickable, hovering shows
the tooltip, the legend is visible, and the mermaid nodes are coloured and
clickable.
## Out of scope
- The summary dashboard (U-utilisation / counts) — not selected.
- Dark-mode-specific palettes.
- A cluster colour system — cluster appears in the tooltip only.

View file

@ -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.

View file

@ -1,8 +0,0 @@
# Links of general relevance to the project
- https://nordicmedialab.dk/#hvorfor
- https://zensocial.dk/
- https://oase.app/oase/46ec46bf-5445-4a32-bb21-ada8a61b4b0a/join/8f84eb1a-1e07-478c-9153-6e21850141fe?spa=1
- https://meningspunktet.dk
- https://sustainablemanifesto.org/
- https://www.demai.tech/

View file

@ -1 +0,0 @@
# Ønske/skaffe-liste

View file

@ -1,2 +0,0 @@
-r requirements.txt
pytest==8.*

View file

@ -1,3 +0,0 @@
# sandbox/
Scratch space and pipeline fixtures. Files here are kept around as test inputs (e.g. for the Marp/Mermaid slide pipeline) — they are not meant to be read as documentation.

View file

@ -1,7 +0,0 @@
# scripts/
Build helpers for the documentation site.
- `gen_overview.py` — generates a category index table from per-item YAML frontmatter (currently used for `docs/hardware/index.md`). Configured by `overview_config.yml`. Run via `make docs-index`; CI drift-checks it via `make docs-check`.
- `mkdocs_hooks.py` — MkDocs build hook that renders a Specs table on each hardware host page from its frontmatter, sharing `fmt_*` helpers with `gen_overview.py` so the host page and the index row stay in sync.
- `overview_config.yml` — schema (required fields, enums) and table layout for `gen_overview.py`.

View file

@ -22,39 +22,11 @@ CONFIG_PATH = REPO_ROOT / "scripts" / "overview_config.yml"
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL) FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
# Shown at the bottom of every error report so a newcomer knows where to look.
GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/"
class SchemaError(Exception): class SchemaError(Exception):
pass pass
def _allowed_hint(field: str, enums: dict) -> str:
allowed = enums.get(field)
return f" Allowed values: {', '.join(map(str, allowed))}." if allowed else ""
def _example_value(field: str, enums: dict) -> str:
allowed = enums.get(field)
return str(allowed[0]) if allowed else "..."
def report_errors(errors: list[str], category: str) -> None:
"""Print a collected list of problems with orientation for newcomers."""
print(
f"\ngen_overview: found {len(errors)} problem(s) in the {category} docs:",
file=sys.stderr,
)
for err in errors:
print(f" ✗ {err}", file=sys.stderr)
print(
"\nFix the field(s) named above, then run 'make docs-index' again.\n"
f"Guide: {GUIDE_URL}",
file=sys.stderr,
)
def parse_frontmatter(path: Path) -> dict | None: def parse_frontmatter(path: Path) -> dict | None:
text = path.read_text(encoding="utf-8") text = path.read_text(encoding="utf-8")
m = FRONTMATTER_RE.match(text) m = FRONTMATTER_RE.match(text)
@ -70,117 +42,67 @@ def parse_frontmatter(path: Path) -> dict | None:
def validate(path: Path, fm: dict, cfg: dict) -> None: def validate(path: Path, fm: dict, cfg: dict) -> None:
enums = cfg.get("enums", {})
name = path.name
for field in cfg["required_fields"]: for field in cfg["required_fields"]:
if field not in fm: if field not in fm:
raise SchemaError( raise SchemaError(f"{path}: missing required field '{field}'")
f"{name}: missing required field '{field}'. Add a line like " for field, allowed in cfg.get("enums", {}).items():
f"'{field}: {_example_value(field, enums)}' to the frontmatter."
f"{_allowed_hint(field, enums)}"
)
for field, allowed in enums.items():
if field in fm and fm[field] not in allowed: if field in fm and fm[field] not in allowed:
raise SchemaError( raise SchemaError(
f"{name}: {field} {fm[field]!r} is not allowed. " f"{path}: {field}={fm[field]!r} not in {allowed}"
f"Use one of: {', '.join(map(str, allowed))}."
) )
key_field = cfg.get("key_field", "hostname")
if key_field not in fm:
raise SchemaError(
f"{name}: missing the '{key_field}' field (the device's id). It must "
f"match the filename, e.g. '{key_field}: {path.stem}'."
)
stem = path.stem stem = path.stem
value = fm[key_field] hostname = fm["hostname"]
if stem != value: if stem != hostname:
raise SchemaError( raise SchemaError(
f"{name}: '{key_field}: {value}' does not match the filename '{name}'. " f"{path}: filename stem {stem!r} != hostname {hostname!r}"
f"Rename the file to '{value}.md', or set {key_field} to '{stem}'."
) )
def fmt_cpu(fm: dict) -> str: def fmt_cpu(fm: dict) -> str:
model = fm.get("cpu") or "" model = fm.get("cpu", "")
cores = fm.get("cpu_cores") cores = fm.get("cpu_cores")
threads = fm.get("cpu_threads") threads = fm.get("cpu_threads")
suffix = "" suffix = ""
if isinstance(cores, int) and isinstance(threads, int) and threads != cores: if cores and threads and threads != cores:
suffix = f" · {cores}c/{threads}t" suffix = f" · {cores}c/{threads}t"
elif isinstance(cores, int): elif cores:
suffix = f" · {cores}c" suffix = f" · {cores}c"
return (str(model) + suffix).strip() return (model + suffix).strip()
def fmt_ram(fm: dict) -> str: def fmt_ram(fm: dict) -> str:
n = fm.get("ram_gb") n = fm.get("ram_gb")
if isinstance(n, int): return f"{n} GB" if isinstance(n, int) else ""
return f"{n} GB"
if isinstance(n, str) and n:
return n
return ""
def _fmt_size_gb(n: int) -> str:
if n >= 1000 and n % 1000 == 0:
return f"{n // 1000} TB"
if n >= 1000:
return f"{n / 1000:.1f} TB"
return f"{n} GB"
def fmt_storage(fm: dict) -> str: def fmt_storage(fm: dict) -> str:
drives = fm.get("storage")
if isinstance(drives, list) and drives:
parts = []
for d in drives:
gb = d.get("gb")
t = (d.get("type") or "").upper()
if isinstance(gb, int):
parts.append(f"{_fmt_size_gb(gb)} {t}".strip())
elif t:
parts.append(t)
return " + ".join(parts)
if isinstance(drives, str) and drives:
return drives
n = fm.get("storage_gb") n = fm.get("storage_gb")
t = fm.get("storage_type", "").upper() if fm.get("storage_type") else "" t = fm.get("storage_type", "").upper() if fm.get("storage_type") else ""
if not isinstance(n, int): if not isinstance(n, int):
return t # type alone if no capacity return t # type alone if no capacity
return f"{_fmt_size_gb(n)} {t}".strip() if n >= 1000 and n % 1000 == 0:
size = f"{n // 1000} TB"
elif n >= 1000:
size = f"{n / 1000:.1f} TB"
else:
size = f"{n} GB"
return f"{size} {t}".strip()
def fmt_nic(fm: dict) -> str: def fmt_nic(fm: dict) -> str:
g = fm.get("nic_gbps") g = fm.get("nic_gbps")
if g is None or g == "": if g is None:
return "" return ""
if isinstance(g, str): if isinstance(g, float) and not g.is_integer():
return g return f"{g} GbE"
return f"{int(g)} GbE"
def one(v: float | int) -> str:
if isinstance(v, float) and not v.is_integer():
return f"{v}"
return f"{int(v)}"
if isinstance(g, list):
if not g:
return ""
return "/".join(one(v) for v in g) + " GbE"
return f"{one(g)} GbE"
def cell(fm: dict, col: dict) -> str: def cell(fm: dict, col: dict) -> str:
kind = col.get("kind") kind = col.get("kind")
if kind == "key-link": if kind == "hostname-link":
v = fm[col["field"]] h = fm["hostname"]
return f"[{v}]({v}.md)" return f"[{h}]({h}.md)"
if kind == "url-link":
u = fm.get(col["field"], "")
if not u:
return ""
label = u.removeprefix("https://").removeprefix("http://")
return f"[{label}]({u})"
if kind == "cpu": if kind == "cpu":
return fmt_cpu(fm) return fmt_cpu(fm)
if kind == "ram": if kind == "ram":
@ -259,11 +181,7 @@ def main() -> int:
errors.append(str(e)) errors.append(str(e))
continue continue
if fm is None: if fm is None:
print( print(f"WARNING: {path}: no YAML frontmatter, skipping", file=sys.stderr)
f"WARNING: {path.name}: no '---' frontmatter block — skipping "
f"(it will not appear in the {args.category} index).",
file=sys.stderr,
)
continue continue
try: try:
validate(path, fm, cfg) validate(path, fm, cfg)
@ -273,7 +191,8 @@ def main() -> int:
items.append(fm) items.append(fm)
if errors: if errors:
report_errors(errors, args.category) for err in errors:
print(f"ERROR: {err}", file=sys.stderr)
return 1 return 1
output_file.parent.mkdir(parents=True, exist_ok=True) output_file.parent.mkdir(parents=True, exist_ok=True)

View file

@ -1,895 +0,0 @@
#!/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"}
SHELF_FACES = {"front", "rear"}
# Shown at the bottom of every error report so a newcomer knows where to look.
GUIDE_URL = "https://docs.makerfloss.eu/guides/editing-hardware-docs/"
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 name a rack, e.g. 'rack: rack01'.")
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 name the shelf it sits on, "
f"e.g. 'mounted_on: shf01'."
)
for forbidden in ("rack_u", "u_height", "rack_face"):
if forbidden in fm:
raise SchemaError(
f"{name}: a shelf-mounted device must not set '{forbidden}' — "
f"it takes its position from the shelf. Use 'shelf_face' and "
f"'shelf_slot' instead."
)
sface = fm.get("shelf_face")
if sface not in SHELF_FACES:
raise SchemaError(
f"{name}: shelf_face {sface!r} must be 'front' or 'rear'."
)
slot = fm.get("shelf_slot")
if not isinstance(slot, int) or slot < 1:
raise SchemaError(
f"{name}: 'shelf_slot' must be a whole number 1 or higher "
f"(got {slot!r})."
)
if "chassis_u" in fm:
cu = fm.get("chassis_u")
if isinstance(cu, bool) or not isinstance(cu, int) or cu < 1:
raise SchemaError(
f"{name}: 'chassis_u' is the device's height in U where it "
f"stands on the shelf — it must be a whole number 1 or "
f"higher (got {cu!r})."
)
return
face = fm.get("rack_face")
if face not in FACES:
raise SchemaError(
f"{name}: rack_face {face!r} is not valid. Use 'front', 'rear' or "
f"'both' for a U-mounted device, or 'left'/'right' for a 0U side rail."
)
if face in ZERO_U_FACES:
if "rack_u" in fm or "u_height" in fm:
raise SchemaError(
f"{name}: a side-rail device (rack_face: {face}) is 0U — remove "
f"'rack_u' and '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}: a {face}-mounted device needs whole-number 'rack_u' and "
f"'u_height' (e.g. 'rack_u: 12' and 'u_height: 2')."
)
if u < 1 or u > RACK_UNITS:
raise SchemaError(
f"{name}: rack_u={u} is outside the rack — it must be between 1 "
f"and {RACK_UNITS}."
)
if h < 1:
raise SchemaError(f"{name}: u_height={h} must be at least 1.")
if u + h - 1 > RACK_UNITS:
raise SchemaError(
f"{name}: a {h}U device starting at U{u} runs off the top of the rack "
f"(it would need U{u}–U{u + h - 1}, but the rack is only {RACK_UNITS}U). "
f"Lower 'rack_u' or 'u_height'."
)
def check_overlaps(items: list[dict]) -> None:
"""Raise if two items share a U on the same face within one rack."""
occupied: dict[tuple[str, int], str] = {}
for fm in items:
if "mounted_on" in fm:
continue
face = fm.get("rack_face")
if face in ZERO_U_FACES:
continue
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]} — two devices "
f"can't share the same U on the same face. Move one to a "
f"free U or to the other face."
)
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} — no device with that id is in "
f"this rack. Check the shelf's hostname."
)
if target.get("kind") != "shelf":
raise SchemaError(
f"{name}: mounted_on={shelf_name!r} is a {target.get('kind')!r}, "
f"not a shelf. Only kind:shelf devices can hold mounted gear."
)
if not isinstance(target.get("rack_u"), int) or not isinstance(
target.get("u_height"), int
):
raise SchemaError(
f"{name}: the shelf {shelf_name!r} has no position yet — give it "
f"'rack_u' and 'u_height' first."
)
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]} — each shelf face and slot holds "
f"one device."
)
occupied[key] = name
def load_hardware_index(hardware_dir: Path) -> dict[str, dict]:
"""Map hostname -> frontmatter for every hardware file (global peer lookup)."""
index: dict[str, dict] = {}
for path in sorted(hardware_dir.glob("*.md")):
if path.name == "index.md":
continue
fm = parse_frontmatter(path)
if fm is None:
continue
name = fm.get("hostname")
if isinstance(name, str) and name:
index[name] = fm
return index
def validate_links(items: list[dict], hw_index: dict[str, dict]) -> None:
"""Validate `links` cable declarations (rule 4).
Every links[].peer must resolve to a real hardware file (global lookup via
hw_index); peer_port must fall within the peer's declared `ports` when it
declares an integer count.
"""
for fm in items:
links = fm.get("links")
if links is None:
continue
name = fm.get("hostname", "?")
if not isinstance(links, list):
raise SchemaError(
f"{name}: 'links' must be a list of cables like "
f"'- {{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
for link in links:
if not isinstance(link, dict):
raise SchemaError(
f"{name}: each 'links' entry must look like "
f"'{{ local: eth0, peer: sw01, peer_port: 1 }}'."
)
local = link.get("local")
peer = link.get("peer")
peer_port = link.get("peer_port")
if not isinstance(local, str) or not local:
raise SchemaError(
f"{name}: a 'links' entry needs a 'local' port name, "
f"e.g. 'local: eth0'."
)
if not isinstance(peer, str) or not peer:
raise SchemaError(
f"{name}: a 'links' entry needs a 'peer' device, "
f"e.g. 'peer: sw01'."
)
if not isinstance(peer_port, int):
raise SchemaError(
f"{name}: the link to {peer} needs a whole-number 'peer_port'."
)
target = hw_index.get(peer)
if target is None:
raise SchemaError(
f"{name}: link points at peer={peer!r}, but no hardware file "
f"has that id. Check the peer hostname."
)
ports = target.get("ports")
if isinstance(ports, int) and (peer_port < 1 or peer_port > ports):
raise SchemaError(
f"{name}: peer_port {peer_port} doesn't exist on {peer} — it "
f"has {ports} port(s) (valid 1–{ports})."
)
def _pdu_index(items: list[dict]) -> dict[str, dict]:
"""Map hostname -> frontmatter for every kind:pdu item."""
return {
fm.get("hostname"): fm
for fm in items
if fm.get("kind") == "pdu"
}
def validate_power(items: list[dict]) -> None:
"""Validate PDU outlet declarations and `power` feeds within one rack.
Rule 3: every power[].pdu resolves to a kind:pdu file, and outlet is
within that PDU's `outlets` count.
"""
pdus = _pdu_index(items)
for name, fm in pdus.items():
outlets = fm.get("outlets")
if not isinstance(outlets, int) or outlets < 1:
raise SchemaError(
f"{name}: a PDU must say how many outlets it has, e.g. 'outlets: 8'."
)
for fm in items:
feeds = fm.get("power")
if feeds is None:
continue
name = fm.get("hostname", "?")
if not isinstance(feeds, list):
raise SchemaError(
f"{name}: 'power' must be a list of feeds like "
f"'- {{ pdu: pdu01, outlet: 1 }}'."
)
for feed in feeds:
if not isinstance(feed, dict):
raise SchemaError(
f"{name}: each 'power' feed must look like "
f"'{{ pdu: pdu01, outlet: 1 }}'."
)
pdu = feed.get("pdu")
outlet = feed.get("outlet")
if not isinstance(pdu, str) or not pdu:
raise SchemaError(
f"{name}: a 'power' feed needs a 'pdu' name, "
f"e.g. '{{ pdu: pdu01, outlet: 1 }}'."
)
if not isinstance(outlet, int):
raise SchemaError(
f"{name}: the 'power' feed to {pdu} needs a whole-number 'outlet'."
)
target = pdus.get(pdu)
if target is None:
raise SchemaError(
f"{name}: power feed points at pdu={pdu!r}, but no kind:pdu "
f"device has that id. Check the PDU hostname."
)
count = target["outlets"]
if outlet < 1 or outlet > count:
raise SchemaError(
f"{name}: outlet {outlet} doesn't exist on {pdu} — it has "
f"{count} outlet(s) (valid 1–{count})."
)
def _esc(s: object) -> str:
return str(s).replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
STATUS_STROKE: dict[str, tuple[str, float, str]] = {
"in-use": ("#333333", 1.5, ""),
"staging": ("#333333", 1.5, "4 2"),
"broken": ("#e15759", 3, ""),
"spare": ("#bbbbbb", 1.5, ""),
"donated": ("#bbbbbb", 1.5, ""),
}
DEFAULT_STATUS_STROKE: tuple[str, float, str] = ("#333333", 1.5, "")
def _status_stroke(status: object) -> tuple[str, float, str]:
return STATUS_STROKE.get(status, DEFAULT_STATUS_STROKE)
def _stroke_attrs(status: object) -> str:
stroke, sw, dash = _status_stroke(status)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
return f'stroke="{stroke}" stroke-width="{sw}"{dash_attr}'
def _host_url(host: object) -> str:
return f"/hardware/{host}/"
def _placement(fm: dict) -> str:
if "mounted_on" in fm:
return (
f"{fm.get('mounted_on', '?')}/{fm.get('shelf_face', '')}/"
f"slot {fm.get('shelf_slot', '')}"
)
face = fm.get("rack_face")
if face in ZERO_U_FACES:
return f"0U {face}"
u = fm.get("rack_u")
h = fm.get("u_height")
if isinstance(u, int) and isinstance(h, int):
return f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
return "?"
def _tooltip(fm: dict) -> str:
host = fm.get("hostname", "?")
return _esc(
f"{host} · {fm.get('kind', '')} · {fm.get('status', '')} · "
f"cluster: {fm.get('cluster', '—')} · {_placement(fm)}"
)
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
LEGEND_H = 56
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 + LEGEND_H
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
rear_x = front_x + COL_W + GAP
right_gutter_x = rear_x + COL_W
width = right_gutter_x + LABEL_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'style="max-width:100%;height:auto" '
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>'
)
for u in range(1, RACK_UNITS + 1):
y = u_y(u)
p.append(
f'<text x="{right_gutter_x + 4}" y="{y + 14}" text-anchor="start" '
f'fill="#999">{u}</text>'
)
for col_x in (front_x, rear_x):
p.append(
f'<rect x="{col_x}" y="{top}" width="{COL_W}" height="{body_h}" '
f'fill="none" stroke="#999" stroke-width="1.5"/>'
)
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
f'height="{box_h - 2}" rx="3" fill="{color}" '
f"{_stroke_attrs(fm.get('status'))}/>"
)
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>"
)
p.append("</a>")
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'<a href="{_host_url(name)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
p.append(
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
f"fill=\"{color}\" {_stroke_attrs(fm.get('status'))}/>"
)
p.append(
f'<text x="{cx}" y="{cy}" text-anchor="middle" '
f'dominant-baseline="central" fill="#ffffff" '
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
)
p.append("</a>")
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
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", "?")
# The device stands on the shelf strip and rises chassis_u U's
# upward; without chassis_u it fills the shelf block (legacy).
dev_u = m.get("chassis_u")
if not isinstance(dev_u, int) or isinstance(dev_u, bool) or dev_u < 1:
dev_u = h
dev_h = dev_u * U_H - SHELF_STRIP_H
by = strip_y - dev_h
p.append(f'<a href="{_host_url(mname)}">')
p.append(f"<title>{_tooltip(m)}</title>")
p.append(
f'<rect x="{bx + 1}" y="{by + 1}" width="{bw - 2}" '
f'height="{dev_h - 2}" rx="3" fill="{mcolor}" '
f"{_stroke_attrs(m.get('status'))}/>"
)
p.append(
f'<text x="{bx + bw // 2}" y="{by + dev_h // 2 + 4}" '
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
)
p.append("</a>")
p.append(f'<a href="{_host_url(sname)}">')
p.append(f"<title>{_tooltip(fm)}</title>")
for col_x in (front_x, rear_x):
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>'
)
p.append("</a>")
# Paint order (bottom → top): shelves and their towers first, then
# U-mounted devices (so a rail-mounted PDU stays visible over a tower),
# then 0U side rails.
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
draw_shelf(fm)
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)
for idx, fm in enumerate(left_items):
draw_rail(fm, PAD + idx * RAIL_W)
for idx, fm in enumerate(right_items):
draw_rail(fm, right_gutter_x + LABEL_W + idx * RAIL_W)
legend_y = top + body_h + PAD + 8
p.append(
f'<text x="{front_x}" y="{legend_y}" font-weight="bold">Legend</text>'
)
present_kinds = sorted({i.get("kind", "") for i in items if i.get("kind")})
kx = front_x
ky = legend_y + 18
for kind in present_kinds:
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
p.append(
f'<rect x="{kx}" y="{ky - 10}" width="12" height="12" '
f'fill="{color}" stroke="#333"/>'
)
p.append(f'<text x="{kx + 16}" y="{ky}">{_esc(kind)}</text>')
kx += 28 + 7 * len(kind)
sx = front_x
sy = ky + 18
for label in ("in-use", "staging", "broken", "spare"):
stroke, sw, dash = _status_stroke(label)
dash_attr = f' stroke-dasharray="{dash}"' if dash else ""
p.append(
f'<rect x="{sx}" y="{sy - 10}" width="12" height="12" '
f'fill="#ffffff" stroke="{stroke}" stroke-width="{sw}"{dash_attr}/>'
)
p.append(f'<text x="{sx + 16}" y="{sy}">{_esc(label)}</text>')
sx += 28 + 7 * len(label)
p.append("</svg>")
return "\n".join(p) + "\n"
def _node_id(name: str) -> str:
"""A mermaid-safe node id derived from a hostname."""
return re.sub(r"[^0-9A-Za-z]", "_", str(name))
def render_power(rack: str, items: list[dict]) -> str:
"""Return a mermaid power-distribution flowchart, or '' if no feeds.
Assumes `validate_power(items)` has already passed: every referenced PDU
resolves to a kind:pdu item with a positive-int `outlets`. `generate`
guarantees this by validating before any render call.
"""
powered = [fm for fm in items if fm.get("power")]
if not powered:
return ""
pdus = _pdu_index(items)
edges: list[tuple[str, int, str]] = []
for fm in powered:
device = fm.get("hostname", "?")
for feed in fm["power"]:
edges.append((feed["pdu"], feed["outlet"], device))
edges.sort()
lines: list[str] = ["```mermaid", "flowchart LR"]
for pdu in sorted(pdus):
outlets = pdus[pdu].get("outlets")
lines.append(f' {_node_id(pdu)}["{pdu}<br/>{outlets} outlets"]')
devices = sorted(
powered,
key=lambda i: (
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
i.get("hostname", ""),
),
)
for fm in devices:
device = fm.get("hostname", "?")
lines.append(f' {_node_id(device)}["{device}"]')
for pdu, outlet, device in edges:
lines.append(
f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}"
)
by_host = {fm.get("hostname"): fm for fm in items}
node_hosts = sorted(set(pdus) | {fm.get("hostname", "?") for fm in powered})
for host in node_hosts:
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\n"
def render_network(rack: str, items: list[dict]) -> str:
"""Return a mermaid network-cabling flowchart, or '' if no links.
Assumes `validate_links` has already passed: every link has a non-empty
`local`/`peer` and an integer `peer_port`, and `peer` resolves to a real
hardware file. `generate` validates before any render call.
"""
linked = [fm for fm in items if fm.get("links")]
if not linked:
return ""
by_host = {fm.get("hostname"): fm for fm in items}
edges: list[tuple[str, str, str, int, object]] = []
nodes: set[str] = set()
for fm in linked:
source = fm.get("hostname", "?")
nodes.add(source)
for link in fm["links"]:
peer = link["peer"]
nodes.add(peer)
edges.append(
(source, link["local"], peer, link["peer_port"],
link.get("speed_gbps"))
)
edges.sort(key=lambda e: (e[0], e[1], e[2], e[3]))
def node_label(name: str) -> str:
fm = by_host.get(name)
kind = fm.get("kind") if fm else None
if kind in ("switch", "patch-panel"):
return f"{name}<br/>{kind}"
return name
lines: list[str] = ["```mermaid", "flowchart LR"]
for name in sorted(nodes):
lines.append(f' {_node_id(name)}["{node_label(name)}"]')
for source, local, peer, peer_port, speed in edges:
label = f"{local} → p{peer_port}"
if speed is not None:
label += f" · {speed}G"
lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}")
for host in sorted(nodes):
kind = by_host.get(host, {}).get("kind", "")
color = KIND_COLORS.get(kind, DEFAULT_COLOR)
nid = _node_id(host)
lines.append(
f" style {nid} fill:{color},stroke:#333,color:#ffffff"
)
if host in by_host:
lines.append(f' click {nid} "{_host_url(host)}"')
lines.append("```")
return "\n".join(lines) + "\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('<div class="rack-elevation">')
lines.append(render_svg(rack, items).rstrip())
lines.append("</div>")
lines.append("")
lines.append(f"[Download SVG]({rack}-elevation.svg)")
lines.append("")
power = render_power(rack, items)
if power:
lines.append("## Power")
lines.append("")
lines.append(power.rstrip())
lines.append("")
network = render_network(rack, items)
if network:
lines.append("## Network")
lines.append("")
lines.append(network.rstrip())
lines.append("")
lines.append("## Occupancy")
lines.append("")
lines.append("| U | Device | Kind | Face | Status |")
lines.append("|---|---|---|---|---|")
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"]
cu = fm.get("chassis_u")
if isinstance(cu, int) and not isinstance(cu, bool) and cu >= 1:
base = su + sh - 1 # the shelf's bottom U; towers rise from it
top = base - cu + 1
urange = f"U{base}" if cu == 1 else f"U{top}–U{base}"
else:
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))
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def report_errors(errors: list[str]) -> None:
"""Print a collected list of problems with orientation for newcomers."""
print(
f"\ngen_rack: found {len(errors)} problem(s) in docs/hardware/:",
file=sys.stderr,
)
for err in errors:
print(f" ✗ {err}", file=sys.stderr)
print(
"\nEach line is '<device>: what's wrong'. Fix the named frontmatter "
"field(s),\nthen run 'make docs-index' again.\n"
f"Guide: {GUIDE_URL}",
file=sys.stderr,
)
def generate(hardware_dir: Path, output_dir: Path) -> int:
items = load_rack_items(hardware_dir)
hw_index = load_hardware_index(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)
validate_power(ritems)
validate_links(ritems, hw_index)
check_shelves(ritems)
except SchemaError as e:
errors.append(f"{rack}: {e}")
if errors:
report_errors(errors)
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())

View file

@ -1,54 +0,0 @@
"""MkDocs build hook: render a Specs section on each hardware or service page from its YAML frontmatter."""
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from gen_overview import fmt_cpu, fmt_nic, fmt_ram, fmt_storage # noqa: E402
def _table(rows: list[tuple[str, str]]) -> str:
lines = ["| Field | Value |", "|---|---|"]
for label, value in rows:
if not value:
continue
lines.append(f"| {label} | {value} |")
return "\n".join(lines)
def _hardware_page(meta: dict, body: str) -> str:
rows = [
("Location", meta.get("location")),
("CPU", fmt_cpu(meta)),
("RAM", fmt_ram(meta)),
("Storage", fmt_storage(meta)),
("NIC", fmt_nic(meta)),
("Status", meta.get("status")),
]
return f"# {meta['hostname']}\n\n## Specs\n\n{_table(rows)}\n\n{body}"
def _service_page(meta: dict, body: str) -> str:
url = meta.get("url")
upstream = meta.get("upstream")
rows = [
("Kind", meta.get("kind")),
("Host", meta.get("host")),
("URL", f"[{url}]({url})" if url else ""),
("Tech", meta.get("tech")),
("Upstream", f"[{upstream}]({upstream})" if upstream else ""),
("TLS", meta.get("tls")),
("Status", meta.get("status")),
]
return f"# {meta['name']}\n\n## Service\n\n{_table(rows)}\n\n{body}"
def on_page_markdown(markdown, page, config, files): # noqa: ARG001
meta = page.meta or {}
src = (page.file.src_uri or page.file.src_path or "").replace("\\", "/")
if src.startswith("hardware/") and meta.get("hostname"):
return _hardware_page(meta, markdown)
if src.startswith("services/") and meta.get("name"):
return _service_page(meta, markdown)
return markdown

View file

@ -8,14 +8,13 @@ hardware:
title: "Hardware Overview" title: "Hardware Overview"
source_dir: docs/hardware source_dir: docs/hardware
output_file: docs/hardware/index.md output_file: docs/hardware/index.md
key_field: hostname
required_fields: required_fields:
- hostname - hostname
- kind - kind
- status - status
enums: enums:
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm, wan] kind: [server, laptop, sbc, switch, ap, desktop]
status: [in-use, staging, spare, broken, donated] status: [in-use, spare, broken, donated]
storage_type: [nvme, ssd, hdd, mixed] storage_type: [nvme, ssd, hdd, mixed]
group_by: kind group_by: kind
# Human-friendly H2 names per group_by value. Anything missing falls back # Human-friendly H2 names per group_by value. Anything missing falls back
@ -27,48 +26,13 @@ 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
wan: WAN uplinks
sort_by: hostname sort_by: hostname
columns: columns:
- { header: Hostname, kind: key-link, field: hostname } - { header: Hostname, kind: hostname-link }
- { header: Model, field: model }
- { header: Location, field: location } - { header: Location, field: location }
- { header: CPU, kind: cpu } - { header: CPU, kind: cpu }
- { header: RAM, kind: ram } - { header: RAM, kind: ram }
- { header: Storage, kind: storage } - { header: Storage, kind: storage }
- { header: NIC, kind: nic } - { header: NIC, kind: nic }
- { header: Status, field: status } - { header: Status, field: status }
services:
title: "Services Overview"
source_dir: docs/services
output_file: docs/services/index.md
key_field: name
required_fields:
- name
- kind
- status
enums:
kind: [web-app, static-site, dns, slide-builder, library, reverse-proxy, mail]
status: [in-use, staging, planned, broken, decommissioned]
group_by: kind
group_titles:
web-app: Web applications
static-site: Static sites
dns: DNS
slide-builder: Slide builders
library: Libraries
reverse-proxy: Reverse proxies
mail: Mail
sort_by: name
columns:
- { header: Name, kind: key-link, field: name }
- { header: URL, kind: url-link, field: url }
- { header: Host, field: host }
- { header: Tech, field: tech }
- { header: Status, field: status }

View file

@ -1,365 +0,0 @@
---
marp: true
theme: gaia
class: invert
paginate: true
title: How to implement a TAPPaaS module
description: Live build of the podman module — OrangeMaker, 24 August
---
<style>
section { font-size: 26px; }
h1 { font-size: 1.5em; }
h2 { font-size: 1.15em; }
pre { font-size: 0.7em; line-height: 1.35; }
table { font-size: 0.78em; }
th, td { padding: 0.2em 0.55em; }
/* Unfinished content, loud on purpose: an unfinished deck should never be
presented by accident. Written in the markdown as `TODO: ...`. */
code.todo { color: #ff8a80; font-weight: 600; }
/* Mermaid renders at its natural size, which is far too small on a projector. */
div.mermaid { display: flex; justify-content: center; width: 100%; }
div.mermaid svg { width: 100% !important; height: auto !important; max-height: 500px; }
section.diagram h2 { margin-bottom: 0.1em; }
</style>
<!-- _paginate: false -->
# Getting a module onto TAPPaaS
Live build of a **container host** — Podman, with a console
OrangeMaker · 24 August
<!--
Slides are the map; the terminal is the territory. Everything here has a live
counterpart — keep the deck moving and spend the time in the shell.
-->
---
## The plan
1. **What TAPPaaS is** — a few diagrams, ten minutes, no deeper
2. **What a module actually is** — a json contract and three scripts
3. **Build one live** — the `lab1` environment, then `podman` into it
4. **Prove it works** — tests, then the web console
5. **You get a login** — your own identity on this system
You leave with: a mental model, a module you watched get built, and an account.
---
<!-- _class: invert diagram -->
## The shape of a TAPPaaS site
```mermaid
flowchart LR
sat["satellite VPS<br/>public IP · ingress<br/>off-site backup"]
subgraph z0["mgmt · zone mgmt"]
cicd["tappaas-cicd"]
net["network"]
clu["cluster"]
bak["backup"]
idp["identity"]
end
subgraph z1["default · zone srv"]
nc["nextcloud"]
ha["home-assistant"]
end
subgraph z2["lab1 · zone lab1"]
pod["podman — today"]
end
sat --> net
cicd --> nc
cicd --> pod
```
---
## The words we will use
| Word | Meaning |
| --- | --- |
| **Module** | The smallest deployable unit — its own VM, its own json contract. Foundation *and* apps are modules. |
| **Environment** | A tenant. `mgmt` and one named after the site always exist; add more freely. Today we add `lab1`. |
| **Zone** | A VLAN with a firewall policy. Modules land in their environment's zone. |
| **Mothership** | `tappaas-cicd` — where the managers run and where you type. |
| **Satellite** | Optional VPS, for sites with no usable public IP. |
Everything is a module. There is one model to learn, not eight.
---
<!-- _class: invert diagram -->
## Developing modules: dev instance, private branch
```mermaid
flowchart RL
subgraph Development["Development repository"]
dev_main["main"]
end
subgraph Upstream["Upstream repository"]
up_main["main"]
up_stable["stable"]
up_main -->|merge| up_stable
end
subgraph InstanceDev["TAPPaaS instance · dev"]
localDev["clone of Upstream"]
localDev_dev["clone of Development"]
end
subgraph InstanceProd["TAPPaaS instance · prod"]
localProd["clone of Upstream"]
end
localProd -->|pull| up_stable
localDev -->|pull| up_main
localDev_dev -->|push| dev_main
dev_main -->|PR| up_main
```
---
<!-- _class: invert diagram -->
## Our setup tonight
```mermaid
flowchart RL
subgraph t["TAPPaaS · codeberg"]
stable["stable"]
main["main"]
end
subgraph c["Community · codeberg"]
cm["main"]
end
subgraph f["forgejo.makerfloss.eu"]
fm["main"]
end
subgraph inst["Our TAPPaaS · tappaas-cicd"]
lt["clone of TAPPaaS"]
lc["clone of Community"]
lf["clone of MakerFLOSS"]
end
lt -->|pull| main
lc -->|pull| cm
lf -->|pull| fm
classDef tracked stroke:#ff8a80,stroke-width:3px
class main tracked
```
We track **`main`**, not `stable` — this is a lab, we want the new things.
---
## A module is a contract and three scripts
```text
podman/
├── podman.json # the contract — what it is, needs, provides
├── install.sh # put the software in the VM (once)
├── update.sh # keep it patched (on schedule)
├── test.sh # prove it still works (gates updates)
├── README.md # what it is
└── INSTALL.md # what automation cannot do for you
```
That is the whole surface. Everything else is the platform's job.
---
## The contract, for real
```json
{
"description": "Podman host with a Portainer console, TAPPaaS login",
"vmname": "podman", "vmid": 812,
"dependsOn": ["cluster:vm", "templates:debian", "backup:vm",
"network:proxy", "identity:identity"],
"identity": { "oidcRedirectPaths": ["/"],
"secretsEnv": "/etc/secrets/podman.env" },
"config": {
"cluster:vm": { "cores": 2, "memory": "2048", "diskSize": "32G",
"image": "debian-13-generic-amd64.qcow2" },
"network:proxy": { "proxyPort": 9443, "proxyUpstreamTls": true }
}
}
```
`dependsOn` is the whole trick: five services, declared — not scripted.
---
## `install.sh` — called once, at add
```bash
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
exec "${SCRIPT_DIR}/update.sh" "$@"
```
By the time it runs: `cluster:vm` built the VM, `templates:debian` apt-upgraded it,
`network:proxy` published the name — and `identity:identity` already created the
OIDC application and dropped its client credentials on the VM.
Nothing here is install-only, so **install *is* update**. Five lines is a perfectly
good `install.sh`.
---
## `update.sh` — where the work is
Runs on the **mothership**, not on the VM. It:
1. finds the node hosting the VM via `pvesh`, resolves its IP through the **guest agent**
2. `apt install podman`, then enables **both** sockets: the rootful one *is* the
Docker-compatible API Portainer speaks; the rootless one is for people who ssh in
3. pulls `portainer-ce:lts` and runs it against the socket + a named volume
4. creates a **break-glass admin**, password into `/etc/secrets/podman-admin`
5. reads `/etc/secrets/podman.env`, fetches the OIDC **discovery document**,
and `PUT`s the three endpoints into `/api/settings` → login becomes OAuth
Re-runnable throughout: the container is recreated only when the image digest
actually changed, and an existing admin password is never regenerated.
---
## `test.sh` — the one that earns trust
| Check | How |
| --- | --- |
| VM reachable | IP resolves via the guest agent |
| container API | rootful `podman.socket` is active |
| app running | `podman container inspect` says `running` |
| API alive | `/api/status` returns a version |
| **identity intact** | `/api/settings/public` reports method `3` = OAuth |
That last one is the check worth stealing. It catches the failure nobody notices:
identity quietly falling back to local accounts, so the app still *works* while no
longer being the login you thought it was.
Run on demand, and again before every update is merged. If `test.sh` is honest,
unattended updates are safe — that is the entire deal.
---
## What you did not have to write
- A VM — `cluster:vm` built it from the Debian 13 cloud image
- OS prep — `templates:debian` did apt + guest agent
- A VLAN, an interface, DHCP, firewall rules — the zone came with the environment
- `https://lab1.makerfloss.eu`, valid cert — `network:proxy`
- An OIDC application, a group binding and client credentials — `identity:identity`
- Nightly backup to PBS, scheduled updates, health reporting
A few lines of json bought all of it.
---
## Demo 1 — an environment of our own
```bash
network-manager add lab1 --from-zone makerfloss # --check first for a dry run
network-manager show lab1 # vlan 299, 10.2.99.0/24, access-to
network-manager reconcile # dry-run: drift on all 4 planes
environment-manager add lab1 --display "MakerFLOSS lab" --owner makerfloss \
--zone lab1 --domain lab1.makerfloss.eu
environment-manager show lab1
```
`add` reconciles every plane itself. A tenant with its own VLAN, firewall
posture and DNS names — `lab1.makerfloss.eu` outside, `lab1.internal` inside.
<!-- Show zones.json before/after, and the OPNsense interface appearing. -->
---
## Demo 2 — install the module
```bash
site-manager repository add forgejo.makerfloss.eu/TAPPaaS/makerfloss --branch main
module-manager module add podman --environment lab1 \
--proxyDomain lab1.makerfloss.eu # publish at the environment's own name
module-manager module show podman-lab1 # note the -lab1 suffix
```
Outside the default environment the module is **`podman-lab1`** — `add` takes the
base name, everything after it takes the effective one. `--proxyDomain` makes it
the environment's **gateway** instead of `podman.lab1.makerfloss.eu`.
Watch the order: dependencies resolve first, then the VM, then the network, then
`install.sh`. Install order is **computed** from every module's `dependsOn` — nobody
maintains a list.
<!-- Expect a few minutes. Good moment for questions. -->
---
## Demo 3 — prove it, then look at it
```bash
module-manager module test podman-lab1
```
Then open **`https://lab1.makerfloss.eu`** and press **Sign in**.
- No login form. You are already you — Authentik, via OIDC.
- Not in `devops`? No console. The group binding **is** the access gate.
- Other machines in `lab1` join as Environments: one agent container on `:9001`.
Create a container. Start it. Read its logs. From a browser, as yourself.
---
## Where this module lives
```text
makerfloss/src/containers/podman/
```
One sibling next to it: `komodo/` — the GPL alternative, scripts still to write.
Podman is the engine; Portainer is the GUI running *as a container on it*.
Three homes for a module, all first-class:
| Home | For |
| --- | --- |
| **TAPPaaS** repo, by pull request | modules the whole project should carry |
| **Community** repo | yours, shared, no gatekeeping |
| **Private** repo — `forgejo.makerfloss.eu/TAPPaaS/makerfloss` | ours, today's podman |
Adding a repository is one `site-manager repository add`.
---
## Your turn — identity on this TAPPaaS
```bash
people-manager user add <you> --email <you>@example.org \
--roles user --groups devops
people-manager reconcile # preview
people-manager reconcile --apply # push to Authentik
authentik-manager user-recovery-link <you> # one-time URL: set your own password
```
One login per person, roles via groups. Nobody types a password into a chat window.
<!-- Do these live, one per participant, while the podman VM builds. -->
---
## Take it further
- **Develop a module** — tappaas.org/generated/develop-a-module
- **Every json field** — tappaas.org/generated/schemas
- **Zones and the access model** — tappaas.org/generated/zones
- **Repository topology** — tappaas.org/generated/design/cicd-git
- **This deck** — slides.makerfloss.eu/tappaas/how-to/new-module
Pick an app you want on a sovereign box. Open an issue first — someone may already
be packaging it.

Some files were not shown because too many files have changed in this diff Show more