{_esc(name)}
+{_esc(p['hardware'])}
+-
+
- Inference: {_esc(p['inference'])} (ngl={_esc(p['ngl'])}) +
- Context: {_esc(p['default_ctx'])} +
- Model:
{_esc(p['default_model'])}
+ - {_esc(p.get('notes', ''))} +
diff --git a/AGENTS.md b/AGENTS.md index c6e4e06..8e4e54f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,5 +1,20 @@ # Agent notes (arr-stack) +## Project specification (read first) + +| File | Audience | +|------|----------| +| **`specification.json`** | Coding agents / tools — structured index of the whole stack | +| **`specification.html`** | Humans — same content, readable | + +**Do not edit those two files by hand.** Update `scripts/generate-specification.py` (`SPEC` dict), then run: + +```bash +./scripts/generate-specification.py +``` + +Commit **both** outputs together so they stay in lockstep. Prefer JSON over HTML for agent context. + ## Git - Remote: `origin` → `ssh://git@192.168.8.123:2222/tim/stack.git` (Forgejo `tim/stack`) diff --git a/quickstart.md b/quickstart.md index 652f8a6..f4c244e 100644 --- a/quickstart.md +++ b/quickstart.md @@ -3,6 +3,9 @@ Homelab host: **`192.168.8.123`** (SER5, Ubuntu 24.04) Stack: **`/opt/stack`** · Media: **`/storage`** · User: **`tim`** +**Full project index (session recovery):** [specification.html](./specification.html) (human) · [specification.json](./specification.json) (agents). +Regenerate both from one source: `./scripts/generate-specification.py` + --- ## Fresh install / tech demo (max automation) diff --git a/scripts/generate-specification.py b/scripts/generate-specification.py new file mode 100755 index 0000000..48a586a --- /dev/null +++ b/scripts/generate-specification.py @@ -0,0 +1,640 @@ +#!/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( + "
{_esc(s['id'])}{_esc(p['hardware'])}
+{_esc(p['default_model'])}{_esc(k)} — {_esc(v)}{_esc(s)}{_esc(SPEC['architecture']['summary'])}
+ + + + +http://{_esc(host)}/ (Caddy landing){_esc(m['primary_host']['user'])} · OS: {_esc(m['primary_host']['os'])}{_esc(m['demo_credentials']['username'])} / {_esc(m['demo_credentials']['password'])}Apply with ./scripts/apply-profile.sh mini|mid|gaming
All services deploy by default (docker compose up -d). Replace HOST with {_esc(host)}. Links use plain http:// (no TLS).
| ID | Name | Category | Port | URL | Purpose |
|---|
Edit secrets/.env first, then:
{_esc(s)}{_esc(s)}{_esc(SPEC['ansible']['entry'])}{_esc(r)}' for r in SPEC['ansible']['roles'])}{_esc(x)}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.
+ + + + +http://192.168.8.123/ (Caddy landing)tim · OS: Ubuntu Server 24.04tim / asdfasdfApply with ./scripts/apply-profile.sh mini|mid|gaming
Beelink / SER5-class mini PC
+Ternary-Bonsai-27B-Q2_0.gguf~8GB AMD discrete GPU, ~16GB system RAM
+Qwen3.5-9B-abliterated-v2-MAX.Q4_K_M.gguf~16GB AMD GPU, ~64GB system RAM
+Ternary-Bonsai-27B-Q2_0.ggufAll services deploy by default (docker compose up -d). Replace HOST with 192.168.8.123. Links use plain http:// (no TLS).
| ID | Name | Category | Port | URL | Purpose |
|---|---|---|---|---|---|
landing | Landing (Caddy) | gateway | 80 | http://192.168.8.123/ | Dark-mode LAN resource map |
gluetun | gluetun | vpn | — | — | WireGuard VPN + killswitch |
qbittorrent | qBittorrent | download | 8080 | http://192.168.8.123:8080/ | Torrent client (VPN-only via gluetun) |
sabnzbd | SABnzbd | download | 8085 | http://192.168.8.123:8085/ | Usenet NZB downloader |
prowlarr | Prowlarr | download | 9696 | http://192.168.8.123:9696/ | Indexer manager (torrents + NZB) |
flaresolverr | FlareSolverr | download | 8191 | http://flaresolverr:8191 (internal) | Cloudflare JS challenge proxy for Prowlarr |
sonarr | Sonarr | media | 8989 | http://192.168.8.123:8989/ | TV automation |
radarr | Radarr | media | 7878 | http://192.168.8.123:7878/ | Movie automation |
lidarr | Lidarr | media | 8686 | http://192.168.8.123:8686/ | Music automation |
bazarr | Bazarr | media | 6767 | http://192.168.8.123:6767/ | Subtitles |
jellyfin | Jellyfin | media | 8096 | http://192.168.8.123:8096/ | Media server / playback |
bonsai | Bonsai (llama-server) | ai | 8081 | http://192.168.8.123:8081/v1 | Local LLM OpenAI-compatible API (Prism build) |
open-webui | Open WebUI | ai | 3000 | http://192.168.8.123:3000/ | Chat UI + RAG + tools |
searxng | SearXNG | ai | 8888 | http://127.0.0.1:8888/ (localhost only) | Private search for Open WebUI RAG |
openhands | OpenHands | ai | 3001 | http://192.168.8.123:3001/ | AI coding agent (always deployed) |
forgejo | Forgejo | code | 3002 | http://192.168.8.123:3002/ | Self-hosted Git |
cloudflare-ddns | Cloudflare DDNS | dns | — | — | Update public IPv4 A records for domain + subdomains |
generate-specification.py — Regenerate specification.json + specification.html (this file)apply-profile.sh — Merge profiles/{mini,mid,gaming}.env into .envbootstrap-stack.sh — Fresh bring-up: dirs, models, compose up all, heal gluetun, usenet, cloudflaredownload-models.sh — Download Bonsai 27B and/or Qwen 9B GGUF into models/bonsaidownload-bonsai-model.sh — Wrapper → download-models.shconfigure-usenet.sh — SABnzbd folders + Newshosting/Tweaknews from secrets/.envrender-cloudflare-ddns-env.sh — Build config/cloudflare-ddns/ddns.env from secretsgluetun-endpoint-watch.sh — If gluetun unhealthy: dig WG host, update Endpoint, recreate gluetun+qbitEdit secrets/.env first, then:
./scripts/render-cloudflare-ddns-env.shdocker compose up -d cloudflare-ddns --force-recreate./scripts/configure-usenet.shTest servers in SABnzbd UIUpdate API key in Prowlarr UIEdit wireguard/wg0.confdocker compose up -d gluetun --force-recreateor ./scripts/gluetun-endpoint-watch.shEdit .envdocker compose up -d (+ build bonsai if backend changes)cp .env.example .env; fill STACK_HOST, LAN_SUBNET, usenet, cloudflare keysPlace wireguard/wg0.conf (Endpoint IP + # EndpointHost = hostname)./scripts/apply-profile.sh mini|mid|gaming./scripts/bootstrap-stack.shOne-time UI: Prowlarr apps, Jellyfin libraries, Forgejo/Open WebUI admin (demo user/pass)cd /opt/stack && git pulldocker compose up -drebuild bonsai if Dockerfile/profile backend changedansible/site.ymlcommon, docker, amd_vulkan, firewall, stack, users.envsecrets/.envwireguard/wg0.confconfig/models/**/*.ggufdata/