feat(open-notebook): Quadlet pair — app + SurrealDB on a private bridge

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.
This commit is contained in:
Frank
2026-10-18 16:26:37 -07:00
parent f206ddfde5
commit 09b0badaf5
3 changed files with 219 additions and 0 deletions
@@ -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/<pid>/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
@@ -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
# `<host-you-opened>: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
@@ -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