Compare commits
No commits in common. "main" and "feat/hardware-overview" have entirely different histories.
main
...
feat/hardw
96 changed files with 501 additions and 9142 deletions
|
|
@ -15,10 +15,10 @@ jobs:
|
|||
volumes:
|
||||
- /srv/docs-makerfloss/html:/output
|
||||
steps:
|
||||
- name: Install git, rsync, nodejs for actions/checkout
|
||||
- name: Install git for actions/checkout
|
||||
run: |
|
||||
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
|
||||
uses: actions/checkout@v4
|
||||
|
|
@ -26,22 +26,14 @@ jobs:
|
|||
- name: Install Python dependencies
|
||||
run: pip install --quiet -r requirements.txt
|
||||
|
||||
- name: Install dev dependencies and run tests
|
||||
run: |
|
||||
pip install --quiet -r requirements-dev.txt
|
||||
pytest -q
|
||||
- name: Regenerate hardware index
|
||||
run: python3 scripts/gen_overview.py --category hardware
|
||||
|
||||
- name: Regenerate hardware and services indices
|
||||
- name: Fail on drift in docs/hardware/index.md
|
||||
run: |
|
||||
python3 scripts/gen_overview.py --category hardware
|
||||
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
|
||||
if ! git diff --exit-code docs/hardware/index.md; then
|
||||
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."
|
||||
exit 1
|
||||
fi
|
||||
|
|
@ -52,16 +44,3 @@ jobs:
|
|||
- name: Publish to /output (main only)
|
||||
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
|
||||
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
|
||||
|
|
|
|||
20
CLAUDE.md
20
CLAUDE.md
|
|
@ -8,7 +8,7 @@ This is a **documentation-only repository** for the MakerFLOSS initiative at Ora
|
|||
|
||||
## 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)
|
||||
- **Git**: Trunk-based development, feature branches, simple commit messages
|
||||
|
|
@ -17,23 +17,15 @@ From `notes/todo/2026-04-14-todo.md`:
|
|||
- **AI**: Allowed but reviewed; no secrets in commits
|
||||
- **Decisions**: Lightweight markdown decision logs
|
||||
- **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
|
||||
|
||||
```
|
||||
docs/ # everything here is built and shipped to 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 (build-slides.sh)
|
||||
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)
|
||||
docs/
|
||||
infrastruktur/ # Infrastructure documentation (VPS, DNS, etc.)
|
||||
kommunikation/ # Communication materials (Facebook posts, announcements)
|
||||
møder/ # Meeting notes
|
||||
todo/ # Task lists and working norms
|
||||
```
|
||||
|
||||
## Infrastructure
|
||||
|
|
|
|||
26
Makefile
26
Makefile
|
|
@ -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:
|
||||
@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-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 " test Run the Python unit tests (pytest)"
|
||||
|
||||
docs-index:
|
||||
python3 scripts/gen_overview.py --category hardware
|
||||
python3 scripts/gen_overview.py --category services
|
||||
python3 scripts/gen_rack.py
|
||||
|
||||
docs-build:
|
||||
mkdocs build --strict
|
||||
|
|
@ -22,22 +19,7 @@ docs-serve:
|
|||
|
||||
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/ \
|
||||
|| { \
|
||||
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; \
|
||||
}
|
||||
git diff --exit-code docs/hardware/index.md
|
||||
|
||||
slides:
|
||||
./build-slides.sh
|
||||
|
||||
test:
|
||||
pytest -q
|
||||
|
|
|
|||
56
README.md
56
README.md
|
|
@ -1,57 +1,3 @@
|
|||
# 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.
|
||||
|
||||
- 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).
|
||||
Første midlertidige repo til Orange Makerspaces MakerFLOSS initiativ.
|
||||
|
|
@ -15,7 +15,7 @@ find "$OUTPUT_DIR" -maxdepth 1 -name "*.html" -delete
|
|||
SLIDES=()
|
||||
while IFS= read -r f; do
|
||||
SLIDES+=("$f")
|
||||
done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" 2>/dev/null || true)
|
||||
done < <(grep -rl "^marp: true" "$REPO_ROOT/docs" --include="*.md" --exclude-dir=superpowers 2>/dev/null || true)
|
||||
|
||||
if [ ${#SLIDES[@]} -eq 0 ]; then
|
||||
echo "No marp presentations found in docs/."
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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` | `srv05` | |
|
||||
| `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
25
docs/hardware/fisi.md
Normal 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.
|
||||
|
|
@ -2,66 +2,15 @@
|
|||
|
||||
_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 |
|
||||
|---|---|---|---|---|---|---|
|
||||
| [pp01](pp01.md) | | | | | | 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 |
|
||||
| Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [tembo](tembo.md) | ThinkPad T480 | Orange Makerspace (kiosk) | Intel Core i5-8350U · 4c/8t | 16 GB | 512 GB NVME | 1 GbE | in-use |
|
||||
|
||||
## Servers
|
||||
|
||||
| Hostname | 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 |
|
||||
| [nas01](nas01.md) | The pile | ? | ? | ? | ? | staging |
|
||||
| [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 | ? | ? | ? | ? | 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 |
|
||||
|
||||
## 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 |
|
||||
|
||||
## 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 |
|
||||
| Hostname | Model | Location | CPU | RAM | Storage | NIC | Status |
|
||||
|---|---|---|---|---|---|---|---|
|
||||
| [fisi](fisi.md) | HP MicroServer Gen10 Plus | home rack | Xeon E-2226G · 6c/12t | 64 GB | 8 TB HDD | 1 GbE | in-use |
|
||||
| [makerfloss](makerfloss.md) | Hetzner CX22 | Hetzner HEL1 (cloud) | AMD EPYC (shared vCPU) · 2c | 4 GB | 40 GB NVME | 1 GbE | in-use |
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
---
|
||||
hostname: makerfloss.eu
|
||||
hostname: makerfloss
|
||||
kind: server
|
||||
status: in-use
|
||||
model: Hetzner CX22
|
||||
location: Hetzner HEL1 (cloud)
|
||||
cpu: AMD EPYC (shared vCPU)
|
||||
cpu_cores: 2
|
||||
|
|
@ -12,9 +13,12 @@ storage_type: nvme
|
|||
nic_gbps: 1
|
||||
---
|
||||
|
||||
## Notes
|
||||
# makerfloss
|
||||
|
||||
Hetzner Cloud VPS running the public-facing MakerFLOSS stack: Forgejo
|
||||
(self-hosted git forge), Traefik with Let's Encrypt, poste.io mail
|
||||
server, a Forgejo Actions runner, and the nginx services that serve
|
||||
`slides.makerfloss.eu` and `docs.makerfloss.eu`.
|
||||
|
||||
Managed via the [`AnsibleBaobabV4`](https://forgejo.nyumbani.baobab.band/sjat/AnsibleBaobabV4)
|
||||
Ansible project. SSH on port 7576.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
||||
-
|
||||
|
|
@ -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
|
||||
|
||||
-
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -1,20 +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 }
|
||||
---
|
||||
|
||||
## 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 → working table (white cable), non-active.
|
||||
- Port 4 ← sw01:8 (switch management), patched out to the working table
|
||||
(black cable), non-active.
|
||||
|
|
@ -1,26 +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: sw01, peer_port: 7 }
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- 24-port patch panel; ports 1–8 are the live feeds.
|
||||
- Port 1 → pp01:1 → wan01 (srv01 eth0, WAN).
|
||||
- Ports 2–8 → sw01:1–7 (LAN): srv01 eth1 (p2), srv02 (p3), srv03 (p4),
|
||||
srv04 (p5), srv05 (p6), srv06 (p7), srv07 (p8).
|
||||
|
|
@ -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).
|
||||
|
|
@ -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).
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
---
|
||||
hostname: shf03
|
||||
kind: shelf
|
||||
status: in-use
|
||||
rack: rack01
|
||||
rack_u: 21
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Half-depth shelf at U21 (front), currently empty.
|
||||
- Paired with shf04 (rear half-depth at the same U).
|
||||
|
|
@ -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.
|
||||
- Paired with shf03 (front half-depth at the same U).
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
---
|
||||
hostname: srv07
|
||||
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: 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
|
||||
|
||||
- Reconstructed from committed rack artifacts; specs are placeholders.
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
---
|
||||
hostname: sw01
|
||||
kind: switch
|
||||
status: in-use
|
||||
rack: rack01
|
||||
rack_u: 23
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
ports: 10
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- 10 ports: p1–p8 are 1 GbE; sfp1–sfp2 are 2.5 GbE SFP+ (unused today).
|
||||
- p1–p7 carry server uplinks via pp02 (pp02:2–8 → sw01:1–7).
|
||||
- p8 is the management port, patched out via pp01:4 to the working table
|
||||
(black cable) — non-active.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
24
docs/hardware/tembo.md
Normal file
24
docs/hardware/tembo.md
Normal 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.
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
hostname: ups01
|
||||
kind: ups
|
||||
status: staging
|
||||
rack: rack01
|
||||
rack_u: 4
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
---
|
||||
hostname: ups02
|
||||
kind: ups
|
||||
status: staging
|
||||
rack: rack01
|
||||
rack_u: 3
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
-
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -8,10 +8,7 @@ jam-session community focused on self-hosted, open-source infrastructure.
|
|||
|
||||
- [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.
|
||||
- [Services](services/index.md) — the things we run on (or around) that
|
||||
hardware: the docs site, slides, Forgejo, DNS, the slide-builder
|
||||
toolchain, and so on.
|
||||
- [House rules](house-rules.md) — working norms, governance, and
|
||||
- [House rules](makerFLOSS_house_rules.md) — working norms, governance, and
|
||||
what we do (and don't) do.
|
||||
|
||||
## Working norms (summary)
|
||||
|
|
@ -21,5 +18,5 @@ jam-session community focused on self-hosted, open-source infrastructure.
|
|||
- **Environments:** containerised and reproducible.
|
||||
- **Hardware:** all setups documented (this site) and physically labelled.
|
||||
- **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.
|
||||
|
|
|
|||
|
|
@ -1,355 +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/shf03/">
|
||||
<title>shf03 · 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">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/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: 23 KiB |
|
|
@ -1,509 +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/shf03/">
|
||||
<title>shf03 · 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">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/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"]
|
||||
wan01["wan01"]
|
||||
pp01 -->|1 → p1| wan01
|
||||
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 → p7| sw01
|
||||
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
|
||||
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 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 |
|
||||
| U21 | [shf03](../../hardware/shf03.md) | shelf | front | in-use |
|
||||
| U21 | [shf04](../../hardware/shf04.md) | shelf | rear | 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 |
|
||||
178
docs/infrastruktur/labdesign-fit-test.md
Normal file
178
docs/infrastruktur/labdesign-fit-test.md
Normal 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
|
||||
|
||||
188
docs/møder/2026-05-11_messaging-presentation-fit-test.md
Normal file
188
docs/møder/2026-05-11_messaging-presentation-fit-test.md
Normal 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
|
||||
|
|
@ -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["<svc>.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`_
|
||||
|
|
@ -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)_
|
||||
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,34 +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 |
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -1,14 +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 in `docs/presentations/` 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).
|
||||
5
docs/todo/2026-03-16_todo.md
Normal file
5
docs/todo/2026-03-16_todo.md
Normal 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
7
docs/todo/2026-05-05.md
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
# ToDo
|
||||
|
||||
ø Facebook rekleme
|
||||
|
||||
- Indkøbsliste
|
||||
g Netværk inden mandag
|
||||
- Skaf penge
|
||||
|
|
@ -5,4 +5,3 @@
|
|||
- Bo's invetory system and DNS for his QR-codes
|
||||
- Task management system for BuildDays
|
||||
- Display systemet Konrad
|
||||
- touch display med kalender etc.
|
||||
5
docs/todo/indkøbsliste.md
Normal file
5
docs/todo/indkøbsliste.md
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
# Indkøbsliste
|
||||
|
||||
- Rack-studs
|
||||
- 3 =< 3B+
|
||||
- Kølepasta
|
||||
19
mkdocs.yml
19
mkdocs.yml
|
|
@ -36,11 +36,7 @@ markdown_extensions:
|
|||
- tables
|
||||
- attr_list
|
||||
- md_in_html
|
||||
- pymdownx.superfences:
|
||||
custom_fences:
|
||||
- name: mermaid
|
||||
class: mermaid
|
||||
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||
- pymdownx.superfences
|
||||
- pymdownx.highlight:
|
||||
anchor_linenums: true
|
||||
- pymdownx.inlinehilite
|
||||
|
|
@ -52,19 +48,8 @@ markdown_extensions:
|
|||
plugins:
|
||||
- search
|
||||
|
||||
hooks:
|
||||
- scripts/mkdocs_hooks.py
|
||||
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Hardware:
|
||||
- hardware/index.md
|
||||
- Editing the hardware docs: guides/editing-hardware-docs.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
|
||||
- House rules: makerFLOSS_house_rules.md
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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.
|
||||
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
def _sorted_items(items: list[dict]) -> list[dict]:
|
||||
"""Deterministic order: faced items by U then hostname, 0U items last."""
|
||||
return sorted(
|
||||
items,
|
||||
key=lambda i: (
|
||||
0 if i.get("rack_face") not in ZERO_U_FACES else 1,
|
||||
i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0,
|
||||
i.get("hostname", ""),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def render_svg(rack: str, items: list[dict]) -> str:
|
||||
U_H = 20
|
||||
COL_W = 240
|
||||
LABEL_W = 30
|
||||
RAIL_W = 16
|
||||
PAD = 12
|
||||
GAP = 50
|
||||
TITLE_H = 28
|
||||
|
||||
items = _sorted_items(items)
|
||||
left_items = [i for i in items if i.get("rack_face") == "left"]
|
||||
right_items = [i for i in items if i.get("rack_face") == "right"]
|
||||
|
||||
body_h = RACK_UNITS * U_H
|
||||
height = PAD + TITLE_H + body_h + PAD
|
||||
front_x = PAD + len(left_items) * RAIL_W + LABEL_W
|
||||
rear_x = front_x + COL_W + GAP
|
||||
width = rear_x + COL_W + len(right_items) * RAIL_W + PAD
|
||||
top = PAD + TITLE_H
|
||||
|
||||
def u_y(u: int) -> int:
|
||||
# U1 at the top; U numbers increase downward.
|
||||
return top + (u - 1) * U_H
|
||||
|
||||
p: list[str] = []
|
||||
p.append(
|
||||
f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" '
|
||||
f'height="{height}" viewBox="0 0 {width} {height}" '
|
||||
f'font-family="sans-serif" font-size="11">'
|
||||
)
|
||||
p.append(f'<rect width="{width}" height="{height}" fill="#ffffff"/>')
|
||||
p.append(
|
||||
f'<text x="{PAD}" y="{PAD + 16}" font-size="16" '
|
||||
f'font-weight="bold">Rack {_esc(rack)}</text>'
|
||||
)
|
||||
|
||||
for col_x, col_label in ((front_x, "front"), (rear_x, "rear")):
|
||||
p.append(
|
||||
f'<text x="{col_x + COL_W // 2}" y="{top - 6}" '
|
||||
f'text-anchor="middle" font-weight="bold">{col_label}</text>'
|
||||
)
|
||||
for u in range(1, RACK_UNITS + 1):
|
||||
y = u_y(u)
|
||||
p.append(
|
||||
f'<rect x="{col_x}" y="{y}" width="{COL_W}" height="{U_H}" '
|
||||
f'fill="#f5f5f5" stroke="#e0e0e0"/>'
|
||||
)
|
||||
|
||||
# U numbers in the gutter left of the front column.
|
||||
for u in range(1, RACK_UNITS + 1):
|
||||
y = u_y(u)
|
||||
p.append(
|
||||
f'<text x="{front_x - 4}" y="{y + 14}" text-anchor="end" '
|
||||
f'fill="#999">{u}</text>'
|
||||
)
|
||||
|
||||
def draw_device(fm: dict, col_x: int) -> None:
|
||||
u = fm["rack_u"]
|
||||
h = fm["u_height"]
|
||||
y = u_y(u)
|
||||
box_h = h * U_H
|
||||
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||
name = fm.get("hostname", "?")
|
||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||
p.append(
|
||||
f'<rect x="{col_x + 1}" y="{y + 1}" width="{COL_W - 2}" '
|
||||
f'height="{box_h - 2}" rx="3" fill="{color}" stroke="#333"/>'
|
||||
)
|
||||
p.append(
|
||||
f'<text x="{col_x + COL_W // 2}" y="{y + box_h // 2 + 4}" '
|
||||
f'text-anchor="middle" fill="#ffffff">'
|
||||
f'{_esc(name)} ({urange})</text>'
|
||||
)
|
||||
|
||||
for fm in items:
|
||||
face = fm.get("rack_face")
|
||||
if face in ("front", "both"):
|
||||
draw_device(fm, front_x)
|
||||
if face in ("rear", "both"):
|
||||
draw_device(fm, rear_x)
|
||||
|
||||
def draw_rail(fm: dict, x: int) -> None:
|
||||
color = KIND_COLORS.get(fm.get("kind", ""), DEFAULT_COLOR)
|
||||
name = fm.get("hostname", "?")
|
||||
cx = x + RAIL_W // 2
|
||||
cy = top + body_h // 2
|
||||
p.append(
|
||||
f'<rect x="{x}" y="{top}" width="{RAIL_W}" height="{body_h}" '
|
||||
f'fill="{color}" stroke="#333"/>'
|
||||
)
|
||||
p.append(
|
||||
f'<text x="{cx}" y="{cy}" text-anchor="middle" fill="#ffffff" '
|
||||
f'transform="rotate(-90 {cx} {cy})">{_esc(name)}</text>'
|
||||
)
|
||||
|
||||
for idx, fm in enumerate(left_items):
|
||||
draw_rail(fm, PAD + idx * RAIL_W)
|
||||
for idx, fm in enumerate(right_items):
|
||||
draw_rail(fm, rear_x + COL_W + idx * RAIL_W)
|
||||
|
||||
p.append("</svg>")
|
||||
return "\n".join(p) + "\n"
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: PASS (14 passed).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||
git commit -m "feat(rack): render SVG elevation (U1 at top, front/rear columns)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Page rendering + orchestration (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/gen_rack.py`
|
||||
- Modify: `tests/test_gen_rack.py`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `render_svg`, `validate_item`, `check_overlaps`, `load_rack_items`.
|
||||
- Produces:
|
||||
- `render_page(rack: str, items: list[dict]) -> str` — the generated Markdown page (banner, embedded SVG image, occupancy table linking to host pages).
|
||||
- `generate(hardware_dir: Path, output_dir: Path) -> int` — orchestrates load/validate/group/write; returns `0` on success, `1` on any schema error (printing errors to stderr, writing nothing on failure).
|
||||
- `main() -> int` — calls `generate(HARDWARE_DIR, OUTPUT_DIR)`.
|
||||
|
||||
- [ ] **Step 1: Add failing tests for `render_page` and `generate`**
|
||||
|
||||
Append to `tests/test_gen_rack.py`:
|
||||
|
||||
```python
|
||||
def test_render_page_has_banner_image_and_table():
|
||||
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||
page = gen_rack.render_page("rack01", items)
|
||||
assert "do not edit by hand" in page
|
||||
assert "" in page
|
||||
assert "../../hardware/mf00.md" in page
|
||||
assert "U1–U2" in page
|
||||
|
||||
|
||||
def _write_item(d, name, body):
|
||||
(d / f"{name}.md").write_text(body, encoding="utf-8")
|
||||
|
||||
|
||||
def test_generate_writes_artifacts(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
_write_item(
|
||||
hw,
|
||||
"mf00",
|
||||
"---\nhostname: mf00\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||
)
|
||||
# a non-rack file must be ignored
|
||||
_write_item(hw, "cloud", "---\nhostname: cloud\nkind: server\nstatus: in-use\n---\n")
|
||||
|
||||
rc = gen_rack.generate(hw, out)
|
||||
|
||||
assert rc == 0
|
||||
assert (out / "rack01.md").exists()
|
||||
assert (out / "rack01-elevation.svg").exists()
|
||||
assert "mf00" in (out / "rack01-elevation.svg").read_text()
|
||||
|
||||
|
||||
def test_generate_returns_1_on_overlap(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
for n, u in (("a", 1), ("b", 1)):
|
||||
_write_item(
|
||||
hw,
|
||||
n,
|
||||
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
|
||||
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\n---\n",
|
||||
)
|
||||
|
||||
rc = gen_rack.generate(hw, out)
|
||||
|
||||
assert rc == 1
|
||||
assert not (out / "rack01.md").exists()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'render_page'`.
|
||||
|
||||
- [ ] **Step 3: Implement `render_page`, `generate`, and `main`**
|
||||
|
||||
Add to `scripts/gen_rack.py`:
|
||||
|
||||
```python
|
||||
def render_page(rack: str, items: list[dict]) -> str:
|
||||
items = _sorted_items(items)
|
||||
lines: list[str] = []
|
||||
lines.append(f"# Rack {rack}")
|
||||
lines.append("")
|
||||
lines.append(
|
||||
f"_Auto-generated from `docs/hardware/*.md` (items with `rack: {rack}`) "
|
||||
f"— do not edit by hand. Run `make docs-index` after changing a "
|
||||
f"source file._"
|
||||
)
|
||||
lines.append("")
|
||||
lines.append("## Elevation")
|
||||
lines.append("")
|
||||
lines.append(f"")
|
||||
lines.append("")
|
||||
lines.append("## Occupancy")
|
||||
lines.append("")
|
||||
lines.append("| U | Device | Kind | Face | Status |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
for fm in items:
|
||||
name = fm.get("hostname", "?")
|
||||
link = f"[{name}](../../hardware/{name}.md)"
|
||||
face = fm.get("rack_face", "")
|
||||
if face in ZERO_U_FACES:
|
||||
urange = "0U"
|
||||
else:
|
||||
u = fm["rack_u"]
|
||||
h = fm["u_height"]
|
||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||
lines.append(
|
||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
||||
f"| {fm.get('status', '')} |"
|
||||
)
|
||||
lines.append("")
|
||||
return "\n".join(lines).rstrip() + "\n"
|
||||
|
||||
|
||||
def generate(hardware_dir: Path, output_dir: Path) -> int:
|
||||
items = load_rack_items(hardware_dir)
|
||||
|
||||
errors: list[str] = []
|
||||
for fm in items:
|
||||
try:
|
||||
validate_item(fm)
|
||||
except SchemaError as e:
|
||||
errors.append(str(e))
|
||||
|
||||
racks: dict[str, list[dict]] = {}
|
||||
for fm in items:
|
||||
racks.setdefault(fm["rack"], []).append(fm)
|
||||
|
||||
if not errors: # only check overlaps once placements are individually valid
|
||||
for rack, ritems in racks.items():
|
||||
try:
|
||||
check_overlaps(ritems)
|
||||
except SchemaError as e:
|
||||
errors.append(f"{rack}: {e}")
|
||||
|
||||
if errors:
|
||||
for err in errors:
|
||||
print(f"ERROR: {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
for rack in sorted(racks):
|
||||
ritems = racks[rack]
|
||||
(output_dir / f"{rack}-elevation.svg").write_text(
|
||||
render_svg(rack, ritems), encoding="utf-8"
|
||||
)
|
||||
(output_dir / f"{rack}.md").write_text(
|
||||
render_page(rack, ritems), encoding="utf-8"
|
||||
)
|
||||
print(f"Wrote {rack}.md + {rack}-elevation.svg ({len(ritems)} item(s))")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
return generate(HARDWARE_DIR, OUTPUT_DIR)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: PASS (18 passed).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||
git commit -m "feat(rack): render page and orchestrate generation"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Wire build tooling and populate rack01
|
||||
|
||||
**Files:**
|
||||
- Modify: `Makefile`
|
||||
- Modify: `docs/hardware/mf00.md` (and other host files actually in the rack — see note)
|
||||
- Create (generated): `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `gen_rack.main` via `python3 scripts/gen_rack.py`.
|
||||
|
||||
> **Operator note — real data required.** I do not know the true U positions of the devices in the physical rack, and the `mfNN` machines are tower/desktop units that may sit on a shelf rather than occupy U slots. The edit below is a **worked example** for `mf00`. Apply the same shape to **each device actually mounted in the rack**, using its real `rack_u`, `u_height`, and `rack_face` (front/rear/both). Remove rack fields from any host not in the rack. The overlap validator (`check_overlaps`) will reject conflicting positions, so wrong guesses fail loudly rather than silently. `makerfloss.eu` is cloud-hosted and must NOT get a `rack` field.
|
||||
|
||||
- [ ] **Step 1: Add rack placement to each in-rack host file (example: `mf00`)**
|
||||
|
||||
In `docs/hardware/mf00.md`, add these four lines to the frontmatter (between the existing keys and the closing `---`):
|
||||
|
||||
```yaml
|
||||
rack: rack01
|
||||
rack_u: 1
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
```
|
||||
|
||||
Repeat for every other device physically in the rack, choosing real, non-overlapping U positions.
|
||||
|
||||
- [ ] **Step 2: Add the `gen_rack` step to the Makefile**
|
||||
|
||||
In `Makefile`, change the `docs-index` target to:
|
||||
|
||||
```makefile
|
||||
docs-index:
|
||||
python3 scripts/gen_overview.py --category hardware
|
||||
python3 scripts/gen_overview.py --category services
|
||||
python3 scripts/gen_rack.py
|
||||
```
|
||||
|
||||
Change the `docs-check` target to:
|
||||
|
||||
```makefile
|
||||
docs-check:
|
||||
python3 scripts/gen_overview.py --category hardware
|
||||
python3 scripts/gen_overview.py --category services
|
||||
python3 scripts/gen_rack.py
|
||||
git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/
|
||||
```
|
||||
|
||||
Add a `test` target at the end of the file:
|
||||
|
||||
```makefile
|
||||
test:
|
||||
pytest -q
|
||||
```
|
||||
|
||||
And add `test` to the `.PHONY` line and a help line:
|
||||
|
||||
```makefile
|
||||
.PHONY: help docs-index docs-build docs-serve docs-check slides test
|
||||
```
|
||||
|
||||
```makefile
|
||||
@echo " test Run the Python unit tests (pytest)"
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Generate the rack artifacts**
|
||||
|
||||
Run: `make docs-index`
|
||||
Expected: prints `Wrote rack01.md + rack01-elevation.svg (N item(s))`; the two files now exist under `docs/infrastructure/racks/`.
|
||||
|
||||
- [ ] **Step 4: Eyeball the SVG**
|
||||
|
||||
Open `docs/infrastructure/racks/rack01-elevation.svg` in a browser. Expected: a "Rack rack01" title, front and rear columns, U numbers running **1 at the top → 48 at the bottom**, and each placed device as a colored box at its U position.
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add Makefile docs/hardware/*.md docs/infrastructure/racks/
|
||||
git commit -m "feat(rack): populate rack01 and wire gen_rack into make targets"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: CI integration, nav, and end-to-end verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `.forgejo/workflows/docs.yml`
|
||||
- Modify: `mkdocs.yml`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `python3 scripts/gen_rack.py`, `pytest`, the generated artifacts under `docs/infrastructure/racks/`.
|
||||
|
||||
- [ ] **Step 1: Add a test step and the rack generator to CI**
|
||||
|
||||
In `.forgejo/workflows/docs.yml`, after the `Install Python dependencies` step, add a new step:
|
||||
|
||||
```yaml
|
||||
- name: Install dev dependencies and run tests
|
||||
run: |
|
||||
pip install --quiet -r requirements-dev.txt
|
||||
pytest -q
|
||||
```
|
||||
|
||||
In the `Regenerate hardware and services indices` step, append the rack generator so the `run:` block reads:
|
||||
|
||||
```yaml
|
||||
run: |
|
||||
python3 scripts/gen_overview.py --category hardware
|
||||
python3 scripts/gen_overview.py --category services
|
||||
python3 scripts/gen_rack.py
|
||||
```
|
||||
|
||||
In the `Fail on drift in generated indices` step, extend the diff to cover the rack artifacts:
|
||||
|
||||
```yaml
|
||||
run: |
|
||||
if ! git diff --exit-code docs/hardware/index.md docs/services/index.md docs/infrastructure/racks/; then
|
||||
echo
|
||||
echo "::error::A generated index is stale."
|
||||
echo "Regenerate locally via 'make docs-index' and commit the result."
|
||||
exit 1
|
||||
fi
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add an Infrastructure section to the MkDocs nav**
|
||||
|
||||
In `mkdocs.yml`, replace the `nav:` block with:
|
||||
|
||||
```yaml
|
||||
nav:
|
||||
- Home: index.md
|
||||
- Hardware:
|
||||
- hardware/index.md
|
||||
- Services:
|
||||
- services/index.md
|
||||
- Infrastructure:
|
||||
- Lab design: infrastructure/labdesign.md
|
||||
- VPS & DNS: infrastructure/vps-and-dns.md
|
||||
- Rack rack01: infrastructure/racks/rack01.md
|
||||
- House rules: house-rules.md
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build the site strictly and confirm it passes**
|
||||
|
||||
Run: `mkdocs build --strict`
|
||||
Expected: build succeeds with no warnings-as-errors. The rack page and its SVG appear under `site/infrastructure/racks/`.
|
||||
|
||||
- [ ] **Step 4: Confirm the drift guard is satisfied**
|
||||
|
||||
Run: `make docs-check`
|
||||
Expected: exit 0 (no diff) — the committed artifacts match a fresh regeneration.
|
||||
|
||||
- [ ] **Step 5: Preview the page**
|
||||
|
||||
Run: `mkdocs serve`
|
||||
Open `http://127.0.0.1:8000/infrastructure/racks/rack01/`. Expected: the elevation SVG renders inline, U1 at the top; the occupancy table lists devices and links to their host pages.
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add .forgejo/workflows/docs.yml mkdocs.yml
|
||||
git commit -m "ci(rack): generate rack artifacts, run tests, add nav entry"
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Push and confirm CI is green**
|
||||
|
||||
```bash
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Open the Forgejo Actions run for this push. Expected: the tests step passes, the drift guard passes, the site builds, and `docs.makerfloss.eu/infrastructure/racks/rack01/` shows the elevation.
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (Phase 1 scope):**
|
||||
- Placement schema (`rack`, `rack_u`, `u_height`, `rack_face`) — Task 2 (validation), Task 6 (population). ✔
|
||||
- New `kind` values — Task 1. ✔
|
||||
- `gen_rack.py` producing SVG elevation + occupancy table — Tasks 4, 5. ✔
|
||||
- U1-at-top rendering — Task 4 (`u_y`), verified Task 6 Step 4 / Task 7 Step 5. ✔
|
||||
- Validation rules 1, 2, 5 (U range, overlap, 0U-omits-units) — Tasks 2, 3. (Rules 3, 4 are power/network — Phases 2/3, out of scope.) ✔
|
||||
- Do-not-edit banner — Task 5 (`render_page`). ✔
|
||||
- CI drift check + nav + strict build — Task 7. ✔
|
||||
- Generated artifacts under `docs/infrastructure/racks/` — Tasks 5, 6. ✔
|
||||
|
||||
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to" placeholders. The only deferred-to-operator item is real U-position data in Task 6, which is unavoidable physical-world input and is explicitly bounded with a worked example and the overlap validator as a safety net.
|
||||
|
||||
**Type consistency:** `SchemaError`, `RACK_UNITS`, `FACES`, `ZERO_U_FACES`, `parse_frontmatter`, `load_rack_items`, `validate_item`, `check_overlaps`, `_esc`, `_sorted_items`, `render_svg`, `render_page`, `generate`, `main` — names and signatures match across tasks and tests. `generate` returns `int` (0/1); `render_*` return `str`; `validate_item`/`check_overlaps` return `None` and raise `SchemaError`. Consistent.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"")
|
||||
lines.append("")
|
||||
lines.append("## Occupancy")
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```python
|
||||
lines.append(f"")
|
||||
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.
|
||||
|
|
@ -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"")
|
||||
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.
|
||||
|
|
@ -1,677 +0,0 @@
|
|||
# Shelf-Mounted Devices Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Model cabinet/tower PCs that sit on a rack shelf — the shelf reserves a U-range, occupants attach via `mounted_on`/`shelf_face`/`shelf_slot` — and render them as side-by-side boxes in the elevation.
|
||||
|
||||
**Architecture:** Extend `scripts/gen_rack.py`: a `mounted_on` branch in `validate_item`, a skip in `check_overlaps`, a new `check_shelves` cross-item validator, and shelf rendering in `render_svg` (a shelf strip plus per-occupant boxes subdividing the column) and `render_page` (occupancy rows for mounted devices). Then populate the worked example. No generator-config or CI changes.
|
||||
|
||||
**Tech Stack:** Python 3 (stdlib + PyYAML only), pytest, MkDocs Material, Forgejo Actions CI.
|
||||
|
||||
**Spec:** `notes/dev/specs/2026-06-24-rack-shelves-design.md`.
|
||||
|
||||
## Global Constraints
|
||||
|
||||
- Scripts use **stdlib + PyYAML only**; deterministic and offline (copy existing `gen_rack.py` style). No randomness/time in generated output.
|
||||
- `re` and `yaml` are already imported in `scripts/gen_rack.py`; do not add new imports.
|
||||
- Validation failures raise `SchemaError`; `generate` prints `ERROR: …` to stderr and returns `1`, **writing nothing** on failure.
|
||||
- A **mounted device** declares `mounted_on` (str), `shelf_face` ∈ {front, rear}, `shelf_slot` (int ≥ 1), and **omits** `rack_u`/`u_height`/`rack_face`. A **shelf** (`kind: shelf`) is placed normally (`rack_u`/`u_height`/`rack_face: both`) and reserves the assembly's U-range.
|
||||
- Peer/PDU/grouping fields (`power:`, `links:`, `cluster:`) on a mounted device are unchanged — they key off hostname, not placement.
|
||||
- Reuse the existing `item()` and `_write_item` test helpers in `tests/test_gen_rack.py`; add a local `shelf()` helper where noted.
|
||||
- `isinstance(x, int)` style (bool-is-int acceptable, matching existing code).
|
||||
- Provisional placeholder data only (matches the existing `srvNN` positions and power/network demos).
|
||||
- **No edits** to `mkdocs.yml`, `Makefile`, `.forgejo/workflows/docs.yml`, or `scripts/overview_config.yml` (`shelf`/`server` already in the enum; drift already covers `racks/`).
|
||||
- `mkdocs build --strict` must pass; `make docs-check` must exit 0 after regeneration.
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Validation — mounted branch, overlap skip, `check_shelves` (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/gen_rack.py` (add `SHELF_FACES`; `validate_item` mounted branch; `check_overlaps` skip; new `check_shelves`; call it in `generate`)
|
||||
- Modify: `tests/test_gen_rack.py` (append tests + a `shelf()` helper)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `SchemaError`, `item()`/`_write_item` helpers, `generate`.
|
||||
- Produces:
|
||||
- `SHELF_FACES = {"front", "rear"}` (constant)
|
||||
- `check_shelves(items: list[dict]) -> None` — raises `SchemaError` on a bad mount.
|
||||
- `validate_item` and `check_overlaps` gain mounted-item handling.
|
||||
|
||||
- [ ] **Step 1: Append a `shelf()` helper and failing tests to `tests/test_gen_rack.py`**
|
||||
|
||||
Add near the top, just after the existing `item()` helper:
|
||||
|
||||
```python
|
||||
def shelf(**kw):
|
||||
base = {"hostname": "shf01", "kind": "shelf", "status": "in-use",
|
||||
"rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"}
|
||||
base.update(kw)
|
||||
return base
|
||||
```
|
||||
|
||||
Append these tests at the end of the file:
|
||||
|
||||
```python
|
||||
def test_validate_accepts_mounted_item():
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_with_rack_u():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1, rack_u=5))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_bad_shelf_face():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="left", shelf_slot=1))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_bad_slot():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=0))
|
||||
|
||||
|
||||
def test_overlaps_skips_mounted_items():
|
||||
items = [
|
||||
item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||||
item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||||
]
|
||||
gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range
|
||||
|
||||
|
||||
def test_check_shelves_accepts_valid_mount():
|
||||
items = [shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_missing_shelf():
|
||||
items = [item(hostname="srv01", mounted_on="ghost",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_non_shelf_target():
|
||||
items = [
|
||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||||
rack_face="front"),
|
||||
item(hostname="srv01", mounted_on="sw01",
|
||||
shelf_face="front", shelf_slot=1),
|
||||
]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_duplicate_slot():
|
||||
items = [shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1),
|
||||
item(hostname="srv02", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_generate_returns_1_on_dangling_mount(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
_write_item(
|
||||
hw, "srv01",
|
||||
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n",
|
||||
)
|
||||
rc = gen_rack.generate(hw, out)
|
||||
assert rc == 1
|
||||
assert not (out / "rack01.md").exists()
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: FAIL — `AttributeError: module 'gen_rack' has no attribute 'check_shelves'` (and the mounted-validation tests fail because `validate_item` rejects the missing `rack_face`).
|
||||
|
||||
- [ ] **Step 3: Add the `SHELF_FACES` constant**
|
||||
|
||||
In `scripts/gen_rack.py`, just after the existing `ZERO_U_FACES = {"left", "right"}` line, add:
|
||||
|
||||
```python
|
||||
SHELF_FACES = {"front", "rear"}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Add the mounted branch to `validate_item`**
|
||||
|
||||
In `validate_item`, the function currently begins:
|
||||
|
||||
```python
|
||||
def validate_item(fm: dict) -> None:
|
||||
name = fm.get("hostname") or fm.get("_path", "?")
|
||||
rack = fm.get("rack")
|
||||
if not isinstance(rack, str) or not rack:
|
||||
raise SchemaError(f"{name}: rack must be a non-empty string")
|
||||
face = fm.get("rack_face")
|
||||
```
|
||||
|
||||
Insert the mounted branch between the `rack` check and the `face = …` line, so it reads:
|
||||
|
||||
```python
|
||||
def validate_item(fm: dict) -> None:
|
||||
name = fm.get("hostname") or fm.get("_path", "?")
|
||||
rack = fm.get("rack")
|
||||
if not isinstance(rack, str) or not rack:
|
||||
raise SchemaError(f"{name}: rack must be a non-empty string")
|
||||
if "mounted_on" in fm:
|
||||
mounted_on = fm.get("mounted_on")
|
||||
if not isinstance(mounted_on, str) or not mounted_on:
|
||||
raise SchemaError(f"{name}: mounted_on must be a non-empty string")
|
||||
for forbidden in ("rack_u", "u_height", "rack_face"):
|
||||
if forbidden in fm:
|
||||
raise SchemaError(
|
||||
f"{name}: mounted item must omit {forbidden}"
|
||||
)
|
||||
sface = fm.get("shelf_face")
|
||||
if sface not in SHELF_FACES:
|
||||
raise SchemaError(
|
||||
f"{name}: shelf_face={sface!r} not in {sorted(SHELF_FACES)}"
|
||||
)
|
||||
slot = fm.get("shelf_slot")
|
||||
if not isinstance(slot, int) or slot < 1:
|
||||
raise SchemaError(f"{name}: shelf_slot must be an integer >= 1")
|
||||
return
|
||||
face = fm.get("rack_face")
|
||||
```
|
||||
|
||||
(The rest of `validate_item` — the `face`/0U/U-range checks — is unchanged.)
|
||||
|
||||
- [ ] **Step 5: Skip mounted items in `check_overlaps`**
|
||||
|
||||
In `check_overlaps`, the loop body currently starts:
|
||||
|
||||
```python
|
||||
for fm in items:
|
||||
face = fm.get("rack_face")
|
||||
if face in ZERO_U_FACES:
|
||||
continue
|
||||
```
|
||||
|
||||
Add a mounted skip as the first thing in the loop:
|
||||
|
||||
```python
|
||||
for fm in items:
|
||||
if "mounted_on" in fm:
|
||||
continue
|
||||
face = fm.get("rack_face")
|
||||
if face in ZERO_U_FACES:
|
||||
continue
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Add `check_shelves` after `check_overlaps`**
|
||||
|
||||
Add this function immediately after `check_overlaps` in `scripts/gen_rack.py`:
|
||||
|
||||
```python
|
||||
def check_shelves(items: list[dict]) -> None:
|
||||
"""Validate shelf-mounted devices within one rack.
|
||||
|
||||
Every mounted_on resolves to a placed kind:shelf item in the same rack;
|
||||
no two devices share (shelf, face, slot).
|
||||
"""
|
||||
by_host = {fm.get("hostname"): fm for fm in items}
|
||||
occupied: dict[tuple[str, str, int], str] = {}
|
||||
for fm in items:
|
||||
if "mounted_on" not in fm:
|
||||
continue
|
||||
name = fm.get("hostname", "?")
|
||||
shelf_name = fm["mounted_on"]
|
||||
target = by_host.get(shelf_name)
|
||||
if target is None:
|
||||
raise SchemaError(
|
||||
f"{name}: mounted_on={shelf_name!r} is not in this rack"
|
||||
)
|
||||
if target.get("kind") != "shelf":
|
||||
raise SchemaError(
|
||||
f"{name}: mounted_on={shelf_name!r} is not a kind:shelf item"
|
||||
)
|
||||
if not isinstance(target.get("rack_u"), int) or not isinstance(
|
||||
target.get("u_height"), int
|
||||
):
|
||||
raise SchemaError(
|
||||
f"{name}: shelf {shelf_name!r} is not placed (needs rack_u/u_height)"
|
||||
)
|
||||
key = (shelf_name, fm["shelf_face"], fm["shelf_slot"])
|
||||
if key in occupied:
|
||||
raise SchemaError(
|
||||
f"{shelf_name} {fm['shelf_face']} slot {fm['shelf_slot']}: "
|
||||
f"{name} overlaps {occupied[key]}"
|
||||
)
|
||||
occupied[key] = name
|
||||
```
|
||||
|
||||
- [ ] **Step 7: Wire `check_shelves` into `generate`**
|
||||
|
||||
In `generate`, the per-rack validation loop currently reads:
|
||||
|
||||
```python
|
||||
try:
|
||||
check_overlaps(ritems)
|
||||
validate_power(ritems)
|
||||
validate_links(ritems, hw_index)
|
||||
except SchemaError as e:
|
||||
errors.append(f"{rack}: {e}")
|
||||
```
|
||||
|
||||
Add `check_shelves(ritems)`:
|
||||
|
||||
```python
|
||||
try:
|
||||
check_overlaps(ritems)
|
||||
validate_power(ritems)
|
||||
validate_links(ritems, hw_index)
|
||||
check_shelves(ritems)
|
||||
except SchemaError as e:
|
||||
errors.append(f"{rack}: {e}")
|
||||
```
|
||||
|
||||
- [ ] **Step 8: Run to verify pass**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: PASS (all prior tests + 10 new).
|
||||
|
||||
- [ ] **Step 9: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||
git commit -m "feat(rack): validate shelf-mounted devices (mounted_on/shelf_face/shelf_slot)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Rendering — shelf strip + occupant boxes + occupancy rows (TDD)
|
||||
|
||||
**Files:**
|
||||
- Modify: `scripts/gen_rack.py` (`render_svg` shelf drawing; `render_page` occupancy)
|
||||
- Modify: `tests/test_gen_rack.py` (append tests)
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `render_svg`, `render_page`, the `shelf()` helper (Task 1).
|
||||
- Produces: `render_svg`/`render_page` render shelves and mounted occupants. No new public function.
|
||||
|
||||
- [ ] **Step 1: Append failing tests to `tests/test_gen_rack.py`**
|
||||
|
||||
```python
|
||||
def test_render_svg_draws_shelf_and_occupants():
|
||||
items = [
|
||||
shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||||
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
|
||||
]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert "shf01" in svg
|
||||
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
|
||||
# the shelf is NOT drawn as a generic full-height device box
|
||||
assert "shf01 (U37" not in svg
|
||||
|
||||
|
||||
def test_render_svg_shelf_is_deterministic():
|
||||
base = [
|
||||
shelf(),
|
||||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||||
]
|
||||
assert gen_rack.render_svg("rack01", base) == gen_rack.render_svg(
|
||||
"rack01", list(reversed(base))
|
||||
)
|
||||
|
||||
|
||||
def test_render_page_lists_mounted_devices():
|
||||
items = [shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
page = gen_rack.render_page("rack01", items)
|
||||
assert "../../hardware/srv01.md" in page
|
||||
assert "front · shf01/1" in page
|
||||
assert "U37–U46" in page # mounted device shows its shelf's U-range
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: FAIL — `test_render_page_lists_mounted_devices` raises `KeyError: 'rack_u'` (occupancy loop has no mounted handling) and the SVG tests fail (`shf01`/occupant boxes not drawn; the shelf is drawn as a generic box so `"shf01 (U37"` is present).
|
||||
|
||||
- [ ] **Step 3: Render shelves in `render_svg`**
|
||||
|
||||
In `render_svg`, the generic device loop currently reads:
|
||||
|
||||
```python
|
||||
for fm in items:
|
||||
face = fm.get("rack_face")
|
||||
if face in ("front", "both"):
|
||||
draw_device(fm, front_x)
|
||||
if face in ("rear", "both"):
|
||||
draw_device(fm, rear_x)
|
||||
```
|
||||
|
||||
Replace it with a version that skips shelves and mounted items:
|
||||
|
||||
```python
|
||||
for fm in items:
|
||||
if fm.get("kind") == "shelf" or "mounted_on" in fm:
|
||||
continue
|
||||
face = fm.get("rack_face")
|
||||
if face in ("front", "both"):
|
||||
draw_device(fm, front_x)
|
||||
if face in ("rear", "both"):
|
||||
draw_device(fm, rear_x)
|
||||
```
|
||||
|
||||
Then, immediately before the final `p.append("</svg>")` line, add the shelf drawing:
|
||||
|
||||
```python
|
||||
SHELF_STRIP_H = 6
|
||||
shelves = [i for i in items if i.get("kind") == "shelf"]
|
||||
mounted = [i for i in items if "mounted_on" in i]
|
||||
|
||||
def draw_shelf(fm: dict) -> None:
|
||||
u = fm["rack_u"]
|
||||
h = fm["u_height"]
|
||||
y = u_y(u)
|
||||
block_h = h * U_H
|
||||
strip_y = y + block_h - SHELF_STRIP_H
|
||||
avail_h = block_h - SHELF_STRIP_H
|
||||
shelf_color = KIND_COLORS.get("shelf", DEFAULT_COLOR)
|
||||
sname = fm.get("hostname", "?")
|
||||
for col_x, sface in ((front_x, "front"), (rear_x, "rear")):
|
||||
occ = sorted(
|
||||
(m for m in mounted
|
||||
if m.get("mounted_on") == sname
|
||||
and m.get("shelf_face") == sface),
|
||||
key=lambda m: (m.get("shelf_slot", 0), m.get("hostname", "")),
|
||||
)
|
||||
n = len(occ)
|
||||
for idx, m in enumerate(occ):
|
||||
sub_w = COL_W // n
|
||||
bx = col_x + idx * sub_w
|
||||
bw = (COL_W - idx * sub_w) if idx == n - 1 else sub_w
|
||||
mcolor = KIND_COLORS.get(m.get("kind", ""), DEFAULT_COLOR)
|
||||
mname = m.get("hostname", "?")
|
||||
p.append(
|
||||
f'<rect x="{bx + 1}" y="{y + 1}" width="{bw - 2}" '
|
||||
f'height="{avail_h - 2}" rx="3" fill="{mcolor}" stroke="#333"/>'
|
||||
)
|
||||
p.append(
|
||||
f'<text x="{bx + bw // 2}" y="{y + avail_h // 2 + 4}" '
|
||||
f'text-anchor="middle" fill="#ffffff">{_esc(mname)}</text>'
|
||||
)
|
||||
p.append(
|
||||
f'<rect x="{col_x}" y="{strip_y}" width="{COL_W}" '
|
||||
f'height="{SHELF_STRIP_H}" fill="{shelf_color}" stroke="#333"/>'
|
||||
)
|
||||
p.append(
|
||||
f'<text x="{front_x + COL_W // 2}" y="{strip_y + SHELF_STRIP_H - 1}" '
|
||||
f'text-anchor="middle" fill="#333" font-size="9">{_esc(sname)}</text>'
|
||||
)
|
||||
|
||||
for fm in sorted(shelves, key=lambda s: s.get("hostname", "")):
|
||||
draw_shelf(fm)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Render mounted devices in the `render_page` occupancy table**
|
||||
|
||||
In `render_page`, the occupancy loop currently reads:
|
||||
|
||||
```python
|
||||
for fm in items:
|
||||
name = fm.get("hostname", "?")
|
||||
link = f"[{name}](../../hardware/{name}.md)"
|
||||
face = fm.get("rack_face", "")
|
||||
if face in ZERO_U_FACES:
|
||||
urange = "0U"
|
||||
else:
|
||||
u = fm["rack_u"]
|
||||
h = fm["u_height"]
|
||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||
lines.append(
|
||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
||||
f"| {fm.get('status', '')} |"
|
||||
)
|
||||
```
|
||||
|
||||
Replace that whole loop with one that orders mounted devices right after their
|
||||
shelf and renders their shelf-relative position:
|
||||
|
||||
```python
|
||||
by_host = {fm.get("hostname"): fm for fm in items}
|
||||
mounted_by_shelf: dict[str, list[dict]] = {}
|
||||
for fm in items:
|
||||
if "mounted_on" in fm:
|
||||
mounted_by_shelf.setdefault(fm["mounted_on"], []).append(fm)
|
||||
|
||||
def occ_row(fm: dict) -> str:
|
||||
name = fm.get("hostname", "?")
|
||||
link = f"[{name}](../../hardware/{name}.md)"
|
||||
if "mounted_on" in fm:
|
||||
target = by_host.get(fm["mounted_on"])
|
||||
if target and isinstance(target.get("rack_u"), int):
|
||||
su = target["rack_u"]
|
||||
sh = target["u_height"]
|
||||
urange = f"U{su}" if sh == 1 else f"U{su}–U{su + sh - 1}"
|
||||
else:
|
||||
urange = "—"
|
||||
face = (
|
||||
f"{fm.get('shelf_face', '')} · "
|
||||
f"{fm['mounted_on']}/{fm.get('shelf_slot', '')}"
|
||||
)
|
||||
else:
|
||||
face = fm.get("rack_face", "")
|
||||
if face in ZERO_U_FACES:
|
||||
urange = "0U"
|
||||
else:
|
||||
u = fm["rack_u"]
|
||||
h = fm["u_height"]
|
||||
urange = f"U{u}" if h == 1 else f"U{u}–U{u + h - 1}"
|
||||
return (
|
||||
f"| {urange} | {link} | {fm.get('kind', '')} | {face} "
|
||||
f"| {fm.get('status', '')} |"
|
||||
)
|
||||
|
||||
for fm in _sorted_items([i for i in items if "mounted_on" not in i]):
|
||||
lines.append(occ_row(fm))
|
||||
if fm.get("kind") == "shelf":
|
||||
occ = sorted(
|
||||
mounted_by_shelf.get(fm.get("hostname"), []),
|
||||
key=lambda m: (m.get("shelf_face", ""), m.get("shelf_slot", 0)),
|
||||
)
|
||||
for m in occ:
|
||||
lines.append(occ_row(m))
|
||||
```
|
||||
|
||||
(The `items = _sorted_items(items)` line at the top of `render_page` and the
|
||||
graph sections above are unchanged.)
|
||||
|
||||
- [ ] **Step 5: Run to verify pass**
|
||||
|
||||
Run: `pytest tests/test_gen_rack.py -q`
|
||||
Expected: PASS (all prior tests + 3 new).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add scripts/gen_rack.py tests/test_gen_rack.py
|
||||
git commit -m "feat(rack): render shelf strip, occupant boxes, and mounted occupancy rows"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Populate the shelf demo, regenerate
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/hardware/shf01.md`
|
||||
- Modify: `docs/hardware/srv01.md`, `srv02.md`, `srv03.md` (convert to mounted)
|
||||
- Regenerate: `docs/hardware/index.md`, `docs/infrastructure/racks/rack01.md`, `docs/infrastructure/racks/rack01-elevation.svg`
|
||||
|
||||
**Interfaces:**
|
||||
- Consumes: `make docs-index`, `make test`, `mkdocs build --strict`, `make docs-check`.
|
||||
|
||||
> **Operator note — provisional data.** The shelf placement and the front/rear/slot assignments are placeholders matching the worked example (TaPPaaS: srv01/srv02 front, srv03 rear, on a shelf reserving U37–U46). `check_shelves`/`check_overlaps` reject inconsistent data loudly.
|
||||
|
||||
- [ ] **Step 1: Create the shelf file**
|
||||
|
||||
Create `docs/hardware/shf01.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
hostname: shf01
|
||||
kind: shelf
|
||||
status: in-use
|
||||
rack: rack01
|
||||
rack_u: 37
|
||||
u_height: 10
|
||||
rack_face: both
|
||||
cluster: tappaas
|
||||
---
|
||||
|
||||
## Notes
|
||||
|
||||
- Provisional placeholder shelf holding the TaPPaaS nodes (srv01/srv02 front, srv03 rear).
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Convert `srv01.md` to mounted**
|
||||
|
||||
In `docs/hardware/srv01.md`, replace these three frontmatter lines:
|
||||
|
||||
```yaml
|
||||
rack_u: 1
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
mounted_on: shf01
|
||||
shelf_face: front
|
||||
shelf_slot: 1
|
||||
```
|
||||
|
||||
Leave everything else (including `cluster: tappaas`, `power:`, `links:`) unchanged.
|
||||
|
||||
- [ ] **Step 3: Convert `srv02.md` to mounted and tag its cluster**
|
||||
|
||||
In `docs/hardware/srv02.md`, replace these three frontmatter lines:
|
||||
|
||||
```yaml
|
||||
rack_u: 2
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
mounted_on: shf01
|
||||
shelf_face: front
|
||||
shelf_slot: 2
|
||||
```
|
||||
|
||||
Then add a `cluster: tappaas` line immediately after the `status: staging` line, so the top reads:
|
||||
|
||||
```yaml
|
||||
hostname: srv02
|
||||
kind: server
|
||||
status: staging
|
||||
cluster: tappaas
|
||||
location: The pile
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Convert `srv03.md` to mounted and tag its cluster**
|
||||
|
||||
In `docs/hardware/srv03.md`, replace these three frontmatter lines:
|
||||
|
||||
```yaml
|
||||
rack_u: 3
|
||||
u_height: 1
|
||||
rack_face: front
|
||||
```
|
||||
|
||||
with:
|
||||
|
||||
```yaml
|
||||
mounted_on: shf01
|
||||
shelf_face: rear
|
||||
shelf_slot: 1
|
||||
```
|
||||
|
||||
Then add a `cluster: tappaas` line immediately after the `status: staging` line:
|
||||
|
||||
```yaml
|
||||
hostname: srv03
|
||||
kind: server
|
||||
status: staging
|
||||
cluster: tappaas
|
||||
location: The pile
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Regenerate all indices and rack artifacts**
|
||||
|
||||
Run: `make docs-index`
|
||||
Expected: `gen_overview.py` rewrites `docs/hardware/index.md` (now listing `shf01` under Shelves); `gen_rack.py` prints `Wrote rack01.md + rack01-elevation.svg (10 item(s))`, exit 0 (no schema error).
|
||||
|
||||
- [ ] **Step 6: Confirm the shelf and mounted devices rendered**
|
||||
|
||||
Run: `grep -c "shf01/" docs/infrastructure/racks/rack01.md`
|
||||
Expected: `3` (occupancy notes `front · shf01/1`, `front · shf01/2`, `rear · shf01/1`).
|
||||
|
||||
Run: `grep -q "shf01" docs/infrastructure/racks/rack01-elevation.svg && echo OK`
|
||||
Expected: `OK` (shelf strip drawn).
|
||||
|
||||
Run: `grep -q "U37–U46" docs/infrastructure/racks/rack01.md && echo OK`
|
||||
Expected: `OK` (mounted devices show the shelf's U-range).
|
||||
|
||||
- [ ] **Step 7: Run the full test suite**
|
||||
|
||||
Run: `make test`
|
||||
Expected: PASS.
|
||||
|
||||
- [ ] **Step 8: Build the site strictly**
|
||||
|
||||
Run: `mkdocs build --strict` (or `python3 -m mkdocs build --strict` if `mkdocs` is not on PATH)
|
||||
Expected: build succeeds with no warnings-as-errors.
|
||||
|
||||
- [ ] **Step 9: Confirm the drift guard is satisfied**
|
||||
|
||||
Run: `make docs-check`
|
||||
Expected: exit 0.
|
||||
|
||||
- [ ] **Step 10: Commit**
|
||||
|
||||
```bash
|
||||
git add docs/hardware/ docs/infrastructure/racks/
|
||||
git commit -m "feat(rack): place TaPPaaS nodes on shelf shf01 (provisional)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review
|
||||
|
||||
**Spec coverage (`2026-06-24-rack-shelves-design.md`):**
|
||||
- Container model: shelf placed + reserves U-range; mounted devices via `mounted_on`/`shelf_face`/`shelf_slot` — Task 1 (validation), Task 3 (data). ✔
|
||||
- `validate_item` mounted branch (forbid rack_u/u_height/rack_face; require shelf_face/shelf_slot) — Task 1 Step 4. ✔
|
||||
- `check_overlaps` skips mounted items — Task 1 Step 5. ✔
|
||||
- `check_shelves` (resolves to placed kind:shelf in rack; unique slot) wired into `generate` — Task 1 Steps 6–7. ✔
|
||||
- SVG shelf strip + subdivided occupant boxes — Task 2 Step 3. ✔
|
||||
- Occupancy rows for mounted devices (shelf U-range + `face · shelf/slot`, ordered after the shelf) — Task 2 Step 4. ✔
|
||||
- Power/network unchanged — no edits to `render_power`/`render_network`; mounted devices keep `power:`/`links:`. ✔
|
||||
- Demo data (shf01 U37–U46; srv01/srv02 front 1–2, srv03 rear 1; cluster tappaas) — Task 3. ✔
|
||||
- No mkdocs/Makefile/CI/overview_config changes — Global Constraints. ✔
|
||||
|
||||
**Placeholder scan:** No "TBD"/"handle edge cases"/"similar to Task N". The provisional demo placements are real-data-dependent and explicitly bounded.
|
||||
|
||||
**Type consistency:** `SHELF_FACES: set`; `check_shelves(items) -> None` (raises `SchemaError`), called per-rack alongside `check_overlaps`/`validate_power`/`validate_links`; `render_svg`/`render_page` -> `str`. Mounted items are identified uniformly by `"mounted_on" in fm` across `validate_item`, `check_overlaps`, `check_shelves`, `render_svg`, and `render_page`. The `shelf()` test helper (Task 1) is reused in Task 2. Field names (`mounted_on`, `shelf_face`, `shelf_slot`) match across tasks, tests, and demo data.
|
||||
|
|
@ -1,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 (``),
|
||||
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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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 `` — 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
|
||||
`` 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.
|
||||
|
|
@ -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.
|
||||
|
|
@ -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/
|
||||
|
|
@ -1 +0,0 @@
|
|||
# Ønske/skaffe-liste
|
||||
|
|
@ -1,2 +0,0 @@
|
|||
-r requirements.txt
|
||||
pytest==8.*
|
||||
|
|
@ -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.
|
||||
|
|
@ -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`.
|
||||
|
|
@ -22,39 +22,11 @@ CONFIG_PATH = REPO_ROOT / "scripts" / "overview_config.yml"
|
|||
|
||||
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):
|
||||
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:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
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:
|
||||
enums = cfg.get("enums", {})
|
||||
name = path.name
|
||||
for field in cfg["required_fields"]:
|
||||
if field not in fm:
|
||||
raise SchemaError(
|
||||
f"{name}: missing required field '{field}'. Add a line like "
|
||||
f"'{field}: {_example_value(field, enums)}' to the frontmatter."
|
||||
f"{_allowed_hint(field, enums)}"
|
||||
)
|
||||
for field, allowed in enums.items():
|
||||
raise SchemaError(f"{path}: missing required field '{field}'")
|
||||
for field, allowed in cfg.get("enums", {}).items():
|
||||
if field in fm and fm[field] not in allowed:
|
||||
raise SchemaError(
|
||||
f"{name}: {field} {fm[field]!r} is not 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}'."
|
||||
f"{path}: {field}={fm[field]!r} not in {allowed}"
|
||||
)
|
||||
stem = path.stem
|
||||
value = fm[key_field]
|
||||
if stem != value:
|
||||
hostname = fm["hostname"]
|
||||
if stem != hostname:
|
||||
raise SchemaError(
|
||||
f"{name}: '{key_field}: {value}' does not match the filename '{name}'. "
|
||||
f"Rename the file to '{value}.md', or set {key_field} to '{stem}'."
|
||||
f"{path}: filename stem {stem!r} != hostname {hostname!r}"
|
||||
)
|
||||
|
||||
|
||||
def fmt_cpu(fm: dict) -> str:
|
||||
model = fm.get("cpu") or ""
|
||||
model = fm.get("cpu", "")
|
||||
cores = fm.get("cpu_cores")
|
||||
threads = fm.get("cpu_threads")
|
||||
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"
|
||||
elif isinstance(cores, int):
|
||||
elif cores:
|
||||
suffix = f" · {cores}c"
|
||||
return (str(model) + suffix).strip()
|
||||
return (model + suffix).strip()
|
||||
|
||||
|
||||
def fmt_ram(fm: dict) -> str:
|
||||
n = fm.get("ram_gb")
|
||||
if isinstance(n, int):
|
||||
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"
|
||||
return f"{n} GB" if isinstance(n, int) else ""
|
||||
|
||||
|
||||
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")
|
||||
t = fm.get("storage_type", "").upper() if fm.get("storage_type") else ""
|
||||
if not isinstance(n, int):
|
||||
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:
|
||||
g = fm.get("nic_gbps")
|
||||
if g is None or g == "":
|
||||
if g is None:
|
||||
return ""
|
||||
if isinstance(g, str):
|
||||
return g
|
||||
|
||||
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"
|
||||
if isinstance(g, float) and not g.is_integer():
|
||||
return f"{g} GbE"
|
||||
return f"{int(g)} GbE"
|
||||
|
||||
|
||||
def cell(fm: dict, col: dict) -> str:
|
||||
kind = col.get("kind")
|
||||
if kind == "key-link":
|
||||
v = fm[col["field"]]
|
||||
return f"[{v}]({v}.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 == "hostname-link":
|
||||
h = fm["hostname"]
|
||||
return f"[{h}]({h}.md)"
|
||||
if kind == "cpu":
|
||||
return fmt_cpu(fm)
|
||||
if kind == "ram":
|
||||
|
|
@ -259,11 +181,7 @@ def main() -> int:
|
|||
errors.append(str(e))
|
||||
continue
|
||||
if fm is None:
|
||||
print(
|
||||
f"WARNING: {path.name}: no '---' frontmatter block — skipping "
|
||||
f"(it will not appear in the {args.category} index).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(f"WARNING: {path}: no YAML frontmatter, skipping", file=sys.stderr)
|
||||
continue
|
||||
try:
|
||||
validate(path, fm, cfg)
|
||||
|
|
@ -273,7 +191,8 @@ def main() -> int:
|
|||
items.append(fm)
|
||||
|
||||
if errors:
|
||||
report_errors(errors, args.category)
|
||||
for err in errors:
|
||||
print(f"ERROR: {err}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
output_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
|
|
|||
|
|
@ -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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
|
||||
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())
|
||||
|
|
@ -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
|
||||
|
|
@ -8,14 +8,13 @@ hardware:
|
|||
title: "Hardware Overview"
|
||||
source_dir: docs/hardware
|
||||
output_file: docs/hardware/index.md
|
||||
key_field: hostname
|
||||
required_fields:
|
||||
- hostname
|
||||
- kind
|
||||
- status
|
||||
enums:
|
||||
kind: [server, laptop, sbc, switch, ap, desktop, pdu, patch-panel, shelf, blank, ups, kvm, wan]
|
||||
status: [in-use, staging, spare, broken, donated]
|
||||
kind: [server, laptop, sbc, switch, ap, desktop]
|
||||
status: [in-use, spare, broken, donated]
|
||||
storage_type: [nvme, ssd, hdd, mixed]
|
||||
group_by: kind
|
||||
# Human-friendly H2 names per group_by value. Anything missing falls back
|
||||
|
|
@ -27,48 +26,13 @@ hardware:
|
|||
switch: Switches
|
||||
ap: Access points
|
||||
desktop: Desktops
|
||||
pdu: PDUs
|
||||
patch-panel: Patch panels
|
||||
shelf: Shelves
|
||||
blank: Blank panels
|
||||
ups: UPS
|
||||
kvm: KVM
|
||||
wan: WAN uplinks
|
||||
sort_by: hostname
|
||||
columns:
|
||||
- { header: Hostname, kind: key-link, field: hostname }
|
||||
- { header: Hostname, kind: hostname-link }
|
||||
- { header: Model, field: model }
|
||||
- { header: Location, field: location }
|
||||
- { header: CPU, kind: cpu }
|
||||
- { header: RAM, kind: ram }
|
||||
- { header: Storage, kind: storage }
|
||||
- { header: NIC, kind: nic }
|
||||
- { 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 }
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
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"))
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import pytest
|
||||
|
||||
import gen_overview
|
||||
|
||||
|
||||
CFG = {
|
||||
"required_fields": ["hostname", "kind", "status"],
|
||||
"enums": {
|
||||
"kind": ["server", "switch", "pdu"],
|
||||
"status": ["in-use", "staging", "spare"],
|
||||
},
|
||||
"key_field": "hostname",
|
||||
}
|
||||
|
||||
|
||||
def validate(stem, fm):
|
||||
from pathlib import Path
|
||||
gen_overview.validate(Path(f"{stem}.md"), fm, CFG)
|
||||
|
||||
|
||||
def test_missing_required_field_names_field_and_lists_allowed():
|
||||
with pytest.raises(gen_overview.SchemaError) as ei:
|
||||
validate("srv06", {"hostname": "srv06", "kind": "server"})
|
||||
msg = str(ei.value)
|
||||
assert "status" in msg # which field
|
||||
assert "in-use" in msg # an allowed value, so a novice knows what to type
|
||||
|
||||
|
||||
def test_enum_violation_lists_allowed_values():
|
||||
with pytest.raises(gen_overview.SchemaError) as ei:
|
||||
validate("x", {"hostname": "x", "kind": "router", "status": "in-use"})
|
||||
msg = str(ei.value)
|
||||
assert "router" in msg # the offending value
|
||||
assert "server" in msg # an allowed value
|
||||
|
||||
|
||||
def test_filename_mismatch_explains_the_rename():
|
||||
with pytest.raises(gen_overview.SchemaError) as ei:
|
||||
validate("srv06", {"hostname": "srv07", "kind": "server", "status": "in-use"})
|
||||
msg = str(ei.value).lower()
|
||||
assert "srv07.md" in msg or "rename" in msg
|
||||
|
||||
|
||||
def test_error_report_points_to_the_guide(capsys):
|
||||
gen_overview.report_errors(["srv06.md: missing required field 'status'"], "hardware")
|
||||
err = capsys.readouterr().err
|
||||
assert "make docs-index" in err
|
||||
assert gen_overview.GUIDE_URL in err
|
||||
|
|
@ -1,834 +0,0 @@
|
|||
import pytest
|
||||
|
||||
import gen_rack
|
||||
|
||||
|
||||
def item(**kw):
|
||||
base = {"hostname": "x", "kind": "server", "status": "in-use", "rack": "rack01"}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def shelf(**kw):
|
||||
base = {"hostname": "shf01", "kind": "shelf", "status": "in-use",
|
||||
"rack": "rack01", "rack_u": 37, "u_height": 10, "rack_face": "both"}
|
||||
base.update(kw)
|
||||
return base
|
||||
|
||||
|
||||
def test_validate_accepts_valid_placement():
|
||||
gen_rack.validate_item(item(rack_u=12, u_height=2, rack_face="front"))
|
||||
|
||||
|
||||
def test_validate_rejects_u_overflow():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack_u=47, u_height=3, rack_face="front"))
|
||||
|
||||
|
||||
def test_validate_rejects_u_below_one():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack_u=0, u_height=1, rack_face="front"))
|
||||
|
||||
|
||||
def test_validate_rejects_bad_face():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
||||
|
||||
|
||||
def test_validate_rejects_zero_u_with_units():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
||||
|
||||
|
||||
def test_validate_accepts_zero_u_rail():
|
||||
gen_rack.validate_item(item(rack_face="left"))
|
||||
|
||||
|
||||
def test_validate_rejects_missing_units_on_faced_item():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack_face="front"))
|
||||
|
||||
|
||||
def test_validate_rejects_empty_rack():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(rack=None, rack_u=1, u_height=1, rack_face="front"))
|
||||
|
||||
|
||||
def test_overlaps_detects_same_face_overlap():
|
||||
items = [
|
||||
item(hostname="a", rack_u=1, u_height=2, rack_face="front"),
|
||||
item(hostname="b", rack_u=2, u_height=1, rack_face="front"),
|
||||
]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_overlaps(items)
|
||||
|
||||
|
||||
def test_overlaps_allows_same_u_different_face():
|
||||
items = [
|
||||
item(hostname="a", rack_u=5, u_height=1, rack_face="front"),
|
||||
item(hostname="b", rack_u=5, u_height=1, rack_face="rear"),
|
||||
]
|
||||
gen_rack.check_overlaps(items) # no raise
|
||||
|
||||
|
||||
def test_overlaps_both_face_conflicts_with_front():
|
||||
items = [
|
||||
item(hostname="a", rack_u=5, u_height=1, rack_face="both"),
|
||||
item(hostname="b", rack_u=5, u_height=1, rack_face="front"),
|
||||
]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_overlaps(items)
|
||||
|
||||
|
||||
def test_overlaps_ignores_zero_u_rails():
|
||||
items = [
|
||||
item(hostname="p1", rack_face="left"),
|
||||
item(hostname="p2", rack_face="left"),
|
||||
]
|
||||
gen_rack.check_overlaps(items) # no raise
|
||||
|
||||
|
||||
def test_render_svg_has_two_columns_of_48_slots():
|
||||
svg = gen_rack.render_svg("rack01", [])
|
||||
# one faint slot rect per U per column (front + rear)
|
||||
assert svg.count('fill="#f5f5f5"') == 2 * gen_rack.RACK_UNITS
|
||||
assert svg.startswith("<svg")
|
||||
assert svg.rstrip().endswith("</svg>")
|
||||
|
||||
|
||||
def test_render_svg_includes_device_label():
|
||||
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert "mf00" in svg
|
||||
assert "U1" in svg
|
||||
|
||||
|
||||
def test_render_svg_is_deterministic():
|
||||
items = [
|
||||
item(hostname="b", rack_u=3, u_height=1, rack_face="front"),
|
||||
item(hostname="a", rack_u=1, u_height=1, rack_face="rear"),
|
||||
]
|
||||
assert gen_rack.render_svg("rack01", items) == gen_rack.render_svg(
|
||||
"rack01", list(reversed(items))
|
||||
)
|
||||
|
||||
|
||||
def test_render_page_has_banner_image_and_table():
|
||||
items = [item(hostname="mf00", rack_u=1, u_height=2, rack_face="front")]
|
||||
page = gen_rack.render_page("rack01", items)
|
||||
assert "do not edit by hand" in page
|
||||
assert '<div class="rack-elevation">' in page
|
||||
assert "<svg" in page
|
||||
assert "[Download SVG](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_render_svg_draws_zero_u_rail():
|
||||
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert "pdu01" in svg
|
||||
assert "rotate(-90" in svg
|
||||
|
||||
|
||||
def test_render_svg_rail_label_is_centered_across_bar():
|
||||
# The rotated 0U rail label must be centered across the narrow bar width,
|
||||
# not sitting on the alphabetic baseline (which reads off-centre).
|
||||
items = [item(hostname="pdu01", kind="pdu", rack_face="left")]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert 'dominant-baseline="central"' in svg
|
||||
|
||||
|
||||
def test_render_svg_both_face_draws_in_both_columns():
|
||||
items = [item(hostname="dev", rack_u=10, u_height=1, rack_face="both")]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert svg.count("dev (U10)") == 2
|
||||
|
||||
|
||||
def test_generate_writes_one_pair_per_rack(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
_write_item(
|
||||
hw,
|
||||
"a",
|
||||
"---\nhostname: a\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||
)
|
||||
_write_item(
|
||||
hw,
|
||||
"b",
|
||||
"---\nhostname: b\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack02\nrack_u: 1\nu_height: 1\nrack_face: front\n---\n",
|
||||
)
|
||||
rc = gen_rack.generate(hw, out)
|
||||
assert rc == 0
|
||||
assert (out / "rack01.md").exists() and (out / "rack02.md").exists()
|
||||
assert (out / "rack01-elevation.svg").exists()
|
||||
assert (out / "rack02-elevation.svg").exists()
|
||||
|
||||
|
||||
def test_generate_returns_1_on_overlap(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
for n, u in (("a", 1), ("b", 1)):
|
||||
_write_item(
|
||||
hw,
|
||||
n,
|
||||
f"---\nhostname: {n}\nkind: server\nstatus: in-use\n"
|
||||
f"rack: rack01\nrack_u: {u}\nu_height: 1\nrack_face: front\n---\n",
|
||||
)
|
||||
|
||||
rc = gen_rack.generate(hw, out)
|
||||
|
||||
assert rc == 1
|
||||
assert not (out / "rack01.md").exists()
|
||||
|
||||
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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_rejects_peer_port_below_one():
|
||||
items = [item(hostname="mf01", rack_u=2, u_height=1, rack_face="front",
|
||||
links=[{"local": "eth0", "peer": "sw01", "peer_port": 0}])]
|
||||
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()
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_validate_accepts_mounted_item():
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_with_rack_u():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1, rack_u=5))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_bad_shelf_face():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="left", shelf_slot=1))
|
||||
|
||||
|
||||
def test_validate_rejects_mounted_bad_slot():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=0))
|
||||
|
||||
|
||||
def test_overlaps_skips_mounted_items():
|
||||
items = [
|
||||
item(hostname="a", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||||
item(hostname="b", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||||
]
|
||||
gen_rack.check_overlaps(items) # no raise — mounted items claim no U-range
|
||||
|
||||
|
||||
def test_check_shelves_accepts_valid_mount():
|
||||
items = [shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_missing_shelf():
|
||||
items = [item(hostname="srv01", mounted_on="ghost",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_non_shelf_target():
|
||||
items = [
|
||||
item(hostname="sw01", kind="switch", rack_u=10, u_height=1,
|
||||
rack_face="front"),
|
||||
item(hostname="srv01", mounted_on="sw01",
|
||||
shelf_face="front", shelf_slot=1),
|
||||
]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_check_shelves_rejects_duplicate_slot():
|
||||
items = [shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1),
|
||||
item(hostname="srv02", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1)]
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.check_shelves(items)
|
||||
|
||||
|
||||
def test_generate_returns_1_on_dangling_mount(tmp_path):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
_write_item(
|
||||
hw, "srv01",
|
||||
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack01\nmounted_on: ghost\nshelf_face: front\nshelf_slot: 1\n---\n",
|
||||
)
|
||||
rc = gen_rack.generate(hw, out)
|
||||
assert rc == 1
|
||||
assert not (out / "rack01.md").exists()
|
||||
|
||||
|
||||
def test_render_svg_draws_shelf_and_occupants():
|
||||
items = [
|
||||
shelf(),
|
||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front", shelf_slot=1),
|
||||
item(hostname="srv02", mounted_on="shf01", shelf_face="front", shelf_slot=2),
|
||||
item(hostname="srv03", mounted_on="shf01", shelf_face="rear", shelf_slot=1),
|
||||
]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert "shf01" in svg
|
||||
assert "srv01" in svg and "srv02" in svg and "srv03" in svg
|
||||
# the shelf is NOT drawn as a generic full-height device box
|
||||
assert "shf01 (U37" not in svg
|
||||
|
||||
|
||||
def test_validate_accepts_chassis_u():
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1, chassis_u=10))
|
||||
|
||||
|
||||
def test_validate_rejects_bad_chassis_u():
|
||||
with pytest.raises(gen_rack.SchemaError):
|
||||
gen_rack.validate_item(item(hostname="srv01", mounted_on="shf01",
|
||||
shelf_face="front", shelf_slot=1, chassis_u=0))
|
||||
|
||||
|
||||
def test_render_svg_tower_height_reflects_chassis_u():
|
||||
items = [
|
||||
shelf(hostname="shf01", rack_u=46, u_height=1),
|
||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
|
||||
shelf_slot=1, chassis_u=10),
|
||||
]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
# 10U tower drawn as 10*U_H - SHELF_STRIP_H - 2 px tall
|
||||
assert 'height="192"' in svg
|
||||
|
||||
|
||||
def test_render_svg_paints_rail_device_over_shelf_tower():
|
||||
# A PDU rail-mounted within a tower's span must be painted after the tower
|
||||
# (later in the SVG = on top) so it stays visible.
|
||||
items = [
|
||||
shelf(hostname="shf02", rack_u=35, u_height=1),
|
||||
item(hostname="srv05", mounted_on="shf02", shelf_face="rear",
|
||||
shelf_slot=1, chassis_u=7),
|
||||
item(hostname="pdu03", kind="pdu", rack_u=34, u_height=1,
|
||||
rack_face="rear"),
|
||||
]
|
||||
svg = gen_rack.render_svg("rack01", items)
|
||||
assert svg.index("srv05") < svg.index("pdu03")
|
||||
|
||||
|
||||
def test_render_page_mounted_shows_chassis_span():
|
||||
items = [shelf(hostname="shf01", rack_u=46, u_height=1),
|
||||
item(hostname="srv01", mounted_on="shf01", shelf_face="front",
|
||||
shelf_slot=1, chassis_u=10)]
|
||||
page = gen_rack.render_page("rack01", items)
|
||||
assert "U37–U46" in page
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def test_network_graph_off_rack_peer_has_no_click():
|
||||
items = [
|
||||
item(hostname="srv01", rack_u=1, u_height=1, rack_face="front",
|
||||
links=[{"local": "eth0", "peer": "router0", "peer_port": 1}]),
|
||||
]
|
||||
out = gen_rack.render_network("rack01", items)
|
||||
assert "style router0 fill:" in out # off-rack peer is still colored
|
||||
assert 'click router0 "' not in out # but it is NOT clickable
|
||||
assert 'click srv01 "/hardware/srv01/"' in out
|
||||
|
||||
|
||||
# --- novice-friendly error messages ---------------------------------------
|
||||
|
||||
def test_overlap_message_explains_the_conflict():
|
||||
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) as ei:
|
||||
gen_rack.check_overlaps(items)
|
||||
msg = str(ei.value).lower()
|
||||
assert "overlap" in msg
|
||||
assert "same u" in msg or "free u" in msg or "other face" in msg # tells them how to fix
|
||||
|
||||
|
||||
def test_zero_u_message_tells_user_to_drop_units():
|
||||
with pytest.raises(gen_rack.SchemaError) as ei:
|
||||
gen_rack.validate_item(item(rack_face="left", rack_u=1, u_height=1))
|
||||
msg = str(ei.value).lower()
|
||||
assert "rack_u" in msg and "remove" in msg
|
||||
|
||||
|
||||
def test_bad_face_message_lists_valid_faces():
|
||||
with pytest.raises(gen_rack.SchemaError) as ei:
|
||||
gen_rack.validate_item(item(rack_u=1, u_height=1, rack_face="sideways"))
|
||||
msg = str(ei.value)
|
||||
assert "front" in msg and "left" in msg # both U-mounted and 0U options shown
|
||||
|
||||
|
||||
def test_generate_error_output_is_novice_friendly(tmp_path, capsys):
|
||||
hw = tmp_path / "hardware"
|
||||
out = tmp_path / "out"
|
||||
hw.mkdir()
|
||||
_write_item(
|
||||
hw, "srv01",
|
||||
"---\nhostname: srv01\nkind: server\nstatus: in-use\n"
|
||||
"rack: rack01\nrack_face: front\n---\n", # front face but no rack_u/u_height
|
||||
)
|
||||
rc = gen_rack.generate(hw, out)
|
||||
err = capsys.readouterr().err
|
||||
assert rc == 1
|
||||
assert "make docs-index" in err
|
||||
assert gen_rack.GUIDE_URL in err
|
||||
Loading…
Add table
Reference in a new issue