#!/usr/bin/env bats # Seam: open-notebook, the fleet's podcast generator on Dankclaw — the Quadlet # pair (app + SurrealDB), the private network between them, the env file both # units read, the deployer routing, and the homepage card. Runbook: # serverAppConfigs-docs/Dankclaw/Open Notebook.md. # # Structure only: what the files declare and what the deployer can see. Nothing # here starts a container, opens a socket, or touches the network — the app's # behaviour inside the image is upstream's contract, cited in the docs page. load helpers UNIT_DIR="dankclaw/open-notebook/quadlet" APP="$REPO_ROOT/$UNIT_DIR/open-notebook.container" DB="$REPO_ROOT/$UNIT_DIR/open-notebook-surrealdb.container" NET="$REPO_ROOT/$UNIT_DIR/open-notebook.network" ENVF="$REPO_ROOT/scripts/open-notebook.env" PLANNER="$REPO_ROOT/scripts/deploy-plan.py" REMOTE="$REPO_ROOT/scripts/deploy-remote.sh" CADDY="$REPO_ROOT/dankclaw/Caddyfile" # The host-side path the units read (they run on Dankclaw, not here), spelled # out once so the ordering test below compares against a real constant. HOST_TUNABLES="EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env" HOST_SECRETS="EnvironmentFile=/home/dank/Containers/open-notebook/env" # The pins are part of the contract, so a silent re-resolution to a different # build has to fail here rather than at the next deploy. Both digests were # resolved from the registry on 2026-10-18; the SurrealDB one is 2.7.0. APP_PIN="sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212" DB_PIN="sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723" @test "open-notebook ships a network, a database unit and an app unit" { [ -f "$NET" ] [ -f "$DB" ] [ -f "$APP" ] } @test "both units are boot-persistent and self-healing" { for unit in "$APP" "$DB"; do grep -qE '^Restart=(always|on-failure)$' "$unit" || { echo "$(basename "$unit") has no Restart="; false; } # "short RestartSec": a podcast service has no reason to wait minutes. restart_sec="$(sed -n 's/^RestartSec=\([0-9]*\)s\?$/\1/p' "$unit")" [ -n "$restart_sec" ] || { echo "$(basename "$unit") has no RestartSec"; false; } [ "$restart_sec" -le 30 ] || { echo "$(basename "$unit") RestartSec=${restart_sec}s is not short"; false; } grep -qE '^\[Install\]' "$unit" grep -qE '^WantedBy=default\.target$' "$unit" done } @test "both images are digest-pinned and never re-resolved at start" { grep -qxF "Image=docker.io/lfnovo/open_notebook:v1-latest@$APP_PIN" "$APP" grep -qxF "Image=docker.io/surrealdb/surrealdb:v2@$DB_PIN" "$DB" grep -qxF 'Pull=never' "$APP" grep -qxF 'Pull=never' "$DB" # Pull=never is only honest if the fetch it depends on is written down where # the operator reads the unit, and wired into the deployer's precondition. grep -qF 'podman pull' "$APP" grep -qF 'podman pull' "$DB" grep -qF 'ensure_pinned_image' "$REMOTE" } @test "the database port is never published" { # 8000 belongs to the private bridge. A published 8000 would put the RPC, # root credentials included, on every interface of the host. ! grep -qE '^PublishPort=' "$DB" ! grep -qE '^PublishPort=.*:8000(:|$)' "$APP" } # onb_vhost — the host Caddy routes to the UI's 8502. Derived, never restated: # the API origin and the homepage link have to agree with whatever the Caddyfile # actually routes, so renaming the vhost there moves this seam instead of # leaving it blessing a stale name. onb_vhost() { awk ' /^[[:space:]]*@[A-Za-z0-9_-]+[[:space:]]+host[[:space:]]/ { h = $3 } /^[[:space:]]*reverse_proxy[[:space:]]+192\.168\.0\.143:8502$/ { if (h) { print h; exit } } ' "$CADDY" } @test "the app publishes the UI for the guard chain and keeps the API off the LAN" { # 8502 is the single upstream the post-auth listener dials, so it is the # only port that has to answer on a LAN interface. grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP" [ "$(grep -cE '^PublishPort=0\.0\.0\.0:' "$APP")" = "1" ] || { echo "the UI port is not the only wildcard publish: $(grep -E '^PublishPort=' "$APP")"; false; } # seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave # the container on the port upstream uses; and it needs no outward face at # all, because the image's Next.js layer rewrites /api/* to it. Loopback is # where the deployer's health gate dials it. grep -qxF 'PublishPort=127.0.0.1:5056:5055' "$APP" # One origin serves UI and API, so API_URL names the guarded vhost rather # than a host:port. Left to itself the frontend auto-detects `:5055`; # a LAN-origin API_URL takes the browser around the guard chain, where its # authenticated session does not ride. api_url="$(sed -n 's/^API_URL=//p' "$ENVF")" vhost="$(onb_vhost)" [ -n "$vhost" ] || { echo "no Caddy route to the UI port was found"; false; } [ "$api_url" = "https://${vhost}" ] || { echo "API_URL ($api_url) is not the guarded origin https://${vhost}"; false; } # Same-origin traffic needs no CORS at all, but while CORS_ORIGINS is unset # the app warns and defaults to `*` in front of every episode's audio. cors="$(sed -n 's/^CORS_ORIGINS=//p' "$ENVF")" [ "$cors" = "https://${vhost}" ] || { echo "CORS_ORIGINS ($cors) is not the single guarded origin https://${vhost}"; false; } } @test "the vhost is guarded and the app login stays behind it" { vhost="$(onb_vhost)" # The vhost itself proxies the WHOLE request to Anubis — an unmatched path # must never reach the app unchallenged. awk -v v="$vhost" ' $0 == v " {" { inblock = 1; seen = 0 } inblock && /reverse_proxy anubis:8923/ { seen = 1 } inblock && /^}/ { exit } END { exit !seen } ' "$CADDY" || { echo "$vhost does not proxy everything through Anubis"; false; } # And the app's own password is still required, so a route that ever bypasses # the chain does not hand out the episode library: the deployer refuses to # start the units while the secret file leaves it blank. grep -qF 'OPEN_NOTEBOOK_PASSWORD' "$ENVF" } @test "the pair shares one network and the app finds the database by container name" { grep -qxF 'Network=open-notebook.network' "$DB" grep -qxF 'Network=open-notebook.network' "$APP" [ "$(grep -cE '^Network=' "$DB")" = "1" ] # dankclaw_default is the app's second network and must come FIRST: the # generated --network flags keep file order, so the default route (and the # gateway name proven on that network) rides it. [ "$(grep -cE '^Network=' "$APP")" = "2" ] [ "$(grep -E '^Network=' "$APP" | head -1)" = "Network=dankclaw_default" ] db_name="$(sed -n 's/^ContainerName=//p' "$DB")" [ "$db_name" = "open-notebook-surrealdb" ] grep -qxF "SURREAL_URL=ws://${db_name}:8000/rpc" "$ENVF" } @test "each unit reads the repo tunables first and the host secrets second" { # podman applies --env-file in argument order and the LAST file wins, so # swapping these two lines would blank every credential the app needs. for unit in "$APP" "$DB"; do files="$(grep -E '^EnvironmentFile=' "$unit")" [ "$(echo "$files" | wc -l)" = "2" ] || { echo "$(basename "$unit") must read exactly two env files"; false; } [ "$(echo "$files" | head -1)" = "$HOST_TUNABLES" ] || { echo "$(basename "$unit") must read the tracked env file first"; false; } [ "$(echo "$files" | tail -1)" = "$HOST_SECRETS" ] || { echo "$(basename "$unit") must read the host secret file last"; false; } done } @test "the interim episode destination is mounted exactly once" { # The bind that answers Gitea issue #7 provisionally: two of them is a # shadowing bug, none is episode audio locked inside a named volume. [ "$(grep -cE '^Volume=/home/dank/media/podcasts:/app/data/podcasts$' "$APP")" = "1" ] # The app's own state stays in a named volume; the database gets its own. grep -qE '^Volume=open-notebook-data:/app/data$' "$APP" grep -qE '^Volume=open-notebook-db:/mydata$' "$DB" # The deployer creates the host directory as the deploy user. grep -qF 'mkdir -p /home/dank/media/podcasts' "$REMOTE" } @test "the env file declares the CPU-shutdown guard and both timeouts" { # Upstream defaults are 5 and 5; frank-tts spawns ~8 GiB / 8 threads per # request on a box that also serves two LLM endpoints. grep -qxF 'TTS_BATCH_SIZE=1' "$ENVF" grep -qxF 'OPEN_NOTEBOOK_WORKER_MAX_TASKS=1' "$ENVF" # Per dialogue line, and strictly beyond frank-tts's own per-request # ceiling: nested budgets go inside-out (the rule scripts/frank-tts.env # states for LLAMA_TIMEOUT), so a shim give-up surfaces as frank-tts's # error instead of the worker's own timeout. The seam reads the ceiling # rather than restating it, so re-measuring frank-tts cannot leave this # file silently behind. tts="$(sed -n 's/^ESPERANTO_TTS_TIMEOUT=//p' "$ENVF")" shim="$(sed -n 's/^SHIM_TIMEOUT=//p' "$REPO_ROOT/scripts/frank-tts.env")" [ -n "$tts" ] || { echo "no ESPERANTO_TTS_TIMEOUT in the env file"; false; } [ -n "$shim" ] || { echo "no SHIM_TIMEOUT in scripts/frank-tts.env"; false; } [ "$tts" -gt "$shim" ] || { echo "ESPERANTO_TTS_TIMEOUT=$tts is not beyond frank-tts SHIM_TIMEOUT=$shim"; false; } # Upstream: keep the scriptwriting call under the web UI's ~600 s patience, # or the failure arrives as a UI timeout with no name on it. llm="$(sed -n 's/^ESPERANTO_LLM_TIMEOUT=//p' "$ENVF")" [[ "$llm" =~ ^[0-9]+$ ]] && [ "$llm" -lt 600 ] || { echo "ESPERANTO_LLM_TIMEOUT=$llm is not a number under 600"; false; } } @test "secrets stay declared-but-empty in the tracked env file" { # scripts/*.env is un-ignored for "paths and numbers, never credentials": # the values belong to the host file the deployer seeds. for key in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do grep -qxF "${key}=" "$ENVF" || { echo "$key is not declared, empty, in $ENVF"; false; } done has_no_leaked_secret "$ENVF" has_no_leaked_sudo_pw "$ENVF" # And the deployer treats blank as fatal, because blank means "auth off", # not "unconfigured". grep -qF 'seed_open_notebook_env' "$REMOTE" } @test "the database password never rides in a command line" { # `surreal start` reads SURREAL_USER / SURREAL_PASS from the environment; # --pass would put it in /proc//cmdline for any ps on the host. grep -qE '^Exec=start' "$DB" ! grep -qE '^Exec=.*--(pass|user)' "$DB" } @test "the database probe can run in a shell-less image and the API probe names the health route" { # podman wraps a BARE HealthCmd as CMD-SHELL (measured on this host's podman # 5.7), and the SurrealDB image is distroless — no /bin/sh — so a bare # command there is a probe that can only ever fail: the database answers # perfectly while the health status stays `unhealthy` and the deploy gate # times out. Only the exec-form JSON array runs in that image. db_cmd="$(sed -n 's/^HealthCmd=//p' "$DB")" [ "$db_cmd" = '["/surreal","is-ready"]' ] || { echo "database probe is not exec-form JSON: $db_cmd"; false; } # The API image ships curl and a shell, so a bare command is fine there; it # must name the route that is exempt from the password middleware, on the # CONTAINER port (the probe runs inside the netns). grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP" } @test "the deployer gates open-notebook on the health path that actually answers" { grep -qF 'open-notebook) deploy_open_notebook ;;' "$REMOTE" gate="$(grep -E '^[[:space:]]*wait_for_health open-notebook ' "$REMOTE" | head -1)" [ -n "$gate" ] || { echo "deploy_open_notebook has no wait_for_health gate"; false; } # /health is the route upstream serves; the suite must not bless a guess. printf '%s' "$gate" | grep -qE '/health( |$)' # The gate is a host-loopback URL (the publish is wildcard, so 127.0.0.1 # answers) on the PUBLISHED port, never the container's 5055. printf '%s' "$gate" | grep -qE 'http://127\.0\.0\.1:[0-9]+/health' ! printf '%s' "$gate" | grep -qE ':5055/' # The database is gated by its container health before the app is started. grep -qE 'wait_container_healthy open-notebook-surrealdb' "$REMOTE" } @test "the deployer refuses to start the pair on blank secrets and never rewrites a seeded file" { # Read the seed function itself: the refusal, and the no-clobber rule that # protects a password an operator set by hand. seed="$(sed -n '/^seed_open_notebook_env()/,/^}/p' "$REMOTE")" [ -n "$seed" ] printf '%s' "$seed" | grep -qF 'return 1' printf '%s' "$seed" | grep -qF 'if [[ ! -e "$dest" ]]' printf '%s' "$seed" | grep -qF 'chmod 600' # One value, two spellings: the app reads SURREAL_PASSWORD, the CLI SURREAL_PASS. printf '%s' "$seed" | grep -qF 'SURREAL_PASS=' } @test "the planner maps every open-notebook path to the open-notebook service" { run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/open-notebook/quadlet/open-notebook.container \ dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container \ dankclaw/open-notebook/quadlet/open-notebook.network \ scripts/open-notebook.env [ "$status" -eq 0 ] [ "$output" = "open-notebook" ] # One deployable owns the pair: the unit files never plan separately. run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container [ "$output" = "open-notebook" ] # And the service belongs to dankclaw only. run python3 "$PLANNER" --target dankserver --paths scripts/open-notebook.env [ "$status" -eq 0 ] [ -z "$output" ] } @test "changed-path routing selects this seam" { run "$REPO_ROOT/scripts/test-changed.sh" --list --paths \ dankclaw/open-notebook/quadlet/open-notebook.container [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats" run "$REPO_ROOT/scripts/test-changed.sh" --list --paths scripts/open-notebook.env [ "$status" -eq 0 ] printf '%s\n' "$output" | grep -qx "tests/open_notebook.bats" } @test "both deployer entry points know the service" { grep -qF 'dankclaw:open-notebook|' "$REMOTE" grep -qF 'dankclaw:open-notebook|' "$REPO_ROOT/scripts/deploy.sh" } @test "the homepage card links the guarded vhost now that the service is live" { python3 - "$REPO_ROOT/shared/homepage/services.json" "$(onb_vhost)" <<'PY' import json, os, sys data = json.load(open(sys.argv[1])) vhost = sys.argv[2] cards = [s for s in data if s["name"] == "open-notebook"] assert len(cards) == 1, f"expected exactly one open-notebook card, got {len(cards)}" card = cards[0] assert card["category"] == "user" and card["section"] == "AI", card assert card["probe"] == "127.0.0.1:8502", card["probe"] assert card["method"] == "http" # Deployed and guarded: the card is live and its link is the vhost, so the # button on the page reaches the app through the guard chain. Card links carry # the fleet's http:// form everywhere; Caddy answers the redirect to HTTPS. assert not card.get("wip"), f"card should be live, got wip={card.get('wip')!r}" assert card.get("showLink") is True, card.get("showLink") assert card.get("url") == f"http://{vhost}", card.get("url") art = card["art"].lstrip("/") assert os.path.exists(os.path.join(os.path.dirname(sys.argv[1]), art)), art PY } @test "the runbook page exists and names the interim mount's owning issue" { page="$REPO_ROOT/serverAppConfigs-docs/Dankclaw/Open Notebook.md" [ -f "$page" ] grep -qF 'issue #7' "$page" grep -qF 'human-only' "$page" # The interim decision is one mount line; the page has to say how to undo it. grep -qF '## Rollback' "$page" }