diff --git a/.forgejo/workflows/docs.yml b/.forgejo/workflows/docs.yml new file mode 100644 index 0000000..9510224 --- /dev/null +++ b/.forgejo/workflows/docs.yml @@ -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/ diff --git a/.gitignore b/.gitignore index d763f55..8021355 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,15 @@ slides/*.html slides/*.pdf +# ---> MkDocs build output +site/ +.cache/ + +# ---> Python venvs / caches +.venv/ +__pycache__/ +*.pyc + # ---> Local secrets (Forgejo API token, etc.) .env .env.* diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..fbd716b --- /dev/null +++ b/Makefile @@ -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 diff --git a/docs/hardware/fisi.md b/docs/hardware/fisi.md new file mode 100644 index 0000000..da24ede --- /dev/null +++ b/docs/hardware/fisi.md @@ -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. diff --git a/docs/hardware/index.md b/docs/hardware/index.md new file mode 100644 index 0000000..032af47 --- /dev/null +++ b/docs/hardware/index.md @@ -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 | diff --git a/docs/hardware/makerfloss.md b/docs/hardware/makerfloss.md new file mode 100644 index 0000000..a8f286a --- /dev/null +++ b/docs/hardware/makerfloss.md @@ -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. diff --git a/docs/hardware/tembo.md b/docs/hardware/tembo.md new file mode 100644 index 0000000..63bd034 --- /dev/null +++ b/docs/hardware/tembo.md @@ -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. diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..ef68159 --- /dev/null +++ b/docs/index.md @@ -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. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..3afb211 --- /dev/null +++ b/mkdocs.yml @@ -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 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..11496ff --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +mkdocs==1.6.* +mkdocs-material==9.5.* +pyyaml==6.* diff --git a/scripts/gen_overview.py b/scripts/gen_overview.py new file mode 100755 index 0000000..7538e0b --- /dev/null +++ b/scripts/gen_overview.py @@ -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()) diff --git a/scripts/overview_config.yml b/scripts/overview_config.yml new file mode 100644 index 0000000..97346e4 --- /dev/null +++ b/scripts/overview_config.yml @@ -0,0 +1,38 @@ +# Configuration for scripts/gen_overview.py +# +# Each top-level key is a category. The generator is invoked with +# --category 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 }