diff --git a/dankclaw/compose.yaml b/dankclaw/compose.yaml deleted file mode 100644 index 7d0e4b1..0000000 --- a/dankclaw/compose.yaml +++ /dev/null @@ -1,147 +0,0 @@ -# Dankclaw stack — AI, search, development & observability services. -# Deploy: cd /home/dank/serverAppConfigs && podman-compose -f dankclaw/compose.yaml up -d -# See ../docs/Dankclaw/ and ../docs/Infrastructure/ for service documentation. - -services: - # --- SearXNG stack (gluetun + searxng + valkey) -------------------------- - # These three services are now boot-persistent Quadlet units, not compose - # services. They live in searxng/quadlet/ and install to - # ~/.config/containers/systemd/ on the host. searxng reuses gluetun's netns - # (Network=gluetun.container) so its traffic exits the WireGuard tunnel. - # Deploy with: scripts/deploy-searxng-quadlet.sh dankclaw - # See ../docs/Dankclaw/SearXNG.md, Gluetun.md, Valkey.md. - - # --- AI image generation (ROCm GPU backend, gfx1151) --------------------- - # ComfyUI is a boot-persistent Quadlet unit since 2026-09-16: - # comfyui/quadlet/comfyui.container, installed by deploy-remote.sh. It left - # compose after the container it had started went stale — created before the - # GPU declarations existed, then restarted by `restart: unless-stopped` - # without /dev/kfd or /dev/dri until it had failed 9,146 times on - # "No HIP GPUs are available". Built from Containerfile.comfyui (AMD's - # gfx1151 base), models and data under /home/dank/comfyui-data. - # See ../docs/Dankclaw/ComfyUI.md. - - # --- Chat UI (open-webui) ------------------------------------------------ - # Now a boot-persistent Quadlet unit (quadlet, slirp4netns, port 3005), not - # a compose service. See open-webui/quadlet/open-webui.container and - # ../docs/Dankclaw/OpenWebUI.md. - - hermes: - image: docker.io/nousresearch/hermes-agent:latest - container_name: hermes - network_mode: host - restart: unless-stopped - # The image's default s6 entrypoint runs the interactive TUI, which exits - # without a TTY and crash-loops the container. Override it to run the - # headless gateway (the documented Docker mode). The gateway drops to the - # unprivileged hermes user (uid 10000), so the data dir must be writable by it. - entrypoint: ["bash", "-c", "hermes gateway run --force -v"] - environment: - HERMES_DATA_DIR: /data - # Point hermes's config/.env at the mounted data dir (persistent). - HERMES_HOME: /data - # Signal adapter -> the signal-cli daemon, a HOST process listening on - # :8080. The service runs with network_mode: host below, so it dials - # loopback directly; from a bridge network only DNAT'd published ports - # accept connections to the host LAN IP, and un-forwarded ports refuse - # instantly (verified live 2026-08-30). - SIGNAL_HTTP_URL: "http://127.0.0.1:8080" - SIGNAL_ACCOUNT: "${SIGNAL_ACCOUNT}" - SIGNAL_ALLOWED_USERS: "${SIGNAL_ALLOWED_USERS}" - ports: - - "8090:8090" - volumes: - - /home/dank/hermes-data:/data - # No healthcheck: 'hermes gateway run' starts the messaging gateway (Signal - # SSE), not the :8090 web dashboard, so there is no HTTP endpoint to probe. - # The container's running state + the daemon's SSE connection are the signal. - - # --- Hermes web dashboard (management UI: config, API keys, sessions) ----- - # A separate process from the Signal gateway (the gateway entrypoint does not - # start the dashboard). Bound to the LAN with basic auth: the username + a - # scrypt password_hash live in the git-ignored /home/dank/hermes-data config. - # Port 9119 is published so a browser on the LAN can reach it. - hermes-dashboard: - image: docker.io/nousresearch/hermes-agent:latest - container_name: hermes-dashboard - restart: unless-stopped - entrypoint: ["bash", "-c", "hermes dashboard --host 0.0.0.0 --port 9119 --no-open --skip-build"] - environment: - HERMES_DATA_DIR: /data - HERMES_HOME: /data - ports: - - "9119:9119" - volumes: - - /home/dank/hermes-data:/data - - # --- Hermes web UI (Hermex server; phone-facing API for the agent) -------- - # Runs the open-source hermes-webui (github.com/nesquena/hermes-webui) in - # foreground mode: bootstrap.py creates .venv inside the mounted checkout, - # installs WebUI + agent deps, then execs server.py as a single foreground - # process. It reads HERMES_HOME=/data, so it drives the SAME agent state - # (memory, skills, sessions) as the gateway container. - # Loopback-only publish: the SSH tunnel (phone -> Dankclaw:22) is the sole - # ingress. The password gates Hermex logins and lives in the host .env. - hermes-webui: - image: docker.io/nousresearch/hermes-agent:latest - container_name: hermes-webui - restart: unless-stopped - entrypoint: ["bash", "-c", "python3 /webui/bootstrap.py --foreground"] - environment: - HERMES_HOME: /data - # Container-internal bind: 0.0.0.0 so Podman's port forwarder (which - # dials the container's bridge IP) can reach it. The *host-side* - # publish below stays on 127.0.0.1, so loopback-only ingress holds. - HERMES_WEBUI_HOST: 0.0.0.0 - HERMES_WEBUI_PORT: "8787" - HERMES_WEBUI_PASSWORD: "${HERMES_WEBUI_PASSWORD}" - # Headless host: never show the first-run wizard (a profile already - # exists in /data from the gateway). - HERMES_WEBUI_SKIP_ONBOARDING: "1" - ports: - - "127.0.0.1:8787:8787" - volumes: - - /home/dank/hermes-data:/data - # rw on purpose: bootstrap.py creates /webui/.venv in the repo root. - - /home/dank/hermes-webui:/webui - healthcheck: - test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8787/health')"] - interval: 30s - timeout: 10s - retries: 5 - # First boot pip-installs WebUI + agent deps into a fresh venv. - start_period: 600s - - # --- Git hosting (Gitea) ------------------------------------------------- - # Now boot-persistent Quadlet units (private bridge network, DNS enabled), - # not compose services. See gitea/quadlet/ and ../docs/Dankclaw/Gitea.md. - - # --- Observability ------------------------------------------------------- - # Now boot-persistent Quadlet units, not compose services: prometheus, - # grafana, loki, alloy and node-exporter install from - # dankclaw/observability/quadlet/ and are dispatched by deploy_observability - # in scripts/deploy-remote.sh. They left compose on 2026-09-11, after a host - # reboot proved that compose-only containers do not come back on their own — - # the same gap that had already cost the fleet Caddy and Pi-hole. - # - # The three HTTP UIs publish on 127.0.0.1 only. Off-host access is through - # the guarded franksdank.cloud vhosts (Caddy reaches these containers by - # name over this network) and the host's own homepage status timer probes - # them over loopback. See - # serverAppConfigs-docs/Infrastructure/Observability.md. - - # --- Authentik (SSO / OIDC provider) ------------------------------------- - # Now boot-persistent Quadlet units, not compose services: authentik- - # postgres -> authentik-server -> authentik-worker start in dependency order - # via ~/.config/containers/systemd/*.container on the host (repo source: - # dankclaw/authentik/quadlet/; deploy-remote.sh deploy_authentik installs - # and starts them). Redis was removed in 2025.10 (sessions/caching moved to - # PostgreSQL). The Caddyfile's forward_auth vhosts proxy to - # authentik-server:9000 over the dankclaw_default network (A2A DNS). - # See ../docs/Infrastructure/Authentik.md. - - # Caddy (the homelab's single reverse proxy) left compose on 2026-08-26: - # it is boot-persistent via its Quadlet unit (dankclaw/caddy/quadlet/ - # caddy.container), installed by deploy_caddy in scripts/deploy-remote.sh. - # The rendered Caddyfile, certs, and config still live under - # /home/dank/Containers/caddy (rootless-writable). diff --git a/dankclaw/hermes/quadlet/hermes-webui.container b/dankclaw/hermes/quadlet/hermes-webui.container index bc9a010..b69c27b 100644 --- a/dankclaw/hermes/quadlet/hermes-webui.container +++ b/dankclaw/hermes/quadlet/hermes-webui.container @@ -14,9 +14,13 @@ # daemon binds loopback-only in the host network namespace (see # chrome-devtools-protocol.container). Only processes sharing that namespace # can drive the browser; a bridge-network client could not reach it at all. -# The server therefore listens on 0.0.0.0:8787 of the host netns (LAN-facing as -# before); access control is the password gate, and the SSH tunnel -# (phone -> Dankclaw:22) remains the sole off-network ingress path. +# The server binds LOOPBACK (HERMES_WEBUI_HOST=127.0.0.1). Host networking +# means there is no Podman port forwarder, so the bind IS the exposure: the +# compose service this replaced published 127.0.0.1:8787:8787, and the first +# version of this unit bound 0.0.0.0 instead, which put the phone-facing API on +# the whole LAN with only the password in front of it (measured 2026-09-16: +# 192.168.0.143:8787 answered 200). The SSH tunnel (phone -> Dankclaw:22) dials +# localhost:8787 from the host, so loopback serves it exactly as well. [Unit] Description=Hermes webui (Hermex) container @@ -27,7 +31,7 @@ Image=localhost/hermes-agent:latest Pull=never Network=host EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env -Environment=HERMES_WEBUI_HOST=0.0.0.0 +Environment=HERMES_WEBUI_HOST=127.0.0.1 Environment=HERMES_WEBUI_PORT=8787 Environment=HERMES_WEBUI_PASSWORD=${HERMES_WEBUI_PASSWORD} Environment=HERMES_HOME=/data diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 918acce..1ef1948 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -125,21 +125,12 @@ FRANKBOOK_SERVICES = { "readarr", } -DANKCLAW_COMPOSE_SERVICES = { - "comfyui", - "hermes", - "hermes-dashboard", - "hermes-webui", - "prometheus", - "grafana", - "loki", - "alloy", - "node-exporter", - "authentik-postgres", - "authentik-server", - "authentik-worker", - "caddy", -} +DANKCLAW_COMPOSE_SERVICES = set() +# Dankserver's compose file is still the source of truth for its media stack; +# Dankclaw's is gone (deleted 2026-09-16). Every service on Dankclaw is a +# Quadlet unit now — the last compose services were the hermes trio, whose units +# have owned their containers since 2026-09-12 and whose compose blocks were +# dead weight that could only create a duplicate container. DANKSERVER_COMPOSE_SERVICES = { "mariadb", @@ -198,8 +189,14 @@ def _yaml_services(contents: str | None) -> dict[str, Any]: def compose_changes(repo: str, target: str, base: str, head: str) -> set[str]: - """Return only compose services whose YAML definitions or dependencies changed.""" + """Return only compose services whose YAML definitions or dependencies changed. + + A target with no compose file (Dankclaw since 2026-09-16) plans nothing: + its services are Quadlet units, selected by their own paths. + """ path = f"{target}/compose.yaml" + if not _git_show(repo, head, path) and not _git_show(repo, base, path): + return set() old_doc = _yaml_document(_git_show(repo, base, path)) new_doc = _yaml_document(_git_show(repo, head, path)) old = old_doc.get("services", {}) if isinstance(old_doc.get("services", {}), dict) else {} @@ -264,9 +261,9 @@ def services_for_path(path: str, target: str) -> set[str]: if parts[0] == "dankclaw": if len(parts) >= 2 and parts[1] == "compose.yaml": - # Revision-aware callers replace this broad fallback with the - # service-level YAML comparison in compose_changes(). - _add(services, *DANKCLAW_COMPOSE_SERVICES) + # Dankclaw has no compose file since 2026-09-16: every service is a + # Quadlet unit, each selected by its own path. A change (or a + # deletion) of this path therefore restarts nothing. return services if len(parts) >= 2 and parts[1] == "Caddyfile": _add(services, "caddy") diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index b232396..c47d7dd 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -382,10 +382,15 @@ deploy_compose_service() { echo "WARN: $service is recorded in dankserver/compose.yaml; production container management is host-specific and no restart was attempted." return 0 fi + if [[ "$TARGET" == dankclaw ]]; then + # Dankclaw has no compose file since 2026-09-16: every service is a + # Quadlet unit with its own handler above. Reaching this means the + # dispatcher is missing a case, and the old fallback (`podman-compose + # up -d`) would now be a silent no-op or a duplicate container. + echo "no compose services on dankclaw; $service has no quadlet handler" >&2 + return 2 + fi case "$service" in - hermes|hermes-dashboard|hermes-webui) - podman-compose -f dankclaw/compose.yaml up -d --no-deps "$service" - ;; *) echo "unknown compose service: $service" >&2 return 2 diff --git a/tests/hermes_webui.bats b/tests/hermes_webui.bats index 263171d..fd18fe3 100644 --- a/tests/hermes_webui.bats +++ b/tests/hermes_webui.bats @@ -2,59 +2,69 @@ # Seam: hermes-webui (the Hermex server) deployment integrity. # The webui is the phone-facing API for the Hermes agent. It must stay # loopback-bound so the SSH tunnel is its only ingress, keep its password in -# the host .env, share the gateway data volume, and deploy as an isolated -# compose service that cannot drag the gateway or inference along with it. +# the host .env, and share the gateway data volume. +# +# It reads the QUADLET UNIT, which is the live source of truth. It used to read +# dankclaw/compose.yaml, and that is exactly how the bind drifted: the compose +# service published 127.0.0.1:8787:8787 (loopback), the unit that replaced it +# bound 0.0.0.0 with only the password in front, and the test kept passing +# because it was checking the file nobody ran any more. load helpers -COMPOSE="$REPO_ROOT/dankclaw/compose.yaml" +UNIT="$REPO_ROOT/dankclaw/hermes/quadlet/hermes-webui.container" ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example" PLANNER="$REPO_ROOT/scripts/deploy-plan.py" DEPLOY="$REPO_ROOT/scripts/deploy.sh" REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" webui_service_yaml() { - python3 - "$COMPOSE" <<'PYEOF' -import sys, yaml -with open(sys.argv[1]) as fh: - data = yaml.safe_load(fh) -svc = (data.get("services") or {}).get("hermes-webui") -assert svc, "dankclaw/compose.yaml: no hermes-webui service" -print(yaml.safe_dump(svc)) -PYEOF + # Normalize the unit's key=value lines into the same shape the assertions + # below always used, so the contract reads the same as it did for compose. + [ -f "$UNIT" ] || { echo "missing $UNIT" >&2; return 1; } + grep -E '^(Image|Exec|Environment|Volume|HealthCmd|PublishPort)=' "$UNIT" } @test "hermes-webui runs the webui bootstrap in foreground mode" { local y y=$(webui_service_yaml) - grep -q 'nousresearch/hermes-agent' <<< "$y" + # The unit runs the localhost-aliased tag (this host's quadlet path cannot + # resolve registry-prefixed refs from local storage); the deployer tags it + # from the upstream name, which is what keeps the alias fresh. + grep -q 'Image=localhost/hermes-agent:latest' <<< "$y" + grep -q 'podman tag docker.io/nousresearch/hermes-agent:latest localhost/hermes-agent:latest' \ + "$REPO_ROOT/scripts/deploy-remote.sh" grep -q 'bootstrap.py' <<< "$y" grep -q -- '--foreground' <<< "$y" } -@test "hermes-webui is loopback-bound and password-gated" { +@test "hermes-webui binds loopback, not the LAN" { local y y=$(webui_service_yaml) - # Host-side publish stays on loopback: the SSH tunnel is the only ingress. - grep -q '127.0.0.1:8787' <<< "$y" - grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" - # Container-internal bind must be 0.0.0.0 or Podman's port forwarder - # (which dials the bridge IP) resets every connection. - grep -q 'HERMES_WEBUI_HOST: 0.0.0.0' <<< "$y" + grep -q 'HERMES_WEBUI_PASSWORD' <<< "$y" \ + || { echo "password gate missing"; return 1; } + # With Network=host there is no port forwarder: the app's own bind IS the + # exposure. The compose service this replaced published 127.0.0.1:8787:8787, + # and an early version of the unit bound 0.0.0.0 instead — which answered + # 200 on 192.168.0.143:8787 for anyone on the LAN (measured 2026-09-16). + grep -q 'Environment=HERMES_WEBUI_HOST=127.0.0.1' <<< "$y" \ + || { echo "webui must bind loopback (HERMES_WEBUI_HOST=127.0.0.1)"; return 1; } + ! grep -q 'HERMES_WEBUI_HOST=0.0.0.0' <<< "$y" \ + || { echo "webui must not bind 0.0.0.0 on a host network"; return 1; } } @test "hermes-webui shares the gateway data volume and mounts the webui checkout" { local y y=$(webui_service_yaml) - grep -q 'HERMES_HOME: /data' <<< "$y" - grep -q '/home/dank/hermes-data:/data' <<< "$y" - grep -q '/home/dank/hermes-webui:/webui' <<< "$y" + grep -q 'Environment=HERMES_HOME=/data' <<< "$y" + grep -q 'Volume=/home/dank/Containers/hermes/data:/data' <<< "$y" + grep -q 'Volume=/home/dank/Containers/hermes/webui:/webui' <<< "$y" } @test "hermes-webui has a healthcheck that probes /health" { local y y=$(webui_service_yaml) - grep -q 'healthcheck' <<< "$y" + grep -q 'HealthCmd=' <<< "$y" grep -q '/health' <<< "$y" } @@ -62,43 +72,29 @@ PYEOF env_vars_defined "$ENV_EXAMPLE" | grep -qx 'HERMES_WEBUI_PASSWORD' } -# Revision-aware planner: a change to only the hermes-webui service block must -# plan hermes-webui and nothing else (the gateway is unchanged). -webui_fixture_repo() { - local dir="$1" - git init -q "$dir" - git -C "$dir" config user.email t@t && git -C "$dir" config user.name t - mkdir -p "$dir/dankclaw" - cat > "$dir/dankclaw/compose.yaml" <<'EOF' -services: - hermes: - image: docker.io/nousresearch/hermes-agent:latest - hermes-webui: - image: docker.io/nousresearch/hermes-agent:latest - ports: - - "127.0.0.1:8787:8787" -EOF - git -C "$dir" add -A && git -C "$dir" commit -qm base - # Touch only the webui service block (its port line is unique in the file). - sed -i 's|127.0.0.1:8787:8787|127.0.0.1:8788:8787|' "$dir/dankclaw/compose.yaml" - git -C "$dir" commit -qam head -} - -@test "planner narrows a hermes-webui compose edit to hermes-webui only" { - local dir base head - dir=$(mktemp -d) - webui_fixture_repo "$dir" - base=$(git -C "$dir" rev-parse HEAD~1) - head=$(git -C "$dir" rev-parse HEAD) - run python3 "$PLANNER" --target dankclaw --repo "$dir" \ - --base "$base" --head "$head" --paths dankclaw/compose.yaml +@test "planner maps a hermes-webui unit edit to the hermes service" { + # Any file under dankclaw/hermes/** selects the `hermes` service, because + # the trio (gateway, dashboard, webui) deploys as one unit-set — see + # deploy_hermes, which installs all three units and restarts them together. + # The three names are aliases of that handler, not separate deployments. + # (This test used to assert that a *compose* edit narrowed to hermes-webui; + # Dankclaw has had no compose file since 2026-09-16.) + run python3 "$PLANNER" --target dankclaw \ + --paths dankclaw/hermes/quadlet/hermes-webui.container [ "$status" -eq 0 ] - [ "$output" = "hermes-webui" ] + [ "$output" = "hermes" ] + # And an unrelated service must not be dragged in. + ! grep -qE 'caddy|prometheus|frank-large' <<< "$output" } @test "remote deployer accepts and dispatches hermes-webui" { grep -q 'dankclaw:hermes-webui' "$REMOTE_DEPLOY" - awk '/^deploy_compose_service\(\)/,/^}/' "$REMOTE_DEPLOY" | grep -q 'hermes-webui' + # Dispatched to the unit handler, never to compose: deploy_hermes installs + # the quadlets and retires compose-era containers. + grep -q 'hermes|hermes-dashboard|hermes-webui) deploy_hermes' "$REMOTE_DEPLOY" + # deploy_hermes installs the trio with one loop over the unit names. + grep -q 'for unit in hermes hermes-dashboard hermes-webui' "$REMOTE_DEPLOY" + grep -q 'dankclaw/hermes/quadlet/$unit.container' "$REMOTE_DEPLOY" } @test "top-level deployer accepts hermes-webui as a Dankclaw service" {