From 914e251ebc0df191f39823712e8c1dcf4172b20c Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 28 Aug 2026 03:09:19 -0700 Subject: [PATCH] authentik: boot-persistent quadlet conversion with deploy handler The compose-era authentik trio died at every boot (rootless podman does not auto-start bare containers). Convert the trio to Quadlet units installed by deploy-remote.sh into ~/.config/containers/systemd/, enabled + WantedBy= default.target: - 3 unit files under dankclaw/authentik/quadlet/ (postgres -> server -> worker ordering via Wants=/After=; secrets stay in the git-ignored host .env, expanded by systemd at start time exactly like the gitea units) - deploy_authentik() handler: install units, daemon-reload, retire stale compose-era containers only while units are inactive, enable once, then start in dependency order with podman-health gates between stages - compose.yaml: authentik service definitions retired to a comment pointing at the quadlet dir (same pattern as searxng/open-webui/gitea/caddy) - tests/authentik.bats rewritten from compose assertions to unit-file contract assertions (images, Exec roles, ordering, loopback-only publish, pg_isready probe, secret hygiene, dispatch routing) --- .../quadlet/authentik-postgres.container | 37 +++++++ .../quadlet/authentik-server.container | 46 +++++++++ .../quadlet/authentik-worker.container | 37 +++++++ dankclaw/compose.yaml | 69 ++----------- scripts/deploy-remote.sh | 46 ++++++++- tests/authentik.bats | 96 ++++++++----------- 6 files changed, 211 insertions(+), 120 deletions(-) create mode 100644 dankclaw/authentik/quadlet/authentik-postgres.container create mode 100644 dankclaw/authentik/quadlet/authentik-server.container create mode 100644 dankclaw/authentik/quadlet/authentik-worker.container diff --git a/dankclaw/authentik/quadlet/authentik-postgres.container b/dankclaw/authentik/quadlet/authentik-postgres.container new file mode 100644 index 0000000..800f86b --- /dev/null +++ b/dankclaw/authentik/quadlet/authentik-postgres.container @@ -0,0 +1,37 @@ +# Quadlet unit: Authentik PostgreSQL 16 (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/authentik-postgres.container +# Unit name: authentik-postgres.service (podman 5.x drops the container- prefix) +# +# Replaces the compose service retired when this stack went boot-persistent +# (rootless Podman does not auto-start bare containers at boot). Secrets stay +# out of this file: systemd expands ${AUTHENTIK_PG_PASS} from the git-ignored +# host .env at start time (same pattern as gitea-postgres). + +[Unit] +Description=Authentik PostgreSQL container +After=network.target + +[Container] +ContainerName=authentik-postgres +Image=docker.io/library/postgres:16-alpine +Network=dankclaw_default +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Environment=POSTGRES_DB=authentik +Environment=POSTGRES_USER=authentik +Environment=POSTGRES_PASSWORD=${AUTHENTIK_PG_PASS} +Volume=/home/dank/authentik/postgres:/var/lib/postgresql/data +HealthCmd=/bin/sh -c "pg_isready -d authentik -U authentik" +HealthInterval=30s +HealthTimeout=10s +HealthRetries=5 +HealthStartPeriod=20s + +[Service] +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +TimeoutStartSec=900 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dankclaw/authentik/quadlet/authentik-server.container b/dankclaw/authentik/quadlet/authentik-server.container new file mode 100644 index 0000000..60c945d --- /dev/null +++ b/dankclaw/authentik/quadlet/authentik-server.container @@ -0,0 +1,46 @@ +# Quadlet unit: Authentik core + embedded Caddy outpost (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/authentik-server.container +# Unit name: authentik-server.service (podman 5.x drops the container- prefix) +# +# The embedded outpost serves /outpost.goauthentik.io/* including the +# /auth/caddy forward_auth endpoint the Caddyfile chains to — no outpost token +# exists for this deployment by design. Resolved by name (A2A DNS) from every +# container on dankclaw_default, including caddy. The 9000 publish is +# loopback-only on purpose: LAN clients reach Authentik through Caddy, never +# around it; the loopback publish exists so host-side tooling can probe +# /-/health/. Runs as the image default user (same userns mapping the compose +# era used, so the existing /data ownership carries over unchanged). + +[Unit] +Description=Authentik server container +After=network.target authentik-postgres.service +Wants=authentik-postgres.service + +[Container] +ContainerName=authentik-server +Image=ghcr.io/goauthentik/server:2026.8.0 +Exec=server +Network=dankclaw_default +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Environment=AUTHENTIK_POSTGRESQL__HOST=authentik-postgres +Environment=AUTHENTIK_POSTGRESQL__NAME=authentik +Environment=AUTHENTIK_POSTGRESQL__USER=authentik +Environment=AUTHENTIK_POSTGRESQL__PASSWORD=${AUTHENTIK_PG_PASS} +Environment=AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY} +PublishPort=127.0.0.1:9000:9000 +HealthCmd=python -c "import urllib.request;urllib.request.urlopen('http://localhost:9000/-/health/')" +--shm-size=512m +HealthInterval=30s +HealthTimeout=10s +HealthRetries=5 +HealthStartPeriod=30s + +[Service] +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +TimeoutStartSec=900 +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dankclaw/authentik/quadlet/authentik-worker.container b/dankclaw/authentik/quadlet/authentik-worker.container new file mode 100644 index 0000000..bc3033a --- /dev/null +++ b/dankclaw/authentik/quadlet/authentik-worker.container @@ -0,0 +1,37 @@ +# Quadlet unit: Authentik worker — background tasks, email, webhook/log outbox +# (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/authentik-worker.container +# Unit name: authentik-worker.service (podman 5.x drops the container- prefix) +# +# Same image as the server; the Exec= command selects the worker role. Runs as +# user 0:0 (rootless: maps to the invoking host user, matching the compose-era +# semantics). No published ports — the worker talks to PostgreSQL in-network +# and has no listener of its own. + +[Unit] +Description=Authentik worker container +After=network.target authentik-postgres.service authentik-server.service +Wants=authentik-server.service + +[Container] +ContainerName=authentik-worker +Image=ghcr.io/goauthentik/server:2026.8.0 +Exec=worker +Network=dankclaw_default +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Environment=AUTHENTIK_POSTGRESQL__HOST=authentik-postgres +Environment=AUTHENTIK_POSTGRESQL__NAME=authentik +Environment=AUTHENTIK_POSTGRESQL__USER=authentik +Environment=AUTHENTIK_POSTGRESQL__PASSWORD=${AUTHENTIK_PG_PASS} +Environment=AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY} +Volume=/home/dank/authentik/data:/data +User=0:0 + +[Service] +EnvironmentFile=/home/dank/serverAppConfigs/dankclaw/.env +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dankclaw/compose.yaml b/dankclaw/compose.yaml index 205e22c..77e19c5 100644 --- a/dankclaw/compose.yaml +++ b/dankclaw/compose.yaml @@ -246,69 +246,14 @@ services: retries: 3 # --- Authentik (SSO / OIDC provider) ------------------------------------- - # 3-service stack: PostgreSQL + server + worker. Redis was removed in - # 2025.10 (sessions/caching moved to PostgreSQL). The Caddyfile's - # forward_auth vhosts proxy to authentik-server:9000 (compose network). + # 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. - authentik-postgres: - image: docker.io/library/postgres:16-alpine - container_name: authentik-postgres - restart: unless-stopped - environment: - POSTGRES_DB: authentik - POSTGRES_USER: authentik - POSTGRES_PASSWORD: ${AUTHENTIK_PG_PASS} - volumes: - - /home/dank/authentik/postgres:/var/lib/postgresql/data - healthcheck: - test: ["CMD-SHELL", "pg_isready -d authentik -U authentik"] - interval: 30s - retries: 5 - start_period: 20s - - authentik-server: - image: ghcr.io/goauthentik/server:2026.8.0 - container_name: authentik-server - restart: unless-stopped - command: server - environment: - AUTHENTIK_POSTGRESQL__HOST: authentik-postgres - AUTHENTIK_POSTGRESQL__NAME: authentik - AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - ports: - - "9000:9000" - volumes: - - /home/dank/authentik/data:/data - shm_size: 512mb - depends_on: - authentik-postgres: - condition: service_healthy - healthcheck: - test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:9000/-/health/')"] - interval: 30s - timeout: 10s - retries: 5 - start_period: 30s - - authentik-worker: - image: ghcr.io/goauthentik/server:2026.8.0 - container_name: authentik-worker - restart: unless-stopped - command: worker - environment: - AUTHENTIK_POSTGRESQL__HOST: authentik-postgres - AUTHENTIK_POSTGRESQL__NAME: authentik - AUTHENTIK_POSTGRESQL__USER: authentik - AUTHENTIK_POSTGRESQL__PASSWORD: ${AUTHENTIK_PG_PASS} - AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY} - user: "0:0" - volumes: - - /home/dank/authentik/data:/data - depends_on: - authentik-postgres: - condition: service_healthy # Caddy (the homelab's single reverse proxy) left compose on 2026-08-26: # it is boot-persistent via its Quadlet unit (dankclaw/caddy/quadlet/ diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index a4ad49e..699df9d 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -359,7 +359,7 @@ deploy_compose_service() { return 0 fi case "$service" in - comfyui|hermes|hermes-dashboard|hermes-webui|prometheus|grafana|loki|alloy|node-exporter|authentik-postgres|authentik-server|authentik-worker) + comfyui|hermes|hermes-dashboard|hermes-webui|prometheus|grafana|loki|alloy|node-exporter) case "$service" in comfyui|loki|alloy) build_compose_image "$service" @@ -505,6 +505,47 @@ relink_unit() { run_privileged ln -sfn "$REMOTE_DIR/scripts/$service.service" "/etc/systemd/system/$service.service" } +# Authentik runs as three boot-persistent Quadlet units (postgres -> server -> +# worker). The trio deploys as one unit: unit files are installed, stale +# compose-era containers holding the claimed names are retired, then the chain +# starts in dependency order with health gates between stages. Re-running the +# handler on an already-active stack is a deliberate no-op (no DB bounce). +wait_container_healthy() { + local name="$1" limit="$2" status="unknown" + for ((i = 1; i <= limit; i += 5)); do + status="$(podman inspect -f '{{.State.Health.Status}}' "$name" 2>/dev/null || echo missing)" + [[ "$status" == "healthy" ]] && { + echo "$name healthy after approximately ${i}s" + return 0 + } + sleep 5 + done + echo "FAIL: $name did not become healthy in ${limit}s (status: $status)" >&2 + return 1 +} + +deploy_authentik() { + local unit + mkdir -p "$quadlet_unit_dir" + for unit in authentik-postgres authentik-server authentik-worker; do + cp "dankclaw/authentik/quadlet/$unit.container" "$quadlet_unit_dir/" + done + systemctl --user daemon-reload + # Retire compose-era containers only while the units are not yet active; + # unit-managed containers self-heal via Restart=always. + if ! systemctl --user is-active --quiet authentik-postgres.service; then + podman stop authentik-postgres authentik-server authentik-worker 2>/dev/null || true + podman rm authentik-postgres authentik-server authentik-worker 2>/dev/null || true + fi + systemctl --user enable authentik-postgres.service authentik-server.service authentik-worker.service + systemctl --user start authentik-postgres.service + wait_container_healthy authentik-postgres 120 || return 1 + systemctl --user start authentik-server.service + wait_container_healthy authentik-server 300 || return 1 + systemctl --user start authentik-worker.service +} + + deploy_llm_large() { relink_unit frank-default-large # NPU is intentionally not restarted here. It is a separate, retired @@ -555,6 +596,9 @@ deploy_service() { pihole) deploy_pihole ;; gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;; gitea-server) deploy_gitea_unit gitea-server gitea-server ;; + authentik-postgres|authentik-server|authentik-worker) + deploy_authentik + ;; frank-default-large) deploy_llm_large ;; frank-default-small) deploy_llm_small ;; frank-default-npu) deploy_llm_npu ;; diff --git a/tests/authentik.bats b/tests/authentik.bats index 9b22a23..e72e97a 100644 --- a/tests/authentik.bats +++ b/tests/authentik.bats @@ -19,86 +19,68 @@ COMPOSE="$REPO_ROOT/dankclaw/compose.yaml" CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile" ENV_EXAMPLE="$REPO_ROOT/dankclaw/.env.example" -# --- Compose: the 3-service stack --- +# --- Quadlet units: the 3-service stack ------------------------------------ +# The trio left compose: they are boot-persistent Quadlet units installed to +# ~/.config/containers/systemd/ by deploy-remote.sh (deploy_authentik). -@test "compose defines authentik-postgres" { - grep -qE '^ authentik-postgres:' "$COMPOSE" -} +UNIT_DIR="$REPO_ROOT/dankclaw/authentik/quadlet" -@test "compose defines authentik-server" { - grep -qE '^ authentik-server:' "$COMPOSE" -} - -@test "compose defines authentik-worker" { - grep -qE '^ authentik-worker:' "$COMPOSE" +@test "the three authentik quadlet units exist" { + for unit in authentik-postgres authentik-server authentik-worker; do + [ -f "$UNIT_DIR/$unit.container" ] + done } @test "authentik-postgres uses the official postgres image" { - grep -q 'image: docker.io/library/postgres:' "$COMPOSE" + grep -q 'Image=docker.io/library/postgres:16-alpine' "$UNIT_DIR/authentik-postgres.container" } -@test "authentik-server uses the official goauthentik image" { - grep -q 'image: ghcr.io/goauthentik/server:' "$COMPOSE" +@test "server and worker use the official goauthentik image" { + grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-server.container" + grep -q 'Image=ghcr.io/goauthentik/server:' "$UNIT_DIR/authentik-worker.container" } -@test "authentik-server runs the 'server' command" { - # The same image runs both server and worker; the command selects the role. - grep -qE '^\s+command:\s*server' "$COMPOSE" +@test "the same image runs both roles; the Exec command selects the role" { + grep -qE '^Exec=server$' "$UNIT_DIR/authentik-server.container" + grep -qE '^Exec=worker$' "$UNIT_DIR/authentik-worker.container" } -@test "authentik-worker runs the 'worker' command" { - grep -qE '^\s+command:\s*worker' "$COMPOSE" +@test "startup order is postgres -> server -> worker via unit ordering" { + grep -q 'After=network.target authentik-postgres.service' "$UNIT_DIR/authentik-server.container" + grep -q 'Wants=authentik-postgres.service' "$UNIT_DIR/authentik-server.container" + grep -q 'Wants=authentik-server.service' "$UNIT_DIR/authentik-worker.container" } -@test "authentik-server depends on a healthy authentik-postgres" { - local block - block=$(awk -v s="^ authentik-server:\$" ' - $0 ~ s { inblk = 1; next } - inblk && /^ [a-z]/ { inblk = 0 } - inblk' "$COMPOSE") - echo "$block" | grep -q 'condition: service_healthy' -} - -@test "authentik-worker depends on a healthy authentik-postgres" { - local block - block=$(awk -v s="^ authentik-worker:\$" ' - $0 ~ s { inblk = 1; next } - inblk && /^ [a-z]/ { inblk = 0 } - inblk' "$COMPOSE") - echo "$block" | grep -q 'condition: service_healthy' -} - -@test "authentik-server exposes port 9000" { - local block - block=$(awk -v s="^ authentik-server:\$" ' - $0 ~ s { inblk = 1; next } - inblk && /^ [a-z]/ { inblk = 0 } - inblk' "$COMPOSE") - echo "$block" | grep -qE '"9000:9000"' +@test "the server publishes 9000 on loopback only; LAN reaches it through Caddy" { + grep -q 'PublishPort=127.0.0.1:9000:9000' "$UNIT_DIR/authentik-server.container" } @test "authentik-postgres healthcheck uses pg_isready" { - local block - block=$(awk -v s="^ authentik-postgres:\$" ' - $0 ~ s { inblk = 1; next } - inblk && /^ [a-z]/ { inblk = 0 } - inblk' "$COMPOSE") - echo "$block" | grep -q 'pg_isready' + grep -q 'HealthCmd=.*pg_isready' "$UNIT_DIR/authentik-postgres.container" } -# --- Compose: environment (secrets from .env) --- +# --- Units: environment (secrets stay in the host .env, expanded at start) -- -@test "authentik services consume AUTHENTIK_PG_PASS from .env" { - grep -q 'AUTHENTIK_PG_PASS' "$COMPOSE" +@test "the units consume AUTHENTIK_PG_PASS via systemd expansion, not literals" { + grep -q 'POSTGRES_PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-postgres.container" + grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-server.container" + grep -q 'PASSWORD=${AUTHENTIK_PG_PASS}' "$UNIT_DIR/authentik-worker.container" } -@test "authentik services consume AUTHENTIK_SECRET_KEY from .env" { - grep -q 'AUTHENTIK_SECRET_KEY' "$COMPOSE" +@test "the units consume AUTHENTIK_SECRET_KEY via systemd expansion, not literals" { + for unit in authentik-server authentik-worker; do + grep -q 'AUTHENTIK_SECRET_KEY=${AUTHENTIK_SECRET_KEY}' "$UNIT_DIR/$unit.container" + done } -@test "no real authentik secret is committed in compose" { - # The compose must reference the secret via ${VAR}, never a literal. - ! grep -qE 'AUTHENTIK_SECRET_KEY:\s*["\x27]?\w{16,}' "$COMPOSE" +@test "no secret material is committed in the quadlet units" { + ! grep -rqE 'SECRET_KEY=[^$]' "$UNIT_DIR" +} + +@test "deploy-remote.sh routes the authentik trio to the quadlet handler, not compose up" { + grep -q 'authentik-postgres|authentik-server|authentik-worker)' "$REPO_ROOT/scripts/deploy-remote.sh" + grep -q 'deploy_authentik()' "$REPO_ROOT/scripts/deploy-remote.sh" + ! grep -qE '^[[:space:]]+comfyui\|.*authentik' "$REPO_ROOT/scripts/deploy-remote.sh" } # --- Caddyfile: the auth vhost ---