From 09b0badaf558a8617aea1508beee4e81fcf10163 Mon Sep 17 00:00:00 2001 From: Frank Date: Sun, 18 Oct 2026 16:26:37 -0700 Subject: [PATCH] =?UTF-8?q?feat(open-notebook):=20Quadlet=20pair=20?= =?UTF-8?q?=E2=80=94=20app=20+=20SurrealDB=20on=20a=20private=20bridge?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two containers, because that is what upstream runs (docker-compose.yml:6,13): the app image under supervisord, plus SurrealDB. The `v1-latest-single` tag is not in play — it bundles its own database, is deprecated upstream, and would give the host a second hidden DB. Both images are digest-pinned with Pull=never and a documented one-time pull, the frank-dragoon posture: a Quadlet on :latest re-resolves on the first restart after an unrelated daemon-reload, and the failure is silent. Port map paid for by what is already on the host: seerr holds 127.0.0.1:5055 (since 2026-10-14, live `ss -ltn` agrees), so the API leaves on 5056 and the env file has to set API_URL to match, because the frontend derives the API URL from the window origin when it is unset. 8502 publishes on 0.0.0.0 — the service carries its own password and the episode-audio route is unauthenticated. 8000 is published nowhere, where upstream exposes it on loopback with root credentials. The app joins dankclaw_default first and the private systemd-open-notebook second: it reaches the published fleet endpoints through the former (host.containers.internal, the arrangement Prometheus is proven against) and finds its database by container name through the latter. Generated --network flags keep file order, so the tunable file is listed before the host secret file in both units — podman applies last-match-wins. No UserMapping: the app runs as root in its namespace (no USER in the Dockerfile) and writes only paths owned by that root. Exec= carries no database credential — `exec start --user --pass` puts it in world-readable argv; the image's own entrypoint passes it on stdin, so only the container env holds it. --- .../quadlet/open-notebook-surrealdb.container | 84 +++++++++++++++ .../quadlet/open-notebook.container | 101 ++++++++++++++++++ .../quadlet/open-notebook.network | 34 ++++++ 3 files changed, 219 insertions(+) create mode 100644 dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container create mode 100644 dankclaw/open-notebook/quadlet/open-notebook.container create mode 100644 dankclaw/open-notebook/quadlet/open-notebook.network diff --git a/dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container b/dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container new file mode 100644 index 0000000..acbcd94 --- /dev/null +++ b/dankclaw/open-notebook/quadlet/open-notebook-surrealdb.container @@ -0,0 +1,84 @@ +# Quadlet unit: open-notebook's SurrealDB (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/open-notebook-surrealdb.container +# Unit name: open-notebook-surrealdb.service (container name: open-notebook-surrealdb) +# +# WHAT IT IS. open-notebook's only datastore: notebooks, notes, sources, +# provider credentials and podcast episodes all live here. The app talks to it +# over the WebSocket RPC on the private bridge (SURREAL_URL in +# scripts/open-notebook.env), which is why the container NAME is part of the +# contract — renaming it breaks the app's database URL, not just this file. +# +# IMAGE. `surrealdb/surrealdb:v2` pinned by manifest-list digest, resolved from +# the registry on 2026-10-18 (that digest is SurrealDB 2.7.0, image built +# 2026-09-23; `v2` itself is a MOVING tag, so the digest — not the tag — is what +# makes this reproducible). Upstream open-notebook's own compose file pins the +# same `v2` tag, and its Dockerfile copies /surreal out of the same image for +# the retired single-container variant, so v2 is the version this app is built +# against. Pull=never + the documented pull step (docs: [[Open Notebook]]): +# +# podman pull docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723 +# +# NO PUBLISHED PORT. 8000 is deliberately not published: the app reaches it by +# name on systemd-open-notebook, and a published 8000 would put the RPC with +# the root credentials on every interface (upstream publishes 127.0.0.1:8000 +# "purely for local debugging" — a `podman exec` probe is enough here). The +# binary binds 0.0.0.0:8000 INSIDE its own namespace via the image's +# SURREAL_BIND env; without that image default it would bind loopback only and +# the app could never connect, so no --bind flag belongs here. +# +# CREDENTIALS, AND WHY THEY ARE NOT IN THE COMMAND LINE. The `surreal start` +# CLI reads its options from the environment (`--username` is [env: +# SURREAL_USER=], `--password` is [env: SURREAL_PASS=] — verified against the +# pinned image's own `start --help`), so the credentials arrive through the two +# EnvironmentFile lines and the argv stays clean. Passing `--pass` instead — +# what upstream's compose does — would put the database password in +# /proc//cmdline, readable by any `ps` on the host. Note the two +# spellings: the DATABASE reads SURREAL_PASS, the APP reads SURREAL_PASSWORD, +# and scripts/open-notebook.env declares both because they must hold the same +# value; the deployer writes that pair when it seeds the host secret file. +# +# NO USER= OVERRIDE. The image runs as uid 65532 (Chainguard glibc-dynamic +# base). With a NAMED volume that works unmodified: podman creates the volume's +# backing directory owned by the container's user (measured with this digest — +# the host directory lands on the mapped subuid and the store starts clean), +# which is exactly why upstream's `user: root` line is not repeated here: that +# line is commented in upstream as being "required for bind mounts", and this +# unit has none. The consequence is that the DB files are NOT readable by dank +# without `podman unshare` — accepted for a datastore nobody edits by hand; +# episode AUDIO is a host directory for the opposite reason (see the app unit). +# +# HEALTH. The base image is distroless: no shell, no curl, no wget, so no +# CMD-SHELL probe and no wget/curl probe is possible here (same class of unit +# as loki, whose probe had to move into the image). SurrealDB ships its own +# probe instead — `surreal is-ready` connects to ws://localhost:8000 by default +# and needs no credentials — verified against this digest: it prints `OK` and +# exits 0 while the server is up. Argument-only HealthCmd, like every unit on +# this host, because the quadlet generator splits it on spaces. + +[Unit] +Description=SurrealDB (open-notebook datastore) +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName=open-notebook-surrealdb +Image=docker.io/surrealdb/surrealdb:v2@sha256:e5d7387e3727bbe2f7cd6d869e48cd34777272258572ead37c9656b25f745723 +Pull=never +Network=open-notebook.network +EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env +EnvironmentFile=/home/dank/Containers/open-notebook/env +Exec=start --log info rocksdb:/mydata/mydatabase.db +Volume=open-notebook-db:/mydata +HealthCmd=/surreal is-ready +HealthInterval=30s +HealthTimeout=10s +HealthRetries=3 +HealthStartPeriod=20s + +[Service] +Restart=always +RestartSec=5 + +[Install] +WantedBy=default.target diff --git a/dankclaw/open-notebook/quadlet/open-notebook.container b/dankclaw/open-notebook/quadlet/open-notebook.container new file mode 100644 index 0000000..11b0aa5 --- /dev/null +++ b/dankclaw/open-notebook/quadlet/open-notebook.container @@ -0,0 +1,101 @@ +# Quadlet unit: open-notebook (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.container +# Unit name: open-notebook.service (container name: open-notebook) +# +# WHAT IT IS. The fleet's podcast generator: notes and sources go in, a +# two-voice (up to four) episode MP3 comes out. One image runs THREE processes +# under supervisord — the FastAPI API on 5055, the background worker that +# writes and speaks the episode, and the Next.js UI on 8502 — so this one unit +# owns the whole app, and the frontend refuses to start until the API answers +# (the image's own wait-for-api.sh). Runbook: [[Open Notebook]]. +# +# IMAGE. `lfnovo/open_notebook:v1-latest` pinned by manifest-list digest, +# resolved from the registry on 2026-10-18; it is the tag upstream's own +# docker-compose.yml pins for the multi-container shape. Do NOT reach for the +# `-single` tags: that variant bundles SurrealDB inside the app image, is +# deprecated upstream, and would give this host two databases. Pull=never, so +# the pin is fetched by the documented one-time step (docs: [[Open Notebook]]), +# which the deployer also runs when the image is absent: +# +# podman pull docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212 +# +# NETWORKS, in this order on purpose. The generated run line keeps file order, +# so the DEFAULT ROUTE rides dankclaw_default: that is the network where this +# host has measured that a bridge container can reach a host-namespace +# listener through the gateway name (the Prometheus scrape of frank-zealot is +# the proof — see [[Container Network Namespaces]]), and frank-tts lives there +# too, so the TTS credential can be `http://frank-tts:7772/v1`. The private +# systemd-open-notebook bridge is second and carries only the database; its +# traffic is on-link, so losing the default route costs it nothing. +# +# PUBLISHED PORTS, and the 5055 collision. Upstream publishes 8502 and 5055, +# and 5055 is exactly the port seerr already holds on this host +# (`PublishPort=127.0.0.1:5055:5055`; `ss -ltn` on Dankclaw 2026-10-18 shows +# 127.0.0.1:5055 taken). So the API leaves the container on host port 5056. +# That forces one setting: the frontend auto-detects the API as +# `:5055`, so API_URL in scripts/open-notebook.env must name +# the remapped port explicitly, or the UI loads and every call fails. Both +# ports are published on all interfaces because the consumer is a browser on +# the LAN — the fleet's browser-facing services (open-webui, comfyui, gitea) +# bind the same way, while loopback-only is reserved for services whose user +# path is the guard chain. This service has NO vhost and no Caddy route: it is +# internal-only until it is guarded, and its own password +# (OPEN_NOTEBOOK_PASSWORD) is what stands in front of it. That password is not +# decoration: with it unset the API answers everyone on the LAN, including the +# unauthenticated episode-audio download. +# +# VOLUMES. `/app/data` is a named volume: uploads, the LangGraph SQLite +# checkpoints and the extraction caches the image keeps under it +# (UV_CACHE_DIR, HF_HOME) are app state nobody browses. The ONE exception is +# finished audio, which is bind-mounted out of the container at +# /home/dank/media/podcasts. That mount is interim and reversible: episode +# destination is an open human-only decision (Gitea issue #7, label +# `human-only` — agents must not work it), and until it lands, episodes are +# ordinary files on the host. DATA_FOLDER is hard-coded `./data` in the app +# (WORKDIR /app) and the DB stores audio paths RELATIVE to data/podcasts, so +# covering that one subdirectory is enough and removing the line changes +# nothing else. The image declares no USER, so the app runs as container root, +# which in this host's rootless mapping (0 -> 1000, measured on this host — +# see [[Seerr]]) writes those episode files as dank. +# +# HEALTH. `GET /health` on the API returns {"status":"healthy"} and is in the +# password middleware's excluded-path list, so the probe stays honest after the +# password is set (both read from api/main.py upstream). The image installs +# curl and ffmpeg in the runtime layer, so the probe needs no shell — kept +# argument-only for the generator's space-splitting. Note what this probe does +# NOT prove: the route returns a static body, so it says the API serves, not +# that the database answers. The DB has its own probe in its unit, and the +# worker's failure to reach the database shows up as failed jobs, not as an +# unhealthy container. + +[Unit] +Description=open-notebook (podcast generator: API, worker, web UI) +After=network-online.target open-notebook-surrealdb.service +Wants=network-online.target open-notebook-surrealdb.service + +[Container] +ContainerName=open-notebook +Image=docker.io/lfnovo/open_notebook:v1-latest@sha256:54b2025a4a4853c188f74b9b8246eca8c5480e5dd1babfa92db1038ff2f30212 +Pull=never +Network=dankclaw_default +Network=open-notebook.network +PublishPort=0.0.0.0:8502:8502 +PublishPort=0.0.0.0:5056:5055 +EnvironmentFile=/home/dank/serverAppConfigs/scripts/open-notebook.env +EnvironmentFile=/home/dank/Containers/open-notebook/env +Volume=open-notebook-data:/app/data +Volume=/home/dank/media/podcasts:/app/data/podcasts +HealthCmd=curl -fsS http://127.0.0.1:5055/health +HealthInterval=30s +HealthTimeout=10s +HealthRetries=3 +HealthStartPeriod=60s + +[Service] +Restart=always +RestartSec=10 +TimeoutStartSec=900 + +[Install] +WantedBy=default.target diff --git a/dankclaw/open-notebook/quadlet/open-notebook.network b/dankclaw/open-notebook/quadlet/open-notebook.network new file mode 100644 index 0000000..5bba7c4 --- /dev/null +++ b/dankclaw/open-notebook/quadlet/open-notebook.network @@ -0,0 +1,34 @@ +# Quadlet network: private bridge for the open-notebook pair (rootless). +# +# Install path on dankclaw: ~/.config/containers/systemd/open-notebook.network +# Unit name: open-notebook-network.service (podman network name: +# systemd-open-notebook — quadlet prefixes "systemd-", so probe the runtime +# name, never the filename; see [[Container Network Namespaces]]). +# +# WHY A PRIVATE BRIDGE. The app reaches its database by container name +# (SURREAL_URL=ws://open-notebook-surrealdb:8000/rpc), and container DNS names +# resolve only on the network that owns them. Putting SurrealDB here instead of +# on dankclaw_default keeps the datastore reachable ONLY to the container that +# owns it: 8000 is not published (an unpublished port is refused outright from +# every other namespace anyway), and nothing else on the fleet-wide network can +# dial it with the root credentials. Same reasoning as the gitea and authentik +# database pairs. +# +# Kernel bridge, not slirp4netns: the 2026-08-22 open-webui incident (a dead +# per-container userspace helper left a container that accepted TCP and reset +# every request for two days, logging nothing). A bridge has no such helper. +# +# Subnet registry on dankclaw (rootless): +# 10.88.0.0/16 default "podman" bridge +# 10.89.0.0/24 gitea (systemd-gitea) +# 10.89.1.0/24 compose stack (dankclaw_default) +# 10.89.2.0/24 searxng (systemd-searxng) +# 10.89.3.0/24 open-webui (systemd-open-webui) +# 10.89.4.0/24 open-notebook (systemd-open-notebook) <- this file +# Pinned so the container IPs stay deterministic across reboots, like the +# other pinned nets; 10.89.4.0/24 was the next unclaimed /24 in this registry. + +[Network] +Driver=bridge +Subnet=10.89.4.0/24 +Gateway=10.89.4.1