#!/usr/bin/env python3 """ Single source of truth for project specification. Writes: specification.json — for coding agents / tools specification.html — for humans Run after architecture changes: ./scripts/generate-specification.py Why JSON for agents (not only HTML): - Stable keys, easy to grep/parse - No layout noise - Matches how tools ingest structured context HTML is a readable projection of the same data — never edit HTML by hand. """ from __future__ import annotations import json from datetime import datetime, timezone from pathlib import Path ROOT = Path(__file__).resolve().parents[1] SPEC_VERSION = "1.2.0" GENERATED_AT = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") # --------------------------------------------------------------------------- # Canonical specification (edit THIS, then re-run the generator) # --------------------------------------------------------------------------- SPEC = { "meta": { "name": "arr-stack", "title": "Homelab media + local AI stack", "version": SPEC_VERSION, "generated_at": GENERATED_AT, "generator": "scripts/generate-specification.py", "repository": { "forgejo": "http://192.168.8.123:3002/tim/stack", "clone_ssh": "ssh://git@192.168.8.123:2222/tim/stack.git", "default_branch": "main", }, "primary_host": { "hostname": "stack", "lan_ip": "192.168.8.123", "user": "tim", "os": "Ubuntu Server 24.04", "hardware": "AMD Ryzen 7 5800H + Radeon Graphics (shared RAM), ~28–32 GB RAM", "stack_root": "/opt/stack", "media_root": "/storage", }, "demo_credentials": { "username": "tim", "password": "asdfasdf", "note": "Tech demo only; used for local UIs when configured that way", }, "docs_for_humans": [ "specification.html", "quickstart.md", "README.md", ], "docs_for_agents": [ "specification.json", "AGENTS.md", "quickstart.md", "profiles/*.env", "docker-compose.yml", ], "never_commit": [ ".env", "secrets/.env", "wireguard/wg0.conf", "config/", "models/**/*.gguf", "data/", ], }, "architecture": { "summary": ( "Docker Compose stack combining VPN-locked torrents (*arr + qBittorrent via gluetun), " "Usenet (SABnzbd), media (Jellyfin), local LLM inference (Prism llama-server + Open WebUI), " "Forgejo git, OpenHands agent, Caddy LAN landing page, and Cloudflare DDNS." ), "networks": { "arr-net": "Bridge network for most services", "gluetun_namespace": "qbittorrent uses network_mode: service:gluetun (VPN killswitch)", "host_network": ["cloudflare-ddns"], }, "data_layout": { "CONFIG_DIR": "App configs (default /opt/stack/config on server)", "DATA_DIR": "Shared media tree (default /storage on server)", "paths": { "media": "/data/media/{movies,tv,music}", "torrents": "/data/torrents/{movies,tv,music}", "usenet": "/data/usenet/incomplete and /data/usenet/complete/{movies,tv,music}", "models": "models/bonsai/*.gguf mounted read-only at /models in bonsai", "workspace": "AI_WORKSPACE_DIR for Open WebUI + OpenHands", }, }, "security_model": { "torrents": "Only via WireGuard through gluetun; no BitTorrent port on host", "usenet": "Direct SSL to provider (not through gluetun)", "lan_firewall": "UFW allow service ports from LAN_SUBNET only", "landing_and_uis": "Plain HTTP on LAN (no TLS certs yet)", }, }, "hardware_profiles": { "how_to_apply": "./scripts/apply-profile.sh mini|mid|gaming", "files": { "mini": "profiles/mini.env", "mid": "profiles/mid.env", "gaming": "profiles/gaming.env", }, "profiles": { "mini": { "hardware": "Beelink / SER5-class mini PC", "inference": "cpu", "ngl": 0, "default_ctx": 32768, "threads": 12, "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", "model_repo": "prism-ml/Ternary-Bonsai-27B-gguf", "notes": "CPU preferred on APU; do not force NGL in docker-compose.override.yml", }, "mid": { "hardware": "~8GB AMD discrete GPU, ~16GB system RAM", "inference": "vulkan", "ngl": 99, "default_ctx": 8192, "threads": 8, "kv4": True, "default_model": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", "model_repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", "notes": "Fits VRAM; use KV4 for memory", }, "gaming": { "hardware": "~16GB AMD GPU, ~64GB system RAM", "inference": "vulkan", "ngl": 99, "default_ctx": 32768, "threads": 16, "default_model": "Ternary-Bonsai-27B-Q2_0.gguf", "also_download": ["Qwen3.5-9B Q4_K_M"], "notes": "Full Vulkan + large context", }, }, "models": { "bonsai27": { "file": "Ternary-Bonsai-27B-Q2_0.gguf", "repo": "prism-ml/Ternary-Bonsai-27B-gguf", "runtime": "PrismML llama.cpp fork (custom Q2_0)", "size_approx": "~7.2 GB", }, "qwen9_abliterated_q4km": { "file": "Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf", "repo": "mradermacher/Qwen3.5-9B-abliterated-v2-MAX-GGUF", "runtime": "Same Prism llama-server image (standard GGUF)", "size_approx": "~5.6 GB", }, }, }, "services": [ { "id": "landing", "name": "Landing (Caddy)", "image": "caddy:2-alpine", "port": 80, "url": "http://HOST/", "purpose": "Dark-mode LAN resource map", "network": "arr-net", "category": "gateway", }, { "id": "gluetun", "name": "gluetun", "image": "qmcgaw/gluetun:v3", "port": None, "published_ports": ["8080:8080 (qBittorrent WebUI)"], "purpose": "WireGuard VPN + killswitch", "network": "arr-net", "category": "vpn", "config": "wireguard/wg0.conf", }, { "id": "qbittorrent", "name": "qBittorrent", "image": "lscr.io/linuxserver/qbittorrent:latest", "port": 8080, "url": "http://HOST:8080/", "purpose": "Torrent client (VPN-only via gluetun)", "network": "service:gluetun", "category": "download", }, { "id": "sabnzbd", "name": "SABnzbd", "image": "lscr.io/linuxserver/sabnzbd:latest", "port": 8085, "url": "http://HOST:8085/", "purpose": "Usenet NZB downloader", "network": "arr-net", "category": "download", }, { "id": "prowlarr", "name": "Prowlarr", "image": "lscr.io/linuxserver/prowlarr:latest", "port": 9696, "url": "http://HOST:9696/", "purpose": "Indexer manager (torrents + NZB)", "network": "arr-net", "category": "download", }, { "id": "flaresolverr", "name": "FlareSolverr", "image": "ghcr.io/flaresolverr/flaresolverr:latest", "port": 8191, "url": "http://flaresolverr:8191 (internal)", "purpose": "Cloudflare JS challenge proxy for Prowlarr", "network": "arr-net", "category": "download", "published": False, }, { "id": "sonarr", "name": "Sonarr", "image": "lscr.io/linuxserver/sonarr:latest", "port": 8989, "url": "http://HOST:8989/", "purpose": "TV automation", "network": "arr-net", "category": "media", }, { "id": "radarr", "name": "Radarr", "image": "lscr.io/linuxserver/radarr:latest", "port": 7878, "url": "http://HOST:7878/", "purpose": "Movie automation", "network": "arr-net", "category": "media", }, { "id": "lidarr", "name": "Lidarr", "image": "lscr.io/linuxserver/lidarr:latest", "port": 8686, "url": "http://HOST:8686/", "purpose": "Music automation", "network": "arr-net", "category": "media", }, { "id": "bazarr", "name": "Bazarr", "image": "lscr.io/linuxserver/bazarr:latest", "port": 6767, "url": "http://HOST:6767/", "purpose": "Subtitles", "network": "arr-net", "category": "media", }, { "id": "jellyfin", "name": "Jellyfin", "image": "lscr.io/linuxserver/jellyfin:latest", "port": 8096, "url": "http://HOST:8096/", "purpose": "Media server / playback", "network": "arr-net", "category": "media", }, { "id": "bonsai", "name": "Bonsai (llama-server)", "image": "arr-stack/bonsai:local (ai/Dockerfile)", "port": 8081, "url": "http://HOST:8081/v1", "purpose": "Local LLM OpenAI-compatible API (Prism build)", "network": "arr-net", "category": "ai", }, { "id": "open-webui", "name": "Open WebUI", "image": "ghcr.io/open-webui/open-webui:main", "port": 3000, "url": "http://HOST:3000/", "purpose": "Chat UI + RAG + tools", "network": "arr-net", "category": "ai", "llm_base": "http://bonsai:8080/v1", }, { "id": "searxng", "name": "SearXNG", "image": "docker.io/searxng/searxng:latest", "port": 8888, "url": "http://127.0.0.1:8888/ (localhost only)", "purpose": "Private search for Open WebUI RAG", "network": "arr-net", "category": "ai", "published": "loopback", }, { "id": "openhands", "name": "OpenHands", "image": "ghcr.io/all-hands-ai/openhands:0.54", "port": 3001, "url": "http://HOST:3001/", "purpose": "AI coding agent (always deployed)", "network": "arr-net", "category": "ai", "note": "No Forgejo integration in UI — clone repos into workspace", }, { "id": "forgejo", "name": "Forgejo", "image": "codeberg.org/forgejo/forgejo:11", "port": 3002, "url": "http://HOST:3002/", "ssh_port": 2222, "purpose": "Self-hosted Git", "network": "arr-net", "category": "code", }, { "id": "cloudflare-ddns", "name": "Cloudflare DDNS", "image": "timothyjmiller/cloudflare-ddns:latest", "port": None, "url": None, "purpose": "Update public IPv4 A records for domain + subdomains", "network": "host", "category": "dns", "domain": "devopshomelab.com", "subdomains": ["mc", "minecraft", "vpn", "www", "forgejo", "jellyfin"], }, ], "scripts": { "generate-specification.py": "Regenerate specification.json + specification.html (this file)", "apply-profile.sh": "Merge profiles/{mini,mid,gaming}.env into .env", "bootstrap-stack.sh": "Fresh bring-up: dirs, models, compose up all, heal gluetun, usenet, cloudflare", "download-models.sh": "Download Bonsai 27B and/or Qwen 9B GGUF into models/bonsai", "download-bonsai-model.sh": "Wrapper → download-models.sh", "configure-usenet.sh": "SABnzbd folders + Newshosting/Tweaknews from secrets/.env", "render-cloudflare-ddns-env.sh": "Build config/cloudflare-ddns/ddns.env from secrets", "gluetun-endpoint-watch.sh": "If gluetun unhealthy: dig WG host, update Endpoint, recreate gluetun+qbit", }, "ansible": { "entry": "ansible/site.yml", "inventory": "ansible/inventory/hosts.ini", "roles": ["common", "docker", "amd_vulkan", "firewall", "stack", "users"], "stack_deploy_method": "git (default) or rsync", "server_repo": "/opt/stack tracks Forgejo via git pull", "timers": ["arr-stack.service", "gluetun-endpoint-watch.timer"], }, "workflows": { "fresh_install": [ "cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keys", "Place wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)", "./scripts/apply-profile.sh mini|mid|gaming", "./scripts/bootstrap-stack.sh", "One-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)", ], "day_to_day_server": [ "cd /opt/stack && git pull", "docker compose up -d", "rebuild bonsai if Dockerfile/profile backend changed", ], "after_secrets_change": { "cloudflare": [ "./scripts/render-cloudflare-ddns-env.sh", "docker compose up -d cloudflare-ddns --force-recreate", ], "usenet_servers": ["./scripts/configure-usenet.sh", "Test servers in SABnzbd UI"], "nzbgeek": ["Update API key in Prowlarr UI"], "wireguard": [ "Edit wireguard/wg0.conf", "docker compose up -d gluetun --force-recreate", "or ./scripts/gluetun-endpoint-watch.sh", ], "stack_env": ["Edit .env", "docker compose up -d (+ build bonsai if backend changes)"], }, "switch_profile": [ "./scripts/apply-profile.sh mid", "docker compose build bonsai && docker compose up -d", ], "agent_git": [ "Commit meaningful changes with complete sentences", "Push to origin main (Forgejo)", "Never commit secrets", "After architecture changes: run generate-specification.py and commit both outputs", ], }, "compose": { "file": "docker-compose.yml", "override": "docker-compose.override.yml", "always_deploy_all": True, "note": "OpenHands is not behind a compose profile; full stack always starts", "override_rules": [ "Do not put group_add names (video/render) — use host GIDs or 0666 /dev/dri", "Do not force bonsai NGL in override on mini (overrides .env)", "mini: jellyfin /dev/dri only; mid/gaming: add bonsai devices for Vulkan", ], }, "env_and_secrets": { "stack_env": ".env (from .env.example) — ports, paths, AI, bootstrap flags, demo login", "secrets_env": "secrets/.env — usenet + cloudflare API (gitignored)", "approx_env_keys": 80, "demo_user": "tim", "demo_pass": "asdfasdf", }, } def write_json(path: Path) -> None: path.write_text(json.dumps(SPEC, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") def _esc(s: object) -> str: return ( str(s) .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace('"', """) ) def write_html(path: Path) -> None: m = SPEC["meta"] host = m["primary_host"]["lan_ip"] services = SPEC["services"] profiles = SPEC["hardware_profiles"]["profiles"] scripts = SPEC["scripts"] workflows = SPEC["workflows"] def svc_rows() -> str: rows = [] for s in services: url = (s.get("url") or "—").replace("HOST", host) port = s.get("port") port_s = str(port) if port is not None else "—" rows.append( "" f"{_esc(s['id'])}" f"{_esc(s['name'])}" f"{_esc(s.get('category', ''))}" f"{_esc(port_s)}" f"{_esc(url)}" f"{_esc(s['purpose'])}" "" ) return "\n".join(rows) def profile_cards() -> str: cards = [] for name, p in profiles.items(): cards.append( f"""

{_esc(name)}

{_esc(p['hardware'])}

""" ) return "\n".join(cards) def script_list() -> str: return "\n".join( f"
  • {_esc(k)} — {_esc(v)}
  • " for k, v in scripts.items() ) def workflow_secrets() -> str: parts = [] for k, steps in workflows["after_secrets_change"].items(): items = "".join(f"
  • {_esc(s)}
  • " for s in steps) parts.append(f"

    {_esc(k)}

      {items}
    ") return "\n".join(parts) html = f""" {_esc(m['title'])} — specification

    {_esc(m['title'])}

    {_esc(SPEC['architecture']['summary'])}

    spec v{_esc(m['version'])} generated {_esc(m['generated_at'])} host {_esc(host)} stack {_esc(m['primary_host']['stack_root'])}

    Primary host

    Hardware profiles

    Apply with ./scripts/apply-profile.sh mini|mid|gaming

    {profile_cards()}

    Services

    All services deploy by default (docker compose up -d). Replace HOST with {_esc(host)}. Links use plain http:// (no TLS).

    {svc_rows()}
    IDNameCategoryPortURLPurpose

    Scripts

    After changing secrets

    Edit secrets/.env first, then:

    {workflow_secrets()}

    Fresh install

      {''.join(f'
    1. {_esc(s)}
    2. ' for s in workflows['fresh_install'])}

    Day-to-day on server

      {''.join(f'
    1. {_esc(s)}
    2. ' for s in workflows['day_to_day_server'])}

    Ansible

    Never commit

    """ path.write_text(html, encoding="utf-8") def main() -> None: json_path = ROOT / "specification.json" html_path = ROOT / "specification.html" write_json(json_path) write_html(html_path) print(f"Wrote {json_path.relative_to(ROOT)}") print(f"Wrote {html_path.relative_to(ROOT)}") print(f"spec version {SPEC_VERSION} @ {GENERATED_AT}") if __name__ == "__main__": main()