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.
102 lines
5.4 KiB
INI
102 lines
5.4 KiB
INI
# 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
|