Compare commits

..

2 commits

Author SHA1 Message Date
sjat
3287df35c5 Merge branch 'feat/hardware-overview'
Some checks failed
Build docs site / build (push) Failing after 43s
Build slides / build (push) Successful in 59s
2026-05-17 21:11:19 +02:00
sjat
6b1a61461b feat(docs): MkDocs Material site + auto-generated hardware overview
Bootstraps an MkDocs Material documentation site (rendered to
docs.makerfloss.eu by the Forgejo Actions runner). The first feature
is an auto-generated hardware overview built from per-host YAML
frontmatter blocks under docs/hardware/.

- mkdocs.yml, requirements.txt: MkDocs Material 9.5 + pyyaml
- Makefile: docs-index | docs-build | docs-serve | docs-check
- scripts/gen_overview.py: stdlib + pyyaml generator, deterministic and
  offline. Reads scripts/overview_config.yml — category-driven so
  services/vms can plug in later without touching the script.
- scripts/overview_config.yml: hardware schema and index layout
- docs/hardware/{makerfloss,fisi,tembo}.md: 3 sample entries
- docs/hardware/index.md: GENERATED, committed (CI fails on drift)
- docs/index.md: site landing page
- .forgejo/workflows/docs.yml: drift-check + mkdocs build --strict +
  rsync site/ to /srv/docs-makerfloss/html on push to main
- .gitignore: site/, .venv, __pycache__

Schema:
- hostname, kind, status (required; kind/status are enums)
- model, location, cpu, cpu_cores, cpu_threads, ram_gb, storage_gb,
  storage_type (enum), storage_notes, nic_gbps (all optional)
- Filename stem MUST equal hostname (enforced by generator)
- Extra optional fields are accepted silently and live on the per-page

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 21:10:58 +02:00
12 changed files with 494 additions and 0 deletions

View file

@ -0,0 +1,46 @@
name: Build docs site
on:
push:
branches: [main]
pull_request:
branches: [main]
workflow_dispatch:
jobs:
build:
runs-on: self-hosted
container:
image: python:3.13-bookworm
volumes:
- /srv/docs-makerfloss/html:/output
steps:
- name: Install git for actions/checkout
run: |
apt-get update -qq
apt-get install -y --no-install-recommends git rsync
- name: Checkout repository
uses: actions/checkout@v4
- name: Install Python dependencies
run: pip install --quiet -r requirements.txt
- name: Regenerate hardware index
run: python3 scripts/gen_overview.py --category hardware
- name: Fail on drift in docs/hardware/index.md
run: |
if ! git diff --exit-code docs/hardware/index.md; then
echo
echo "::error::docs/hardware/index.md is stale."
echo "Regenerate locally via 'make docs-index' and commit the result."
exit 1
fi
- name: Build site (strict)
run: mkdocs build --strict
- name: Publish to /output (main only)
if: github.ref == 'refs/heads/main' && github.event_name == 'push'
run: rsync -a --delete site/ /output/

9
.gitignore vendored
View file

@ -5,6 +5,15 @@
slides/*.html slides/*.html
slides/*.pdf slides/*.pdf
# ---> MkDocs build output
site/
.cache/
# ---> Python venvs / caches
.venv/
__pycache__/
*.pyc
# ---> Local secrets (Forgejo API token, etc.) # ---> Local secrets (Forgejo API token, etc.)
.env .env
.env.* .env.*

25
Makefile Normal file
View file

@ -0,0 +1,25 @@
.PHONY: help docs-index docs-build docs-serve docs-check slides
help:
@echo "Targets:"
@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 index, fail if it differs from the committed copy"
@echo " slides Run build-slides.sh (Marp slides)"
docs-index:
python3 scripts/gen_overview.py --category hardware
docs-build:
mkdocs build --strict
docs-serve:
mkdocs serve
docs-check:
python3 scripts/gen_overview.py --category hardware
git diff --exit-code docs/hardware/index.md
slides:
./build-slides.sh

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

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

16
docs/hardware/index.md Normal file
View file

@ -0,0 +1,16 @@
# Hardware Overview
_Auto-generated from `docs/hardware/*.md` — do not edit by hand. Run `make docs-index` after changing a file._
## Laptops
| 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 | 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 |

View file

@ -0,0 +1,24 @@
---
hostname: makerfloss
kind: server
status: in-use
model: Hetzner CX22
location: Hetzner HEL1 (cloud)
cpu: AMD EPYC (shared vCPU)
cpu_cores: 2
cpu_threads: 2
ram_gb: 4
storage_gb: 40
storage_type: nvme
nic_gbps: 1
---
# 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.

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

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

22
docs/index.md Normal file
View file

@ -0,0 +1,22 @@
# MakerFLOSS Docs
Documentation for the [MakerFLOSS](https://forgejo.makerfloss.eu/sjat/MakerFLOSS)
initiative at [Orange Makerspace](https://orangemakers.dk) — a bi-weekly FLOSS
jam-session community focused on self-hosted, open-source infrastructure.
## Sections
- [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.
- [House rules](makerFLOSS_house_rules.md) — working norms, governance, and
what we do (and don't) do.
## Working norms (summary)
- **Language:** English for code and docs. Danish allowed in meeting notes and
community communications.
- **Environments:** containerised and reproducible.
- **Hardware:** all setups documented (this site) and physically labelled.
- **Decisions:** lightweight markdown decision logs under
[`docs/superpowers/`](https://forgejo.makerfloss.eu/sjat/MakerFLOSS/src/branch/main/docs/superpowers).
- **License:** FLOSS by default; MIT for what we build.

55
mkdocs.yml Normal file
View file

@ -0,0 +1,55 @@
site_name: MakerFLOSS Docs
site_url: https://docs.makerfloss.eu/
site_description: Documentation for the MakerFLOSS initiative at Orange Makerspace.
repo_url: https://forgejo.makerfloss.eu/sjat/MakerFLOSS
repo_name: sjat/MakerFLOSS
edit_uri: _edit/main/
theme:
name: material
features:
- navigation.indexes
- navigation.sections
- navigation.top
- content.code.copy
- search.suggest
- search.highlight
palette:
- media: "(prefers-color-scheme: light)"
scheme: default
primary: deep orange
toggle:
icon: material/weather-night
name: Switch to dark mode
- media: "(prefers-color-scheme: dark)"
scheme: slate
primary: deep orange
toggle:
icon: material/weather-sunny
name: Switch to light mode
markdown_extensions:
- admonition
- toc:
permalink: true
- tables
- attr_list
- md_in_html
- pymdownx.superfences
- pymdownx.highlight:
anchor_linenums: true
- pymdownx.inlinehilite
- pymdownx.tabbed:
alternate_style: true
- pymdownx.tasklist:
custom_checkbox: true
plugins:
- search
nav:
- Home: index.md
- Hardware:
- hardware/index.md
- House rules: makerFLOSS_house_rules.md

3
requirements.txt Normal file
View file

@ -0,0 +1,3 @@
mkdocs==1.6.*
mkdocs-material==9.5.*
pyyaml==6.*

207
scripts/gen_overview.py Executable file
View file

@ -0,0 +1,207 @@
#!/usr/bin/env python3
"""Generate a category overview Markdown file from per-item YAML frontmatter.
Reads `scripts/overview_config.yml`, picks the block named by `--category`,
walks `source_dir/*.md` (excluding `output_file`), validates each file's
frontmatter, and writes a grouped+sorted table to `output_file`.
Exits non-zero on any schema violation. Deterministic, offline, stdlib + PyYAML.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
CONFIG_PATH = REPO_ROOT / "scripts" / "overview_config.yml"
FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---\s*\n", re.DOTALL)
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
try:
data = yaml.safe_load(m.group(1))
except yaml.YAMLError as e:
raise SchemaError(f"{path}: invalid YAML frontmatter: {e}") from e
if not isinstance(data, dict):
raise SchemaError(f"{path}: frontmatter is not a mapping")
return data
def validate(path: Path, fm: dict, cfg: dict) -> None:
for field in cfg["required_fields"]:
if field not in fm:
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"{path}: {field}={fm[field]!r} not in {allowed}"
)
stem = path.stem
hostname = fm["hostname"]
if stem != hostname:
raise SchemaError(
f"{path}: filename stem {stem!r} != hostname {hostname!r}"
)
def fmt_cpu(fm: dict) -> str:
model = fm.get("cpu", "")
cores = fm.get("cpu_cores")
threads = fm.get("cpu_threads")
suffix = ""
if cores and threads and threads != cores:
suffix = f" · {cores}c/{threads}t"
elif cores:
suffix = f" · {cores}c"
return (model + suffix).strip()
def fmt_ram(fm: dict) -> str:
n = fm.get("ram_gb")
return f"{n} GB" if isinstance(n, int) else ""
def fmt_storage(fm: dict) -> str:
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
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:
return ""
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 == "hostname-link":
h = fm["hostname"]
return f"[{h}]({h}.md)"
if kind == "cpu":
return fmt_cpu(fm)
if kind == "ram":
return fmt_ram(fm)
if kind == "storage":
return fmt_storage(fm)
if kind == "nic":
return fmt_nic(fm)
value = fm.get(col["field"], "")
return "" if value is None else str(value)
def render(cfg: dict, items: list[dict]) -> str:
columns = cfg["columns"]
group_by = cfg.get("group_by")
sort_by = cfg.get("sort_by", "hostname")
group_titles = cfg.get("group_titles", {})
if group_by:
groups: dict[str, list[dict]] = {}
for fm in items:
groups.setdefault(fm.get(group_by, ""), []).append(fm)
ordered = sorted(groups.items())
else:
ordered = [("", items)]
lines: list[str] = []
lines.append(f"# {cfg['title']}")
lines.append("")
lines.append(
f"_Auto-generated from `{cfg['source_dir']}/*.md` — do not edit by hand. "
f"Run `make docs-index` after changing a file._"
)
lines.append("")
for group_key, rows in ordered:
rows.sort(key=lambda r: r.get(sort_by, ""))
if group_by:
title = group_titles.get(group_key, group_key.title() + "s")
lines.append(f"## {title}")
lines.append("")
header = "| " + " | ".join(c["header"] for c in columns) + " |"
sep = "|" + "|".join("---" for _ in columns) + "|"
lines.append(header)
lines.append(sep)
for fm in rows:
lines.append("| " + " | ".join(cell(fm, c) for c in columns) + " |")
lines.append("")
return "\n".join(lines).rstrip() + "\n"
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
parser.add_argument("--category", required=True, help="Category key from overview_config.yml")
args = parser.parse_args()
config_all = yaml.safe_load(CONFIG_PATH.read_text(encoding="utf-8"))
if args.category not in config_all:
print(f"ERROR: category {args.category!r} not in {CONFIG_PATH}", file=sys.stderr)
return 2
cfg = config_all[args.category]
source_dir = REPO_ROOT / cfg["source_dir"]
output_file = REPO_ROOT / cfg["output_file"]
output_abs = output_file.resolve()
items: list[dict] = []
errors: list[str] = []
for path in sorted(source_dir.glob("*.md")):
if path.resolve() == output_abs:
continue
try:
fm = parse_frontmatter(path)
except SchemaError as e:
errors.append(str(e))
continue
if fm is None:
print(f"WARNING: {path}: no YAML frontmatter, skipping", file=sys.stderr)
continue
try:
validate(path, fm, cfg)
except SchemaError as e:
errors.append(str(e))
continue
items.append(fm)
if errors:
for err in errors:
print(f"ERROR: {err}", file=sys.stderr)
return 1
output_file.parent.mkdir(parents=True, exist_ok=True)
tmp = output_file.with_suffix(output_file.suffix + ".tmp")
tmp.write_text(render(cfg, items), encoding="utf-8")
tmp.replace(output_file)
print(f"Wrote {output_file.relative_to(REPO_ROOT)} ({len(items)} item(s))")
return 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,38 @@
# Configuration for scripts/gen_overview.py
#
# Each top-level key is a category. The generator is invoked with
# --category <name> and looks up its block here. To add a new category
# (services, vms, ...) later, copy a block and adjust the fields.
hardware:
title: "Hardware Overview"
source_dir: docs/hardware
output_file: docs/hardware/index.md
required_fields:
- hostname
- kind
- status
enums:
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
# to the raw value title-cased + "s".
group_titles:
server: Servers
laptop: Laptops
sbc: Single-board computers
switch: Switches
ap: Access points
desktop: Desktops
sort_by: hostname
columns:
- { 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 }