Files
Frank 933420fd29 fix(open-notebook): the database probe has to be exec-form JSON
podman wraps a bare HealthCmd as CMD-SHELL (measured on Dankclaw's podman 5.7:
--health-cmd "/surreal is-ready" stores {"Test":["CMD-SHELL","/surreal is-ready"]}),
and the pinned SurrealDB v2 image is distroless — no /bin/sh, not even `ls`.
So the probe could never run: every 30 s interval exited 1 in ~30 ms with empty
output while the database was listening and answering, and open-notebook's first
deploy died at the gate ("did not become healthy in 90s"). The hand-check
`podman exec … /surreal is-ready` printed OK, which is why the old comment
believed the probe worked.

The JSON array survives the Quadlet generator (--health-cmd "[\"/surreal\",\"is-ready\"]")
and stores ["CMD","/surreal","is-ready"], which execs directly: a throwaway
instance of the pinned digest went healthy on that form. The seam is re-pinned
to the shell-free contract; the old assertion forbade quotes in any HealthCmd,
which is what pinned the broken shape.
2026-10-19 16:07:28 -07:00

94 lines
5.0 KiB
INI

# 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 — measured against this digest, not even
# `ls` exists (`exec: "ls": executable file not found in $PATH`), so there is no
# `/bin/sh`, no curl and no wget. SurrealDB ships its own probe instead:
# `surreal is-ready` connects to ws://localhost:8000 by default, needs no
# credentials, prints `OK` and exits 0 while the server is up.
#
# It must be handed over as a QUADLET-SIDE JSON ARRAY, not a bare command.
# podman wraps a bare `HealthCmd` string as `CMD-SHELL` (measured on this host's
# podman 5.7: `--health-cmd "/surreal is-ready"` stores
# `{"Test":["CMD-SHELL","/surreal is-ready"]}`), and with no `/bin/sh` in the
# image that probe fails in ~30 ms with empty output on every interval while the
# database answers perfectly — which is exactly how this service's first deploy
# died on 2026-10-19 (`did not become healthy in 90s`). The quoted array survives
# the generator (`--health-cmd "[\"/surreal\",\"is-ready\"]"`) and stores
# `["CMD","/surreal","is-ready"]`, which execs directly and goes healthy. Do not
# "simplify" it back to a bare command.
[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