From 5e0a27b32ba6113c72260e99122ed292e2cb4994 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Sun, 18 Oct 2026 21:35:52 +0530 Subject: [PATCH] Add the Audiobookshelf playbook --- audiobookshelf/playbook.yml | 50 ++ audiobookshelf/readme.md | 449 ++++++++++++++++++ .../roles/audiobookshelf/handlers/main.yaml | 16 + .../roles/audiobookshelf/tasks/main.yaml | 120 +++++ .../templates/audiobookshelf.container.j2 | 59 +++ audiobookshelf/roles/caddy/handlers/main.yaml | 31 ++ audiobookshelf/roles/caddy/tasks/main.yaml | 22 + audiobookshelf/vars/default.yml | 79 +++ 8 files changed, 826 insertions(+) create mode 100644 audiobookshelf/playbook.yml create mode 100644 audiobookshelf/readme.md create mode 100644 audiobookshelf/roles/audiobookshelf/handlers/main.yaml create mode 100644 audiobookshelf/roles/audiobookshelf/tasks/main.yaml create mode 100644 audiobookshelf/roles/audiobookshelf/templates/audiobookshelf.container.j2 create mode 100644 audiobookshelf/roles/caddy/handlers/main.yaml create mode 100644 audiobookshelf/roles/caddy/tasks/main.yaml create mode 100644 audiobookshelf/vars/default.yml diff --git a/audiobookshelf/playbook.yml b/audiobookshelf/playbook.yml new file mode 100644 index 0000000..9b5d788 --- /dev/null +++ b/audiobookshelf/playbook.yml @@ -0,0 +1,50 @@ +--- +# Audiobookshelf as a rootless Podman Quadlet unit, behind the shared Caddy unit +# (see caddy/playbook.yml). +# +# Unlike bitwarden/ and tandoor/, this app has no database to attach: its state +# is one SQLite file plus files under ~/podman-data/audiobookshelf, so there is +# no postgres role here and nothing to restore from the shared cluster. +# +# Connects as the container user and runs everything as that user: the Quadlet +# units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# The rootless-Podman prerequisites (installing podman, enabling lingering, the +# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied +# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and +# a host that has already run caddy/playbook.yml has them: +# +# - name: Rootless Podman prerequisites +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +# +# The variables this play needs are duplicated at the top of vars/default.yml, so +# it stands on its own. (Applying the commented include above would also need +# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.) +# +# Run with: ansible-playbook -u tanshu audiobookshelf/playbook.yml +- name: Deploy Audiobookshelf as a rootless Podman Quadlet + hosts: emily + gather_facts: true + vars_files: + - vars/default.yml + + pre_tasks: + # This must happen before the app unit is started, not after: the generated + # unit references {{ network_name }}.network, and Quadlet fails *unit + # generation* (so `systemctl --user start` reports "unit not found") when a + # referenced .network file does not exist yet. The same include writes the + # Caddy-side drop-in that attaches Caddy to this network. + # + # network_name is a play var, so the include can see it directly. + - name: Register Audiobookshelf's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + + roles: + # The app: the four data directories, the Quadlet unit, start + verify. + - audiobookshelf + # Appends this site's block to the shared Caddyfile and reloads Caddy. + - caddy diff --git a/audiobookshelf/readme.md b/audiobookshelf/readme.md new file mode 100644 index 0000000..812269b --- /dev/null +++ b/audiobookshelf/readme.md @@ -0,0 +1,449 @@ +# Audiobookshelf (rootless Podman + Quadlet) + +Runs [Audiobookshelf](https://www.audiobookshelf.org/) on `emily` as a +**rootless Podman** container managed by a **systemd user unit** (Quadlet), +behind the shared [rootless Caddy](../caddy/readme.md). Replaces the plain +`docker run` container on `monoco`: + +```command +docker run -d \ + --volume /var/lib/audiobookshelf/config:/config \ + --volume /var/lib/audiobookshelf/metadata:/metadata \ + --volume /var/lib/audiobookshelf/audiobooks:/audiobooks \ + --volume /var/lib/audiobookshelf/podcasts:/podcasts \ + --name=audiobookshelf \ + --env=TZ=Asia/Kolkata \ + --restart=unless-stopped \ + ghcr.io/advplyr/audiobookshelf:latest +``` + +Unlike [bitwarden/](../bitwarden/readme.md) and [tandoor/](../tandoor/readme.md), +**there is no database to attach.** Audiobookshelf's state is one SQLite file +plus files under `~/podman-data/audiobookshelf`, so this play has no `postgres` +role and nothing to restore from the shared cluster. + +Two facts about the published image shape everything below, and both were +verified against the real image rather than read off the docs: + +| Finding | Consequence | +|---|---| +| The image declares **no `VOLUME`** | The four `Volume=` lines in the unit are load-bearing, not decorative. Without them the app runs perfectly — and loses everything on the next recreate | +| The image ships **no `HEALTHCHECK`** | This play defines one (`HealthCmd=`) so it can gate on the app actually answering | + +## Ordering + +Run [caddy/playbook.yml](../caddy/playbook.yml) first. It is the reverse proxy, +owns the Caddyfile this site appends to, supplies the `caddy.service` handlers, +and is the one play that applies the rootless-Podman prerequisites (installing +podman, `loginctl enable-linger`, the low-port sysctl, the Quadlet directory). + +There is no second prerequisite to wait for — no cluster to bring across, since +this app has no database. + +This playbook does **not** move the library. Copy the data first (see +["Migrating the data"](#migrating-the-data-from-monoco)) so the first start finds +the real `config/`, rather than creating a fresh empty database next to it. + +## Layout + +``` +audiobookshelf/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── audiobookshelf/ # the four data dirs, the Quadlet unit, start + verify + └── caddy/ # appends this site's block to the shared Caddyfile +``` + +Quadlet units and state: + +| On the host | What | +|---|---| +| `~/.config/containers/systemd/audiobookshelf.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/audiobookshelf.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-audiobookshelf.conf` | attaches Caddy to that network | +| `~/podman-data/audiobookshelf/config/` | `absdatabase.sqlite`, `migrations/` | +| `~/podman-data/audiobookshelf/metadata/` | `backups/`, `cache/`, `logs/`, `streams/`, cover art, item metadata | +| `~/podman-data/audiobookshelf/audiobooks/` | the book library (15 GB) | +| `~/podman-data/audiobookshelf/podcasts/` | downloaded podcast episodes | + +## What is state, and what is not + +Per [docs/backup-plan.md §2.1](../docs/backup-plan.md): + +| Path | Keep? | Why | +|---|---|---| +| `config/` | **required** | `absdatabase.sqlite` — users, libraries, listening progress, bookmarks. 2.6 MB, irreplaceable | +| `metadata/` | **required** | cover art, item metadata, and `backups/`. 19 MB, irreplaceable | +| `audiobooks/` | media | 15 GB across 696 files. Re-downloadable, and [proposed for exclusion](../docs/backup-plan.md) from the backup ([§8](../docs/backup-plan.md)) | +| `podcasts/` | media | episodes the app downloads here. Unlike `audiobooks/`, this directory is **written to at runtime** | + +So the real state is **22 MB, not 15 GB**. Treating the library as state is what +made a "full backup" of this service look impossible +([docs/backup-plan.md §2](../docs/backup-plan.md)). + +`metadata/cache/`, `metadata/logs/` and `metadata/streams/` are regenerable — +transcode scratch space, the log, and the image cache. They live under +`metadata/` because that is where the app puts them, and they are small; the +migration copies them anyway rather than carving out exceptions. + +**A live `absdatabase.sqlite` must not be `tar`-ed.** It is SQLite in WAL mode, +so the database file and its `-wal` sidecar are copied at different instants and +the result can be torn — a backup that will not open +([docs/backup-plan.md §3.1](../docs/backup-plan.md)). Use the app's own +**Settings → Backups** (which writes into `BACKUP_PATH`, defaulting to +`metadata/backups`), or stop the container and copy the directory. + +## How it registers with Caddy + +Caddy is the base unit and knows nothing about its upstreams. This playbook calls +the shared include in +[caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the +network unit **and** the drop-in that pulls it into Caddy: + +```yaml +- name: Register Audiobookshelf's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" +``` + +It runs in `pre_tasks`, **before** the app unit is started, because +`audiobookshelf.container` references `audiobookshelf.network`: Quadlet fails +*unit generation* — so `systemctl --user start` reports "unit not found" — when a +referenced `.network` file does not exist yet. + +`network_name` is `audiobookshelf`, so the *file* is `audiobookshelf.network` and +the *Podman network* is `audiobookshelf_net` (`NetworkName=` in the network unit +pins it, matching the name the migration plan already assumed). + +[roles/caddy](roles/caddy/tasks/main.yaml) appends the site block to the shared +Caddyfile with `blockinfile`, exactly as the other services do. Caddy handles the +WebSocket upgrade the app's live-socket features need, and imposes no +request-body limit, so there is nothing like the old nginx `client_max_body_size` +to configure. + +**Nothing is published on the host.** The container listens on 80 and Caddy +reaches it as `audiobookshelf:80` over `audiobookshelf_net`, so this play needs +none of the port-floor work Caddy itself needs. + +## Settings + +| Variable | Value / purpose | +|---|---| +| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | +| `podman_data_dir` | `~/podman-data` | +| `quadlet_dir` | `~/.config/containers/systemd` | +| `host` | `audiobooks.tanshu.com` — the site block Caddy serves | +| `container_name` | `audiobookshelf`, and the Quadlet unit / container name | +| `image` | `ghcr.io/advplyr/audiobookshelf:latest` — already fully qualified | +| `network_name` | `audiobookshelf` → `audiobookshelf.network` / `audiobookshelf_net` | +| `config_dir` / `metadata_dir` | `~/podman-data/audiobookshelf/{config,metadata}` → `/config`, `/metadata` | +| `audiobooks_dir` / `podcasts_dir` | `~/podman-data/audiobookshelf/{audiobooks,podcasts}` → `/audiobooks`, `/podcasts` | +| `container_port` | `80` — the image's `EXPOSE 80` / `ENV PORT=80` | +| `timezone` | `Asia/Kolkata`, the only setting the Docker-era container passed | +| `health_cmd` | The `HealthCmd=` the unit defines, since the image has none | +| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | + +The three `podman_*` / `quadlet_dir` vars are **duplicated** from +[caddy/vars/default.yml](../caddy/vars/default.yml), as in every service +playbook. That is a deliberate trade: a play cannot include the Caddy vars file +wholesale, because it also sets `host_directory` (to the *Caddy* data directory) +and every service defines that for itself. Keep them in sync. Caddy's fourth, +`podman_unprivileged_port_start`, belongs to the podman tasks this play does not +run. + +Like Caddy, `podman_user` is derived from the connection and the play carries no +`become`. Because the podman tasks are not run here, there is **no guard against +connecting as `root`**; the prerequisites, including that check, live in +`caddy/playbook.yml`. + +`UserNS=keep-id:uid=0,gid=0` — the image has no `USER` instruction, so it runs as +container-root throughout and writes `/config` and `/metadata` as root +([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). Mapping +container root onto `podman_user` keeps `~/podman-data/audiobookshelf` owned by +`tanshu` and leaves no subuid-owned files to trip over backups. Upstream +explicitly does not support `PUID`/`PGID`, so this is the mechanism for the +ownership rather than an env var. + +**There is no environment file here**, unlike `bitwarden/` and `tandoor/`. This +deployment holds no secrets: Audiobookshelf generates its own JWT signing key +inside `config/`, and `TZ` is not sensitive — so it goes on the unit as a plain +`Environment=` line, and the unit stays world-readable without exposing anything. + +## Running + +```command +ansible-playbook -u tanshu audiobookshelf/playbook.yml +``` + +The play targets `emily` directly, so the inventory must contain it — the one in +`prepare-server/` does, and its `ansible.cfg` names it. + +This play applies **only** this app's units and data. Its `pre_tasks` run Caddy's +`register` include and nothing else. The rootless-Podman prerequisites are the +shared task file at +[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml) and +are **not** run here. A host that has already run `caddy/playbook.yml` has them. + +`systemctl --user enable audiobookshelf` is neither needed nor possible: the unit +is *generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +## Migrating the data from monoco + +Everything lives in `/var/lib/audiobookshelf` today, owned by **uid 0** (the +image runs as root). It moves to `~/podman-data/audiobookshelf`, and the whole +tree is ~15 GB — budget real time for the bulk copy +([docs/podman-migration.md §4.3](../docs/podman-migration.md)). + +**Run all of this from the control node that holds both SSH aliases** — the +machine where `ssh monoco` and `ssh emily` both work. The two hosts cannot reach +each other here: `emily` is a fresh instance that knows nothing about `monoco`, +and the `monoco` alias exists only in the control node's SSH config. `rsync` +also cannot copy remote-to-remote, so the 15 GB is staged on the control node in +between — check it has the room before starting. + +### 1. Look before you copy + +```bash +# Sizes, numeric owners (`ls -ln`), and whether the tree is readable without +# sudo. The old container ran as root, so the files are uid 0. +ssh monoco 'du -sh /var/lib/audiobookshelf/*' +ssh monoco 'ls -ln /var/lib/audiobookshelf' +ssh monoco 'find /var/lib/audiobookshelf -type f ! -readable' # empty = no sudo needed + +# Space on the destination (it needs the full 15 GB) and on the control node +# (it holds a second copy until step 6 deletes it). +ssh emily 'df -h / | tail -1' +df -h /home | tail -1 +``` + +If the `find` above prints anything, add `--rsync-path="sudo rsync"` to the +`monoco:` side of every `rsync` below so the transfer reads as root. + +### 2. Create the destinations + +```bash +ssh emily 'mkdir -p ~/podman-data/audiobookshelf/{config,metadata,audiobooks,podcasts}' +mkdir -p ~/audiobookshelf-migrate/{config,metadata,audiobooks,podcasts} +``` + +### 3. The 15 GB library, while the old container is still serving + +Audiobookshelf only *reads* `/audiobooks`, so the slow part needs no downtime. +If a hop drops, run it again — `rsync -P` resumes at the file it was on. + +```bash +# monoco -> control node +rsync -aP --no-owner --no-group \ + monoco:/var/lib/audiobookshelf/audiobooks/ ~/audiobookshelf-migrate/audiobooks/ + +# control node -> emily +rsync -aP --no-owner --no-group \ + ~/audiobookshelf-migrate/audiobooks/ emily:~/podman-data/audiobookshelf/audiobooks/ +``` + +A second run of either command that prints **no filenames** means that hop is +complete — cheaper and stronger than comparing counts, and it is why `rsync` is +used here rather than a single `tar` stream. Do **not** add `--delete`: if the +playbook has already run on emily, `--delete` would remove files the app created +there that are not on monoco. + +### 4. The 22 MB of state, with the old container stopped + +```bash +# Not optional for config/: absdatabase.sqlite is live SQLite in WAL mode, and a +# hot copy can be torn (docs/backup-plan.md §3.1). podcasts/ is written at +# runtime too, so it is not safe to copy while the app is up either. +ssh monoco 'docker stop audiobookshelf' + +for d in config metadata podcasts; do + rsync -aP --no-owner --no-group "monoco:/var/lib/audiobookshelf/$d/" ~/audiobookshelf-migrate/"$d"/ + rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/"$d"/ "emily:~/podman-data/audiobookshelf/$d/" +done + +# Final incremental pass over the library, in case a scan wrote metadata beside +# a book during step 3. +rsync -aP --no-owner --no-group monoco:/var/lib/audiobookshelf/audiobooks/ ~/audiobookshelf-migrate/audiobooks/ +rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/audiobooks/ emily:~/podman-data/audiobookshelf/audiobooks/ +``` + +`--no-owner` / `--no-group` are explicit because `-a` implies the opposite, and +the source files are **root**-owned on monoco. The container runs as uid 0 +in-container, which `keep-id` maps to `tanshu`, so files that arrive owned by +`tanshu` are already correct — and restoring root ownership would be wrong. + +### 5. Verify, before the first start + +Verify *before* deploying: once the playbook has run, emily's +`config/absdatabase.sqlite` has been migrated in place and no longer matches the +source. + +```bash +# File count and total bytes per directory, both ends. audiobooks/ should be +# 696 files and 15.0 GB on monoco (docs/backup-plan.md §2). +for d in config metadata audiobooks podcasts; do + echo "== $d ==" + ssh monoco "find /var/lib/audiobookshelf/$d -type f | wc -l; du -sb /var/lib/audiobookshelf/$d | cut -f1" + ssh emily "find ~/podman-data/audiobookshelf/$d -type f | wc -l; du -sb ~/podman-data/audiobookshelf/$d | cut -f1" +done + +# Byte-level digests for the irreplaceable 22 MB. Hashing the 15 GB library costs +# more than copying it did, so it is checked by count and size above instead. +for d in config metadata; do + a=$(ssh monoco "cd /var/lib/audiobookshelf/$d && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum") + b=$(ssh emily "cd ~/podman-data/audiobookshelf/$d && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum") + [ -n "$a" ] && [ "$a" = "$b" ] && echo "$d OK ${a%% *}" || { echo "$d MISMATCH: $a vs $b"; false; } +done + +# Nothing may be owned by root or by a subuid. +ssh emily 'find ~/podman-data/audiobookshelf ! -user tanshu' # empty = correct +``` + +### 6. Deploy, then clean up + +```bash +# Run after the copy, so the first start finds the real database rather than +# creating an empty one beside it. +cd prepare-server && ansible-playbook -u tanshu ../audiobookshelf/playbook.yml + +# Only after step 5 passed and the play reported a healthy container. The +# staging copy is ~15 GB; the library is on emily twice over until this runs. +rm -rf ~/audiobookshelf-migrate +``` + +Stop and leave the old container stopped. Its `config/` is now a stale snapshot, +and two live copies would diverge — rolling back means restoring monoco's +`config/` and starting it there, not starting it in place. + +### Alternatives + +Stream straight across with `tar` if the control node has no room for 15 GB. It +cannot resume: a drop at 90% starts over. + +```bash +set -o pipefail +ssh monoco 'tar -C /var/lib/audiobookshelf/audiobooks -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/audiobookshelf/audiobooks -xf -' +``` + +`set -o pipefail` matters: without it the pipeline reports only the *receiving* +`tar`'s status, so a read error or a dropped stream on the source side exits 0 +and leaves a silently short copy. The 22 MB of state is small enough that the +same pipe is fine for it. + +Or stop the old container up front and copy all four directories in one pass. +Simpler, and provably consistent because nothing is writing; the cost is that the +service is down for the whole 15 GB instead of just the 22 MB. + +```bash +ssh monoco 'docker stop audiobookshelf' +for d in config metadata audiobooks podcasts; do + rsync -aP --no-owner --no-group "monoco:/var/lib/audiobookshelf/$d/" ~/audiobookshelf-migrate/"$d"/ + rsync -aP --no-owner --no-group ~/audiobookshelf-migrate/"$d"/ "emily:~/podman-data/audiobookshelf/$d/" +done +``` + +## Verification + +```bash +systemctl --user status audiobookshelf +journalctl --user -u audiobookshelf -f +podman logs audiobookshelf + +# What the playbook checks for you: +podman healthcheck run audiobookshelf # the HealthCmd= the unit defines +podman exec audiobookshelf ls -d /config /metadata /audiobooks /podcasts +test -e ~/podman-data/audiobookshelf/config/absdatabase.sqlite + +# The health the unit reports on its own 30s timer, not just once: +podman inspect audiobookshelf --format '{{.State.Health.Status}}' + +# The mounts are real bind mounts and not the container layer: +podman inspect audiobookshelf --format '{{range .Mounts}}{{.Source}} -> {{.Destination}}{{"\n"}}{{end}}' + +# The site, end to end. +curl -sSI https://audiobooks.tanshu.com/healthcheck # 200 OK, body "OK" +``` + +Then open `https://audiobooks.tanshu.com` **in a browser** and confirm the +library is there and an existing book resumes at the right timestamp. A `curl` +cannot check that the migrated database is *readable*; only the app can. + +On a *fresh* install the first visit asks you to create the admin account and add +a library — the playbook creates the directories, not the library entries. + +Also exercise a **write** through the app — ask it to match/scan a book, or start +a podcast download — and confirm the files it creates under +`~/podman-data/audiobookshelf` are owned by `tanshu`. That is the check that +catches a wrong `keep-id`; a read-only smoke test will not. + +Then reboot the host and confirm the unit returns unattended — without +`loginctl enable-linger tanshu` it will not, and the failure is silent. + +## Notes and gotchas + +- **The image declares no `VOLUME`.** This is the one silent failure worth + knowing: with a missing `Volume=` line the app starts, serves, and looks + healthy while writing into the container's writable layer — and loses it all on + the next recreate. The play guards against it by asserting that + `config/absdatabase.sqlite` exists on the *host* after the health check passes. +- **The image ships no `HEALTHCHECK`, so this unit defines one.** Upstream's docs + discourage health checks as "generally redundant to setting a restart policy" + and as log noise; that is fair, and the reason for the deviation is that + `is-active` cannot tell a serving server from a crash-looping one under + `Restart=always`. There is deliberately **no `HealthOnFailure=`** — that would + let a temporarily slow start kill and restart a healthy server. Add + `HealthOnFailure=kill` yourself if you would rather have self-healing. +- **`ROUTER_BASE_PATH` defaults to `/audiobookshelf`.** It is undocumented in the + env reference but real, and the server logs it on every start. Bare paths still + work — middleware rewrites non-prefixed URLs — which is why + `reverse_proxy audiobookshelf:80` at the site root is correct. It matters only + if you later serve the app under a subpath, where the prefix would appear twice. +- **`wget`, not `curl`.** `curl` exists only in the image's builder stage and is + absent at runtime; `wget` is present (BusyBox). That is what `health_cmd` uses. +- **`latest` floats.** `:latest` is the newest stable release; `:edge` tracks + every commit to master, and `vX.Y.Z` tags exist. Upstream makes no pinning + recommendation either way. The image measured for this playbook was **2.37.1**, + digest `sha256:581d68b2a6fc7ebf58d81c878a9f387cbbc0d88ac9d37b298b9cee10168af85b` + — pin that digest in `vars/default.yml` if you want reproducible deploys. + Upgrading is `podman pull` plus a restart; it may run SQLite migrations on + first start. +- **The 15 GB library is not backed up, and was never covered.** No backup script + exists for this service at all ([docs/backup-plan.md §13.6](../docs/backup-plan.md)), + and the media is a deliberate exclusion. Decide explicitly whether the + audiobooks exist anywhere else ([§8](../docs/backup-plan.md)) — this playbook + takes no position on it. +- **Registering restarts all of Caddy.** Writing the network unit and the drop-in + changes Caddy's generated unit, and Quadlet has no live equivalent of + `docker network connect`, so Caddy is recreated — a brief restart of *every* + hosted site, not just this one. See + [caddy/readme.md](../caddy/readme.md), "Trade-off worth knowing". +- **The scanner/watcher has no environment variable.** "Automatically watch + libraries for changes" is a UI setting (`scannerDisableWatcher`, needs a + restart), plus a per-library flag. It cannot be set from this playbook. +- **Client IPs.** Whether the original client address survives the rootless port + forward decides whether Audiobookshelf's own auth rate limiting + (`RATE_LIMIT_AUTH_*`) sees real addresses. It is unverified on emily — see + [caddy/readme.md](../caddy/readme.md), "Client IPs". +- **If the Caddyfile does not exist yet**, this playbook creates it containing + only the site block. That is a valid Caddyfile; the Caddy playbook's seeded + global-options block is then not added, because its seed only fires on a + missing or zero-length file. Deploy Caddy first on a fresh host. +- **DNS.** `audiobooks.tanshu.com` must resolve to `emily` before Caddy can + obtain a certificate for it. +- **Root's crontab cannot `podman exec`** into another user's rootless + containers, so any scheduled `Settings → Backups` orchestration or library + scan belongs in a **systemd user timer** next to the Quadlet units. +- **Do not restart the container on monoco.** Its `config/` is a stale snapshot + of a database emily has moved past. + +## Not done here + +- Pinning the image by digest or version tag, or wiring up + `AutoUpdate=registry` with `podman auto-update`. +- Backing up the 15 GB library, or deciding whether it belongs in scope at all. +- Creating a backup script or systemd user timer for the 22 MB of real state — + neither exists today, and the app's own **Settings → Backups** is the + preferred route rather than anything Ansible-side. +- Creating the admin account and library entries, which is a first-run UI step. +- `HealthOnFailure=kill`, and any external monitoring or alerting on the + `unhealthy` state. diff --git a/audiobookshelf/roles/audiobookshelf/handlers/main.yaml b/audiobookshelf/roles/audiobookshelf/handlers/main.yaml new file mode 100644 index 0000000..9788dd8 --- /dev/null +++ b/audiobookshelf/roles/audiobookshelf/handlers/main.yaml @@ -0,0 +1,16 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +- name: Restart Audiobookshelf + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Audiobookshelf diff --git a/audiobookshelf/roles/audiobookshelf/tasks/main.yaml b/audiobookshelf/roles/audiobookshelf/tasks/main.yaml new file mode 100644 index 0000000..e9efb73 --- /dev/null +++ b/audiobookshelf/roles/audiobookshelf/tasks/main.yaml @@ -0,0 +1,120 @@ +--- +# Audiobookshelf as a rootless Podman Quadlet unit. +# +# Everything here runs as {{ podman_user }} — the connection user. No become, no +# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's +# user manager is reachable through the standard environment. + +- name: Ensure the Audiobookshelf data directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + loop: + # config/ and metadata/ are the irreplaceable 22 MB — absdatabase.sqlite, + # migrations, backups, cache, logs. audiobooks/ and podcasts/ are the + # library, which the app reads and podcast downloads are written into. + - "{{ config_dir }}" + - "{{ metadata_dir }}" + - "{{ audiobooks_dir }}" + - "{{ podcasts_dir }}" + +- name: Install the Audiobookshelf Quadlet unit + ansible.builtin.template: + src: audiobookshelf.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Audiobookshelf + +- name: Start Audiobookshelf + block: + - name: Start Audiobookshelf + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: started + daemon_reload: true + rescue: + # systemd reports "control process exited with error code" and nothing + # else, which is useless for the usual failures (an unattached network, an + # unwritable /config, a bad volume). Pull the real reason out. + - name: Collect Audiobookshelf diagnostics + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ unit_name }} --no-pager" + - "journalctl --user -u {{ unit_name }} --no-pager -n 40" + - "podman logs --tail 40 {{ container_name }}" + register: audiobookshelf_diag + changed_when: false + failed_when: false + + - name: Show Audiobookshelf diagnostics + ansible.builtin.debug: + msg: "{{ audiobookshelf_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Audiobookshelf did not start — see the diagnostics above." + +# Quadlet honours [Install] WantedBy= when it generates the unit, so +# `systemctl --user enable` is neither needed nor possible on a generated unit. +- name: Verify Audiobookshelf is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: audiobookshelf_active + changed_when: false + failed_when: audiobookshelf_active.stdout != 'active' + +# `is-active` says the container is running; it does not say the app can serve. +# `Restart=always` means even a crash-looping container is reported active. The +# image ships no HEALTHCHECK, so the unit defines one (HealthCmd=, see the +# template) and this runs exactly that command once, synchronously. +- name: Wait for Audiobookshelf to pass its health check + ansible.builtin.command: "podman healthcheck run {{ container_name }}" + register: audiobookshelf_health + until: audiobookshelf_health.rc == 0 + retries: 24 + delay: 5 + changed_when: false + +- name: Confirm the container sees all four mount targets + ansible.builtin.command: "podman exec {{ container_name }} ls -d /config /metadata /audiobooks /podcasts" + register: audiobookshelf_mounts + changed_when: false + failed_when: audiobookshelf_mounts.rc != 0 + +# The health check proves the server started — it exits 1 with EROFS if /config +# is not writable — but not that /config is the *bind mount*. This does: the +# database the app has just created has to appear on the host, not inside the +# container's writable layer. +- name: Check the database landed on the host + ansible.builtin.stat: + path: "{{ config_dir }}/absdatabase.sqlite" + register: audiobookshelf_db + +- name: Report where the database landed + ansible.builtin.debug: + msg: >- + {{ config_dir }}/absdatabase.sqlite is + {{ audiobookshelf_db.stat.pw_name | default('?') }}:{{ audiobookshelf_db.stat.gr_name | default('?') }}, + {{ audiobookshelf_db.stat.size | default(0) }} bytes — created by the + container, visible on the host, so the bind mount is real. + when: audiobookshelf_db.stat.exists + +# This is the check that catches a broken or missing Volume= line, which is the +# one failure here that is silent: the app would run perfectly well with its +# data in the container's writable layer, and lose all of it on the next +# recreate. +- name: Fail if the database is not on the host + ansible.builtin.assert: + that: audiobookshelf_db.stat.exists + fail_msg: >- + {{ config_dir }}/absdatabase.sqlite does not exist, so /config is not the + bind mount it should be. The app's state is inside the container's + writable layer and will be lost when the container is recreated — check + the Volume= lines in the generated unit. diff --git a/audiobookshelf/roles/audiobookshelf/templates/audiobookshelf.container.j2 b/audiobookshelf/roles/audiobookshelf/templates/audiobookshelf.container.j2 new file mode 100644 index 0000000..d053ddd --- /dev/null +++ b/audiobookshelf/roles/audiobookshelf/templates/audiobookshelf.container.j2 @@ -0,0 +1,59 @@ +# Audiobookshelf — rootless Podman Quadlet unit. +# Managed by Ansible (audiobookshelf/roles/audiobookshelf). Do not edit by hand. +# +# This unit joins {{ network_name }}.network, which is created by +# caddy/roles/register (the same include that attaches Caddy to it). Caddy +# reaches the app by container name, so nothing is published on the host. + +[Unit] +Description=Audiobookshelf +Documentation=https://www.audiobookshelf.org/ +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# The image has no USER instruction: it runs as container-root throughout and +# writes /config and /metadata as root (docs/podman-compat-audit.md §3.2). +# Mapping container root onto {{ podman_user }} keeps {{ host_directory }} owned +# by {{ podman_user }} on the host, so tar/rsync backups keep working and no +# subuid-owned files appear. +UserNS=keep-id:uid=0,gid=0 + +# The only setting the Docker-era container passed, and not a secret — which is +# why this unit has no EnvironmentFile=. +Environment=TZ={{ timezone }} + +Network={{ network_name }}.network + +# The image declares no VOLUME, so these four lines are the only thing keeping +# data out of the container's writable layer. Drop one and recreating the +# container silently destroys what was in it. +Volume={{ config_dir }}:/config +Volume={{ metadata_dir }}:/metadata +Volume={{ audiobooks_dir }}:/audiobooks +Volume={{ podcasts_dir }}:/podcasts + +# The image ships no HEALTHCHECK. This is the app's own unauthenticated +# /healthcheck endpoint, which answers 200 OK; wget is what the runtime image +# has. Upstream's docs discourage health checks as redundant with a restart +# policy — it is defined here anyway so the play can gate on the app actually +# answering, and so an operator can see a wedged container as `unhealthy` +# instead of `active`. Deliberately no HealthOnFailure=: that would let a +# temporarily slow start kill and restart a healthy server. +HealthCmd={{ health_cmd }} +HealthInterval=30s +HealthTimeout=5s +HealthRetries=3 +HealthStartPeriod=30s + +[Service] +Restart=always +# The first start creates and migrates absdatabase.sqlite before the server +# listens. +TimeoutStartSec=120 + +[Install] +WantedBy=default.target diff --git a/audiobookshelf/roles/caddy/handlers/main.yaml b/audiobookshelf/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..5fe87df --- /dev/null +++ b/audiobookshelf/roles/caddy/handlers/main.yaml @@ -0,0 +1,31 @@ +--- +# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit +# there. These handlers run as the connection user, so `systemctl --user` and +# `podman exec` work without any XDG_RUNTIME_DIR juggling. + +# Notified by this role when the Caddyfile changes. A reload keeps existing +# connections; a restart would briefly drop every hosted site. +# +# Note the config path: /etc/caddy/Caddyfile is the mount inside the container, +# not {{ caddyfile_path }}, which is the host side of it. +- name: Reload Caddy configuration + ansible.builtin.command: >- + podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile + changed_when: true + listen: "Reload Caddy" + +# Notified by caddy/roles/register when this service's network unit, or the +# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent +# of `docker network connect`, so the container has to be recreated — a brief +# restart of every hosted site, not just this one. See caddy/readme.md, +# "Trade-off worth knowing". +# +# Defined after the reload above so that when both are notified the cheap +# config reload happens first. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_name }}.service" + scope: user + state: restarted + daemon_reload: true + listen: Restart Caddy diff --git a/audiobookshelf/roles/caddy/tasks/main.yaml b/audiobookshelf/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..ffbe0d8 --- /dev/null +++ b/audiobookshelf/roles/caddy/tasks/main.yaml @@ -0,0 +1,22 @@ +--- +# Append this site's block to the shared Caddyfile. +# +# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the +# change as soon as the file is written; the handler only asks Caddy to re-read +# it. That is a reload, not a restart, so the other hosted sites are untouched. +# +# blockinfile is idempotent on its own via the marker, so there is no need to +# slurp the file and compare first — which also matters because slurp *fails* +# when the Caddyfile does not exist yet, on a host where the Caddy playbook has +# not run. + +- name: "Add the {{ host }} site block to the Caddyfile" + ansible.builtin.blockinfile: + path: "{{ caddyfile_path }}" + marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" + block: "{{ snippet_block }}" + create: yes + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/audiobookshelf/vars/default.yml b/audiobookshelf/vars/default.yml new file mode 100644 index 0000000..d9c64da --- /dev/null +++ b/audiobookshelf/vars/default.yml @@ -0,0 +1,79 @@ +--- +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that +# file wholesale, because it also sets `host_directory` — to the Caddy data +# directory — and every playbook defines that for itself. Keep these three in +# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start, +# belongs to the podman tasks, which this play does not run: Audiobookshelf +# publishes nothing host-side.) +# +# podman_user is derived from the connection: this play carries no become, so +# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts +# (set in playbook.yml). +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" + +# --------------------------------------------------------------------------- +# Audiobookshelf +# --------------------------------------------------------------------------- +host: audiobooks.tanshu.com + +container_name: audiobookshelf +unit_name: "{{ container_name }}.service" + +# Already fully qualified, so rootless Podman never resolves a short name +# against unqualified-search-registries — there is no TTY to answer a prompt +# when the systemd unit starts. +image: ghcr.io/advplyr/audiobookshelf:latest + +# The Quadlet file is .network. caddy/roles/register pins the +# Podman network name to _net (so: audiobookshelf.network, +# audiobookshelf_net). +network_name: "{{ container_name }}" + +host_directory: "{{ podman_data_dir }}/{{ container_name }}" + +# The four mount targets, straight from the Docker-era `docker run`. Two are +# state, two are the library: +# +# config/ absdatabase.sqlite + migrations/ irreplaceable, 2.6 MB +# metadata/ backups/, cache/, logs/, streams/ irreplaceable, 19 MB +# audiobooks/ the book library 15 GB, re-downloadable media +# podcasts/ downloaded podcast episodes +# +# The image declares NO VOLUME (verified against the published image), so these +# are conventional bind-mount targets, not image volumes: without the Volume= +# lines in the unit Podman creates nothing, the data lands in the container's +# writable layer, and recreating the container destroys it. +config_dir: "{{ host_directory }}/config" +metadata_dir: "{{ host_directory }}/metadata" +audiobooks_dir: "{{ host_directory }}/audiobooks" +podcasts_dir: "{{ host_directory }}/podcasts" + +# The port inside the container that Caddy proxies to. The image sets +# EXPOSE 80 and ENV PORT=80. +container_port: 80 + +# The one setting the Docker-era container passed. TZ is not a secret, so unlike +# bitwarden/ and tandoor/ this unit has no EnvironmentFile=: Audiobookshelf +# generates its own JWT signing key inside config/ and needs nothing from +# Ansible that has to be hidden. +timezone: Asia/Kolkata + +# The image ships no HEALTHCHECK, so this play defines one on the unit. +# /healthcheck is the app's own unauthenticated endpoint and answers 200 OK. +# `wget` is what the runtime image has — `curl` exists only in its builder +# stage. +health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/healthcheck" + +# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml. +caddy_name: caddy +caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" + +snippet_block: | + {{ host }} { + reverse_proxy {{ container_name }}:{{ container_port }} + }