Add the Audiobookshelf playbook
This commit is contained in:
1 parent
34cb4aa3dc
commit
5e0a27b32b
8 files changed
+826
No files matched your search
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
}
|
||||
Reference in new issue
Block a user