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,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
|
||||
Reference in new issue
Block a user