diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 8decde7..3f5560e 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -26,6 +26,7 @@ SERVICE_ORDER = ( "marreta", "pihole", "open-webui", + "open-notebook", "gitea-postgres", "gitea-server", "comfyui", @@ -74,6 +75,7 @@ DANKCLAW_SERVICES = { "marreta", "pihole", "open-webui", + "open-notebook", "gitea-postgres", "gitea-server", "comfyui", @@ -316,6 +318,14 @@ def services_for_path(path: str, target: str) -> set[str]: if len(parts) >= 2 and parts[1] == "open-webui": _add(services, "open-webui") return services + if len(parts) >= 2 and parts[1] == "open-notebook": + # One deployable owns the app AND its SurrealDB, like the marreta + # app/companion pair: the app alone cannot write a note and the + # database alone is a volume nothing reads. Any change under the + # directory (unit, network, or the interim episode mount) therefore + # plans the pair, which deploy_open_notebook starts database-first. + _add(services, "open-notebook") + return services # frank-dragoon left the quadlet shape on 2026-09-19 (CIRU v4.4.1 host # build, root unit): its files live in scripts/ and map there. if len(parts) >= 2 and parts[1] == "observability": @@ -426,6 +436,12 @@ def services_for_path(path: str, target: str) -> set[str]: # serving Qwen3.8-Flash-Next Q8_0. The download script fetches # the payload, which is host-side state, so it selects nothing. _add(services, "frank-archon") + if name == "open-notebook.env": + # open-notebook's tunables live here — the only repo-tracked part of + # either container's environment (the credentials are seeded on the + # host, so they are never a deploy trigger). Changing it is a + # restart of the pair, which the single deployable performs. + _add(services, "open-notebook") # Deployment/test/benchmark scripts and other repo tooling do not # restart a runtime service. return services diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index f95853a..26f8a06 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -90,6 +90,7 @@ service_is_allowed() { dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\ dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\ + dankclaw:open-notebook|\ dankclaw:frank-dragoon|dankclaw:frank-zealot|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\ @@ -646,6 +647,103 @@ deploy_open_webui() { systemctl --user restart open-webui.service } +# open-notebook is the fleet's podcast generator: one app container (API, +# worker and web UI under supervisord) in front of a SurrealDB container, with +# a private bridge between them. One handler owns the pair, because restarting +# the app without its database yields a UI that answers /health and fails every +# write; scripts/deploy-plan.py therefore maps every path of the pair here. +# +# Credentials follow the marreta convention (per-service file under the deploy +# user's home, mode 600, seeded from dankclaw/.env) with one difference: they +# are not optional. An empty OPEN_NOTEBOOK_PASSWORD is "authentication OFF", +# not "unconfigured" — the API, episode audio included, then answers anyone on +# the LAN — and an empty SURREAL_PASS lets the database come up with different +# root credentials than the app will present. So this handler refuses to start +# either unit until every key carries a value. An existing file is NEVER +# rewritten: regenerating an operator-set password would desync it from the +# credentials already stored in the database. +seed_open_notebook_env() { + local dest=/home/dank/Containers/open-notebook/env + local var missing=0 + for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do + grep -q "^${var}=." "$dest" 2>/dev/null || missing=1 + done + if [[ "$missing" == 0 ]]; then + return 0 + fi + if [[ ! -e "$dest" ]]; then + if [[ ! -f dankclaw/.env ]]; then + echo "open-notebook: neither $dest nor dankclaw/.env exists; put OPEN_NOTEBOOK_ENCRYPTION_KEY, OPEN_NOTEBOOK_PASSWORD and SURREAL_PASSWORD in dankclaw/.env (see dankclaw/.env.example)" >&2 + return 1 + fi + mkdir -p /home/dank/Containers/open-notebook + { + awk -F= '/^OPEN_NOTEBOOK_ENCRYPTION_KEY=/ || /^OPEN_NOTEBOOK_PASSWORD=/ || /^SURREAL_PASSWORD=/ { print }' \ + dankclaw/.env + # The `surreal` CLI reads SURREAL_PASS from the environment while + # the app reads SURREAL_PASSWORD. One value, written twice, so the + # pair can never be seeded with two different root passwords. + sed -n 's/^SURREAL_PASSWORD=/SURREAL_PASS=/p' dankclaw/.env + } > "$dest" + chmod 600 "$dest" + fi + for var in OPEN_NOTEBOOK_ENCRYPTION_KEY OPEN_NOTEBOOK_PASSWORD SURREAL_PASSWORD SURREAL_PASS; do + grep -q "^${var}=." "$dest" \ + || { echo "open-notebook: $dest carries no value for ${var}; fill it in directly (or in dankclaw/.env before the first seed) — a blank value here means an unauthenticated service or a mismatched database password" >&2; return 1; } + done +} + +# Both units ship Pull=never: the digest pins in the .container files are the +# contract, and the generator must never re-resolve a moving tag at start. That +# makes fetching a pin a precondition the deployer satisfies once per pin +# change; the existence check keeps every later re-deploy offline-safe. +ensure_pinned_image() { + local image="$1" + if podman image exists "$image"; then + echo "open-notebook: $image is already in local storage" + return 0 + fi + echo "open-notebook: pulling pinned image $image" + podman pull "$image" +} + +deploy_open_notebook() { + seed_open_notebook_env || return 1 + + ensure_pinned_image 'docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723' || return 1 + ensure_pinned_image 'docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212' || return 1 + + # install_quadlet copies the sibling open-notebook.network with the app + # unit. That cp is load-bearing, not tidiness: when a referenced Network= + # file is absent the v1 generator refuses to produce the container unit at + # all ("requested Quadlet unit open-notebook.network was not found"). + install_quadlet open-notebook-surrealdb dankclaw/open-notebook/quadlet open-notebook-surrealdb + install_quadlet open-notebook dankclaw/open-notebook/quadlet open-notebook + systemctl --user start open-notebook-network.service + + # Interim episode destination (Gitea issue #7, `human-only`): finished + # episode audio is bind-mounted out to this host directory. Created here as + # the deploy user so the container's mapped root writes files dank can + # read, and so the dependency is explicit rather than podman's default. + mkdir -p /home/dank/media/podcasts + + # No `systemctl --user enable`: generated Quadlet units cannot be enabled + # on this host (see deploy_authentik); boot persistence is the [Install] + # section inside each .container file. Database first, and both on every + # run — a stale app left attached to a recreated database is exactly the + # half-deployed state this handler exists to prevent. + systemctl --user restart open-notebook-surrealdb.service + wait_container_healthy open-notebook-surrealdb 90 || return 1 + systemctl --user restart open-notebook.service + # The gate is the API's own health route: upstream serves `GET /health` on + # 5055 with {"status":"healthy"} and exempts it from the password + # middleware, so it stays a real answer once OPEN_NOTEBOOK_PASSWORD is set. + # Host port 5056, because seerr already holds 127.0.0.1:5055. The UI on + # 8502 is deliberately not gated: supervisord starts it only after the API + # answers (the image's wait-for-api.sh), so this gate already implies it. + wait_for_health open-notebook http://127.0.0.1:5056/health 180 +} + deploy_comfyui() { # Build first: the unit's Image= resolves against local storage, so the # image has to exist before the generator starts a container from it. @@ -1573,6 +1671,7 @@ deploy_service() { gluetun) deploy_gluetun ;; searxng-valkey) deploy_searxng_valkey ;; open-webui) deploy_open_webui ;; + open-notebook) deploy_open_notebook ;; pihole) deploy_pihole ;; gitea-postgres) deploy_gitea_unit gitea-postgres gitea-postgres ;; gitea-server) deploy_gitea_unit gitea-server gitea-server ;; diff --git a/scripts/deploy.sh b/scripts/deploy.sh index 99a1353..393fe50 100755 --- a/scripts/deploy.sh +++ b/scripts/deploy.sh @@ -141,6 +141,7 @@ valid_service() { dankclaw:alertmanager|\ dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|dankclaw:seerr|\ + dankclaw:open-notebook|\ dankclaw:frank-dragoon|dankclaw:frank-zealot|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:calibre-server|dankserver:plex|dankserver:tautulli|\