Files
serverAppConfigs/tests/open_notebook.bats
T
Frank e5580abb6f feat(open-notebook): put the podcast generator behind the guard chain
The service came up internal-only: UI on the LAN at 8502, API at 5056, both
published on every interface, with only its own shared password in front. That
put the episode library outside the fleet's auth chain and one port scan away
from anything on the network.

- Caddy: guarded vhost notebook.franksdank.cloud (Anubis -> Authentik; provider
  notebook-proxy, internal_host caddy:8017) plus the post-auth route to the
  published UI port. One upstream is enough, because the image's own Next.js
  layer rewrites /api/* to the FastAPI beside it — so the API needs no outward
  face at all, and 5056 moves to loopback, where only the deployer's health gate
  dials it.
- scripts/open-notebook.env: API_URL and CORS_ORIGINS become the single guarded
  origin. Left to itself the app auto-detects <host>:5055, the port seerr holds
  on this host; a LAN-origin API_URL would also take the browser around the
  guard chain, where its authenticated session does not ride.
- addn-hosts: the LAN A record. The homepage card goes live and links the vhost.
- Seam: the API origin is derived from whatever the Caddyfile routes to 8502, so
  renaming the vhost moves the test instead of leaving it blessing a stale name,
  and a new test proves the vhost hands the WHOLE request to Anubis.
2026-10-19 16:13:53 -07:00

315 lines
16 KiB
Bash
Executable File

#!/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 `<host>: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/<pid>/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"
}