The env file first landed with ESPERANTO_TTS_TIMEOUT equal to SHIM_TIMEOUT=600. Equality is a race: both budgets expire at the same instant, so the case the number existed to prevent — the worker abandoning a synthesis frank-tts is still finishing — is exactly what happens on the tail of a long line. Nested budgets go inside-out, which is the rule scripts/frank-tts.env states for its own LLAMA_TIMEOUT; the client waits one round-trip longer than the server, so a frank-tts give-up arrives labelled as frank-tts's error. 660. The seam's assertion moves from equality to `>` for the same reason, and now also holds ESPERANTO_LLM_TIMEOUT under upstream's 600 s web-UI patience — above it the failure surfaces as a UI timeout with no name on it. The scriptwriting number itself stays at the app's 180 s default: frank-zealot answers an outline or a transcript well inside it, which corrects the earlier commit message's claim that both timeouts were raised past upstream's 300 s.
275 lines
14 KiB
Bash
Executable File
275 lines
14 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"
|
|
# 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"
|
|
}
|
|
|
|
@test "the app publishes its UI and moves the API off the host port seerr holds" {
|
|
grep -qxF 'PublishPort=0.0.0.0:8502:8502' "$APP"
|
|
# seerr already binds 127.0.0.1:5055 on this host, so the API cannot leave
|
|
# the container on the host port upstream uses.
|
|
publish="$(grep -E '^PublishPort=[^:]+:[0-9]+:5055$' "$APP")"
|
|
[ -n "$publish" ] || { echo "no PublishPort mapping to the API's 5055"; false; }
|
|
mapping="${publish#PublishPort=}" # e.g. 0.0.0.0:5056:5055
|
|
host_port="${mapping#*:}"
|
|
host_port="${host_port%%:*}"
|
|
[ "$host_port" != "5055" ] || {
|
|
echo "the API is published on host 5055, which seerr already holds"; false; }
|
|
# The frontend auto-detects the API as <host it was opened on>:5055, so a
|
|
# remap is only correct if API_URL names the published port.
|
|
api_url="$(sed -n 's/^API_URL=//p' "$ENVF")"
|
|
[ "$api_url" = "http://192.168.0.143:${host_port}" ] || {
|
|
echo "API_URL ($api_url) does not name the published API port ($host_port)"; false; }
|
|
}
|
|
|
|
@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 "both health probes are argument-only and name real paths" {
|
|
for unit in "$APP" "$DB"; do
|
|
cmd="$(sed -n 's/^HealthCmd=//p' "$unit")"
|
|
[ -n "$cmd" ] || { echo "$(basename "$unit") has no HealthCmd"; false; }
|
|
# The quadlet generator splits HealthCmd on spaces, so anything needing
|
|
# a shell (quotes, pipes, &&) would be silently mis-sent.
|
|
! printf '%s' "$cmd" | grep -qE '["&;|`]'
|
|
done
|
|
# The API's own route, exempt from its password middleware upstream.
|
|
grep -qxF 'HealthCmd=curl -fsS http://127.0.0.1:5055/health' "$APP"
|
|
# The distroless database image has no shell and no curl; it ships a probe.
|
|
grep -qxF 'HealthCmd=/surreal is-ready' "$DB"
|
|
}
|
|
|
|
@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 exists and stays hidden until the service is deployed" {
|
|
python3 - "$REPO_ROOT/shared/homepage/services.json" <<'PY'
|
|
import json, os, sys
|
|
data = json.load(open(sys.argv[1]))
|
|
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"
|
|
# Not deployed yet: the card is reported wip-down and carries no link, so the
|
|
# page shows no dead button (the Seerr precedent).
|
|
assert card.get("wip") == "down", card.get("wip")
|
|
assert card.get("showLink") is False, card.get("showLink")
|
|
assert not card.get("url"), "a hidden card must not carry a 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"
|
|
}
|