Add the Audiobookshelf playbook

This commit is contained in:
tanshu committed 2026-10-18 21:35:52 +05:30
1 parent 34cb4aa3dc
commit 5e0a27b32b
8 files changed
+826

No files matched your search

+50
View File
@@ -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
+449
View File
@@ -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.
@@ -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
@@ -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.
@@ -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
@@ -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
@@ -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
+79
View File
@@ -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_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_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 }}
}