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:
@@ -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
|
||||
Reference in New Issue
Block a user