Cut the comment bloat from the Podman playbooks

Comment blocks now stay within three lines and explain only the non-obvious. Long histories, include examples, and commented-out code are gone. No task, key, or value changed; the only data change is comment text inside the generated unit files.
This commit is contained in:
tanshu committed 2026-10-18 22:10:57 +05:30
1 parent 2aab1aec1e
commit 476a6d7cd0
85 files changed
+372 -2433

No files matched your search

+2 -36
View File
@@ -1,31 +1,5 @@
---
# 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
# Requires caddy to have run first. State is SQLite, not Postgres.
- name: Deploy Audiobookshelf as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -33,18 +7,10 @@
- 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.
# Quadlet needs the network unit before it generates the app unit.
- 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
@@ -1,9 +1,4 @@
---
# 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:
@@ -13,9 +8,7 @@
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/ and metadata/ hold irreplaceable state; the media directories can be re-downloaded.
- "{{ config_dir }}"
- "{{ metadata_dir }}"
- "{{ audiobooks_dir }}"
@@ -41,9 +34,7 @@
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.
# systemd reports only a generic exit error, so collect the real reason.
- name: Collect Audiobookshelf diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -62,18 +53,15 @@
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.
# Quadlet honours WantedBy=, so systemctl 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.
# is-active still reports active for a crash-looping container, and the image ships no HEALTHCHECK.
# The unit defines one instead, and this runs exactly that command once.
- name: Wait for Audiobookshelf to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: audiobookshelf_health
@@ -88,10 +76,7 @@
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.
# The health check proves the server started, not that /config is the bind mount.
- name: Check the database landed on the host
ansible.builtin.stat:
path: "{{ config_dir }}/absdatabase.sqlite"
@@ -106,10 +91,7 @@
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.
# A missing Volume= would let the app write to the container layer and lose the data on recreate.
- name: Fail if the database is not on the host
ansible.builtin.assert:
that: audiobookshelf_db.stat.exists
+4 -16
View File
@@ -1,27 +1,15 @@
---
# 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.
# A reload keeps live connections; a restart would drop every hosted site.
# The path is inside the container, not the host {{ caddyfile_path }}.
- 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.
# Quadlet cannot attach a network live, so Caddy must be recreated.
# Defined after the reload so the cheap reload runs first when both are notified.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
@@ -1,14 +1,4 @@
---
# 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:
+6 -45
View File
@@ -1,75 +1,36 @@
---
# ---------------------------------------------------------------------------
# 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).
# Derived from the connection user; requires gather_facts.
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.
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
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).
# The Quadlet file is <network_name>.network; register pins <network_name>_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/ and metadata/ hold irreplaceable state; the media directories can be re-downloaded.
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.
# The image ships no HEALTHCHECK; /healthcheck is the app's own endpoint.
# The runtime image has wget, not curl.
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"
+2 -38
View File
@@ -1,27 +1,5 @@
---
# Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet
# unit, behind the shared Caddy unit (caddy/playbook.yml) and with its data in
# the shared Postgres unit (postgres/playbook.yml).
#
# 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.
#
# Run with: ansible-playbook -u tanshu bitwarden/playbook.yml
# Requires caddy and postgres to have run first.
- name: Deploy Vaultwarden as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -29,25 +7,11 @@
- 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, and the Postgres
# attach below needs the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Bitwarden's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# Attaches this app's network to the shared Postgres unit, which is what
# makes DATABASE_URL's host resolve, and owns the "Restart Postgres" handler
# that postgres/roles/attach notifies. Runs first so the database is
# reachable before the app starts.
- postgres
# The app: data directory, the 0600 environment file, the Quadlet unit,
# start + verify.
- bitwarden
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+1 -7
View File
@@ -1,16 +1,10 @@
---
# 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
# A changed environment file or unit file changes the generated unit, so
# Vaultwarden is recreated. The vault itself is in Postgres and the attachments
# are on a bind mount, so a recreate loses nothing — but it is a few seconds of
# downtime for the one site.
# The vault is in Postgres and the attachments are on a bind mount, so a recreate loses nothing.
- name: Restart Bitwarden
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+8 -53
View File
@@ -1,15 +1,5 @@
---
# Vaultwarden (the Bitwarden-compatible server) 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.
# /data is the image's VOLUME, and it holds everything Vaultwarden owns that is
# not in Postgres: attachments/, sends/, the rsa_key signing pair, the admin
# config and the icon cache. Postgres is authoritative for the vault itself
# (docs/backup-plan.md §3.3).
# /data holds attachments, sends, signing keys, admin config and the icon cache. The vault is in Postgres.
- name: Ensure the Vaultwarden data directory exists
ansible.builtin.file:
path: "{{ data_dir }}"
@@ -18,10 +8,7 @@
group: "{{ podman_user }}"
mode: '0755'
# The app's secrets and database coordinates are consumed by systemd
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
# written before the unit starts. Mode 0600: DATABASE_URL carries the Postgres
# password.
# systemd reads this through EnvironmentFile= before the unit starts, and DATABASE_URL carries the Postgres password.
- name: Write the environment file
ansible.builtin.template:
src: bitwarden.env.j2
@@ -51,9 +38,7 @@
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
# unreachable database, a bad volume). Pull the real reason out.
# systemd only reports "control process exited with error code", so pull the real reason.
- name: Collect Vaultwarden diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -72,35 +57,14 @@
ansible.builtin.fail:
msg: "Vaultwarden 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.
# Quadlet honours [Install] WantedBy=, so `systemctl --user enable` is not needed on a generated unit.
- name: Verify Vaultwarden is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: bitwarden_active
changed_when: false
failed_when: bitwarden_active.stdout != 'active'
# `is-active` says the container is running; it does not say the app can serve.
# Vaultwarden runs its Diesel migrations against Postgres before Rocket starts
# answering, and `Restart=always` means even a crash-looping container is
# reported active.
#
# The probe is the image's own /healthcheck.sh — the script its HEALTHCHECK runs,
# curling http://localhost:80/alive — but it is exec'd directly rather than via
# `podman healthcheck run`. Podman's healthcheck plumbing is unreliable for
# Quadlet-managed rootless containers: the check is registered against the *user*
# systemd manager, and when that fails the check never runs, and `podman
# healthcheck run` reports that the container has no healthcheck at all even
# though the image declares one (upstream issues #26066, #25034, #22804). That is
# exactly what happened on the first deploy here: the app was serving, and the
# probe wrapper was the only thing failing.
#
# Exec'ing the script is the identical probe with no plumbing in between, and it
# still depends on nothing the play has to invent — no curl/wget/python3 of its
# own, just the script the image already ships. The cost is that the path is
# hardcoded; the image's Dockerfile pins it (`COPY docker/healthcheck.sh
# docker/start.sh /`), and if a future tag moves it the failure is a plain 127,
# whose stderr the rescue below prints.
# `is-active` cannot tell whether the app serves, so run the image's own /healthcheck.sh.
- name: Wait for Vaultwarden to answer its own health check
block:
- name: Wait for Vaultwarden to answer its own health check
@@ -111,9 +75,7 @@
delay: 5
changed_when: false
rescue:
# A bare "non-zero return code" hides the one line that matters — curl's
# reason, or "no such file or directory" for a moved script — so pull the
# probe's own output and what the app is saying.
# A bare "non-zero return code" hides curl's reason or a missing script, so print the probe output.
- name: Collect readiness diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -135,9 +97,7 @@
ansible.builtin.fail:
msg: "Vaultwarden is running but did not answer /alive — see the diagnostics above."
# The database is the whole vault, and it arrives over the network that
# roles/postgres attached Postgres to. Check the name the environment file
# actually asks for, plus the shared `db` alias.
# `db` is the shared NetworkAlias, and the container name comes from the attach role, so both must resolve.
- name: Confirm Vaultwarden can resolve its database
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ item }}
@@ -160,12 +120,7 @@
path: "{{ data_dir }}/db.sqlite3"
register: bitwarden_stale_sqlite
# SQLite in /data is the image's own default, and the Docker-era deployment left
# a stale db.sqlite3 (with -wal/-shm sidecars) behind long after DATABASE_URL had
# been pointed at Postgres. This deployment ignores the file entirely, which is
# exactly why it is dangerous: it looks like a restore source, and restoring it
# would silently lose months of vault data (docs/backup-plan.md §3.3). Warn
# rather than fail, because the file is inert while DATABASE_URL is set.
# The Docker-era db.sqlite3 looks like a restore source, but it is inert while DATABASE_URL is set, so warn rather than fail.
- name: Warn about the stale SQLite database
ansible.builtin.debug:
msg: >-
+4 -17
View File
@@ -1,27 +1,14 @@
---
# 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.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- 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.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-11
View File
@@ -1,15 +1,4 @@
---
# 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 }}"
+1 -9
View File
@@ -1,13 +1,5 @@
---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, so `systemctl --user`
# works without setting XDG_RUNTIME_DIR.
#
# Required by postgres/roles/attach, which notifies it whenever this app's
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
# recreated to join the network — Quadlet has no live equivalent of
# `podman network connect`. That is a few seconds of downtime for every attached
# app, not just this one. See postgres/readme.md, "The trade-off".
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
+1 -20
View File
@@ -1,26 +1,7 @@
---
# Attach this app's network to the shared Postgres unit.
#
# Postgres knows nothing about its clients, exactly like Caddy: a service joins
# by dropping 50-bitwarden.conf and the shared 10-network-aliases.conf next to
# postgres.container. The canonical way to do that is the shared include below —
# it writes the same bytes every service writes, asserts that bitwarden.network
# already exists, and notifies the "Restart Postgres" handler defined in this
# role.
#
# This runs in the roles phase, which is after the register include in
# playbook.yml's pre_tasks has created bitwarden.network.
- name: Attach Bitwarden to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# On a first attach both drop-ins are new, so Postgres has to be recreated to
# join bitwarden_net — and that restart is a *handler*, which by default runs at
# the end of the play, after the bitwarden role has already started the app. The
# app would then come up before DATABASE_URL's host resolves, fail its own
# health check, and fail the play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-ins are unchanged, nothing is pending, and this is a no-op.
# The restart that joins Postgres to bitwarden_net is a handler, so flush it before the app starts or the health check fails.
- name: Apply the Postgres network change before starting Vaultwarden
ansible.builtin.meta: flush_handlers
+11 -48
View File
@@ -1,86 +1,49 @@
---
# ---------------------------------------------------------------------------
# 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: Vaultwarden
# 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).
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
# podman_user comes from the connection user, so the play needs gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Vaultwarden (the Bitwarden-compatible server)
# ---------------------------------------------------------------------------
# The service keeps the Docker-era name everywhere — directory, container, unit,
# Podman network and Postgres database are all `bitwarden`, and the Caddyfile
# upstream is `bitwarden:80`. Only the image is `vaultwarden/server`. Renaming
# it would touch the Caddyfile, the Postgres database and every client's saved
# server URL, for no gain.
# The service keeps the Docker-era name everywhere. Only the image is vaultwarden/server.
host: vault.tanshu.com
container_name: bitwarden
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
image: docker.io/vaultwarden/server:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: bitwarden.network,
# bitwarden_net).
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is bitwarden.network.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image's VOLUME is /data and ROCKET_PORT is 80, so this is both the bind
# mount target and the port Caddy proxies to.
# The image's VOLUME is /data and ROCKET_PORT is 80, so this is the bind mount target and the proxied port.
data_dir: "{{ host_directory }}/data"
container_port: 80
# DATABASE_URL carries the Postgres password, and the Quadlet unit is
# world-readable in ~/.config, so it goes in this 0600 file instead and is read
# by systemd through EnvironmentFile=.
# DATABASE_URL carries the Postgres password, so keep it in this 0600 file that systemd reads.
env_file: "{{ host_directory }}/bitwarden.env"
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
# handler that applies them.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The credentials the Docker-era playbook embedded in its db_url, kept as-is so
# the already-restored `bitwarden` database and the app's own connection string
# agree. Postgres is authoritative for this service; the SQLite file the image
# would use by default is stale and deliberately not migrated (see readme.md).
# Kept as-is so the restored database and the app's connection string agree.
pg_user: postgres
pg_password: '123456'
pg_database: bitwarden
# Hostname, not an IP, and the same `postgres` name the old db_url used:
# postgres/roles/attach joins the postgres container to bitwarden_net, which is
# what makes this resolve. (`db`, the shared NetworkAlias, resolves too.)
# A hostname, not an IP: postgres/roles/attach joins the container to bitwarden_net, which makes it resolve.
database_url: "postgresql://{{ pg_user }}:{{ pg_password }}@{{ postgres_container }}:5432/{{ pg_database }}"
# Vaultwarden validates that DOMAIN carries a scheme and serves its web vault
# and API links from it. The old container set exactly this.
# Vaultwarden requires a scheme here and builds its web vault and API links from it.
domain: "https://{{ host }}"
# 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"
# No request-body limit: Caddy streams request bodies and imposes none by
# default, so the 525M `client_max_body_size` the dead nginx front end carried
# has no equivalent — and needs none.
# Caddy streams request bodies with no limit by default, so there is no client_max_body_size equivalent.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}
+1 -11
View File
@@ -1,6 +1,4 @@
---
# 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
@@ -15,15 +13,7 @@
daemon_reload: true
listen: Restart Caddy
# Reference implementation for service playbooks. A service that appends a site
# block to {{ caddyfile_path }} should notify this instead of restarting the
# container, and defines its own copy of the handler in its caddy role, e.g.:
#
# - name: Reload Caddy configuration
# ansible.builtin.command: podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
# listen: "Reload Caddy"
#
# Note the container path is /etc/caddy/Caddyfile (the mount), not caddyfile_path.
# The container path is the mount, /etc/caddy/Caddyfile, not caddyfile_path.
- name: Reload Caddy configuration
ansible.builtin.command: >-
podman exec {{ caddy_name }}
+4 -17
View File
@@ -1,9 +1,4 @@
---
# Start Caddy
#
# Everything here runs as {{ podman_user }} — the connection user. No become,
# no XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager
# is reachable via the standard environment.
- name: Ensure Caddy directories exist
ansible.builtin.file:
@@ -23,13 +18,8 @@
path: "{{ caddyfile_path }}"
register: caddyfile_stat
# Caddy exits 1 with a bare "EOF" on an empty config file, so an empty
# Caddyfile means caddy.service crash-loops. Seed a minimal valid config.
#
# Only acts when the file is missing or still empty, so it never fights the
# site blocks that service playbooks append via blockinfile. The global
# options block must come first, which is why it is seeded rather than
# appended.
# Caddy exits 1 on an empty config file, so it needs a valid one first.
# The global options block has to come before any site block.
- name: Seed the Caddyfile with a valid empty config
ansible.builtin.copy:
dest: "{{ caddyfile_path }}"
@@ -72,9 +62,7 @@
state: started
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing
# else, which is useless for the usual failures (a port already bound, a
# missing image, a bad volume). Pull the real reason out.
# systemd only reports a generic exit code, so collect the real reason.
- name: Collect Caddy diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -92,8 +80,7 @@
ansible.builtin.fail:
msg: "Caddy did not start — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl enable` is neither needed nor possible on a generated unit.
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
- name: Verify Caddy is running
ansible.builtin.command: "systemctl --user is-active {{ caddy_unit }}"
register: caddy_active
+4 -22
View File
@@ -1,9 +1,4 @@
---
# Rootless Podman prerequisites. Shared by every container playbook.
#
# This playbook runs AS the container user; it does not use become to reach it.
# Root-only steps elevate individually. Keeping the connection's PAM session
# intact is what lets `systemctl --user` work without setting XDG_RUNTIME_DIR.
- name: Fail if the connecting user resolved to root
ansible.builtin.assert:
@@ -20,34 +15,21 @@
ansible.builtin.apt:
name:
- podman
# Everything below is only a Recommends, not a Depends, of podman or
# netavark. apt installs Recommends by default, so these would normally
# arrive on their own; listing them makes the requirement explicit and
# keeps it true under --no-install-recommends.
# - aardvark-dns # container-name DNS: Caddy resolves every upstream by name
# - nftables # netavark's firewall backend; Podman 6 dropped iptables
# - passt # pasta, the rootless network backend
# - uidmap # newuidmap/newgidmap, required for rootless user namespaces
state: present
update_cache: true
cache_valid_time: 3600
become: true
# Without lingering, the user's systemd manager is torn down when the last
# session ends: Quadlet units stop, and they never start at boot.
# Without lingering, the user manager stops with the last session and units
# never start at boot.
- name: Enable systemd lingering for {{ podman_user }}
ansible.builtin.command: "loginctl enable-linger {{ podman_user }}"
args:
creates: "/var/lib/systemd/linger/{{ podman_user }}"
become: true
# Rootless containers cannot bind host ports below 1024 unless the kernel
# floor is lowered. Caddy publishes 80/443.
#
# This is applied inline rather than via a handler on purpose: handlers only
# run at the end of the play, by which point Caddy has already tried (and
# failed) to bind port 80. sysctl -w touches the running kernel; the file
# makes it survive a reboot.
# Rootless containers cannot bind ports below 1024 unless the kernel floor is
# lowered. Applied inline, because a handler would run after Caddy already bound.
- name: Read the current unprivileged port floor
ansible.builtin.command: sysctl -n net.ipv4.ip_unprivileged_port_start
register: podman_port_floor
+6 -42
View File
@@ -1,33 +1,6 @@
---
# Register a service's logical network with Caddy.
#
# Caddy is the *base* unit and knows nothing about its upstreams. Each service
# adds itself by writing two files into the Quadlet directory:
#
# 1. its own <name>.network unit, and
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
#
# Caddy's own playbook never changes when a service is added or removed.
#
# Include this from a service playbook:
#
# - name: Register this service with Caddy
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
# vars:
# network_name: "{{ container_name }}"
#
# The including play must also run the `podman` role (for podman_user and
# quadlet_dir) and define a "Restart Caddy" handler:
#
# - name: Restart Caddy
# ansible.builtin.systemd_service:
# name: caddy.service
# scope: user
# state: restarted
# daemon_reload: true
# listen: Restart Caddy
#
# It runs as the connection user, which must be {{ podman_user }}.
# Include from a service playbook with network_name set. The play must define
# the "Restart Caddy" handler.
- name: "Create the {{ network_name }} network unit"
ansible.builtin.copy:
@@ -36,15 +9,8 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# {{ network_name }} — logical app network.
# Managed by Ansible. Do not edit by hand.
#
# NetworkName pins the Podman network name. Without it Quadlet would
# create "systemd-{{ network_name }}".
#
# isolate blocks traffic between separate Podman networks, so a container
# on this network cannot reach one that is only on another. Caddy and any
# shared service still reach it, because they are members of it.
# Managed by Ansible. NetworkName pins the Podman name; isolate blocks
# traffic between separate networks.
[Network]
NetworkName={{ network_name }}_net
Options=isolate=true
@@ -57,10 +23,8 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# Added by the {{ network_name }} playbook. Do not edit by hand.
#
# Quadlet merges this into caddy.container. Deleting this file removes
# the network from Caddy on the next daemon-reload.
# Added by {{ network_name }}. Deleting it detaches the network from
# Caddy on the next daemon-reload.
[Container]
Network={{ network_name }}.network
notify: Restart Caddy
+5 -25
View File
@@ -1,46 +1,26 @@
---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# The container user is derived from the connection: this play carries no
# become, so the modules run as — and the Quadlet units belong to — whoever
# Ansible connects as. Requires gather_facts (set explicitly in playbook.yml).
#
# To use a different user you would have to reintroduce become_user, which is
# exactly what this design avoids; the podman role fails loudly if the resolved
# user is root.
# Set gather_facts: true, because these values come from ansible_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
# State and config live in the user's home, not /var/lib. Taken from the
# user_dir fact, so this is correct even for users whose home is not
# /home/<name>.
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# Rootless containers cannot publish ports below 1024 without this sysctl.
# Caddy needs 80/443, so patching it down to 80 is the standard trade.
# Caddy publishes 80/443, which needs the unprivileged port floor lowered.
podman_unprivileged_port_start: 80
# ---------------------------------------------------------------------------
# Caddy
# ---------------------------------------------------------------------------
caddy_name: caddy
caddy_unit: "{{ caddy_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified, because short names prompt for a registry and the unit has
# no TTY.
caddy_image: docker.io/library/caddy:latest
host_directory: "{{ podman_data_dir }}/caddy"
caddyfile_path: "{{ host_directory }}/conf/Caddyfile"
# Quadlet drop-in directory for the Caddy unit. Services register themselves
# here; see the "How services register themselves" section of readme.md.
# Caddy itself never lists its upstream networks.
caddy_dropin_dir: "{{ quadlet_dir }}/{{ caddy_name }}.container.d"
# Host ports. 443/udp is HTTP/3 (QUIC).
# 443/udp carries HTTP/3.
caddy_ports:
- "80:80"
- "443:443"
-15
View File
@@ -1,6 +1,3 @@
#################################################
# DO Community Playbooks: Docker
#################################################
---
- hosts: all
become: true
@@ -49,15 +46,3 @@
- /var/lib/gitea:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
# - name: Sets Nginx conf file for gitea
# template:
# src: "files/nginx.conf.j2"
# dest: "/etc/nginx/sites-available/{{ http_conf }}"
# - name: Enables new site
# file:
# src: "/etc/nginx/sites-available/{{ http_conf }}"
# dest: "/etc/nginx/sites-enabled/{{ http_conf }}"
# state: link
# notify: Reload Nginx
+2 -42
View File
@@ -1,33 +1,5 @@
---
# Gitea as a rootless Podman Quadlet unit on emily, behind the shared Caddy
# unit (caddy/playbook.yml), with git-over-SSH still arriving on the host's own
# port 22 so that no client has to change its remote.
#
# 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.)
#
# Unlike bitwarden/ and tandoor/ there is no postgres role here: this Gitea runs
# on SQLite, which monoco's live app.ini confirms (`DB_TYPE = sqlite3`,
# `PATH = /data/gitea/gitea.db`). The unused Docker-era Postgres variant that
# used to sit beside this file was removed for the same reason.
#
# Run with: ansible-playbook -u tanshu gitea/playbook.yml
# Requires caddy to have run first. Gitea uses SQLite, not Postgres.
- name: Deploy Gitea as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -35,23 +7,11 @@
- 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.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Gitea's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# The host half of git-over-SSH: the `git` login user, the passthrough shim,
# the authorized_keys hook, the sshd drop-in and the one-command sudoers
# rule. Root-only steps elevate individually — see the role for why.
- ssh
# The app: the data directory tree the image's own root phase would leave
# subuid-owned, the Quadlet unit, start + verify.
- gitea
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -17
View File
@@ -1,27 +1,14 @@
---
# 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.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- 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.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-11
View File
@@ -1,15 +1,4 @@
---
# 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 }}"
+11 -87
View File
@@ -1,33 +1,5 @@
---
# Gitea 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.
# The data tree is created here rather than left to the image, and that is not
# housekeeping. With keep-id:uid={{ gitea_uid }} the container process runs as
# `git`, so /usr/bin/entrypoint is the *only* part of the image's start-up that
# root can do — and it needs root for /etc/passwd, /etc/s6 and this tree.
#
# Container root is a subuid on the host, but inside its user namespace it holds
# the usual capabilities, so `mkdir`, `chmod` and `chown` all succeed; what it
# creates is simply left owned by that subuid. The image chowns the directories it
# knows about back to `git` — `chown -R ${USER}:git /data/gitea` and `/data/git`
# in /etc/s6/gitea/setup, which is where app.ini comes from — so those work either
# way. The things it does *not* chown are what this task is for, and they are the
# ones that would otherwise be subuid-owned and invisible to the app and to a
# plain tar backup:
#
# gitea/ app.ini, gitea.db, sessions, avatars, indexers
# gitea/conf where the entrypoint would generate app.ini
# gitea/log Gitea's file log
# git/ repositories, LFS, and the git account's HOME
# git/.ssh authorized_keys — written by Gitea, read by the host's sshd
# ssh/ only ever a *mount point* — see below
#
# The sshd *inside* the image is the exception; it cannot use this tree at all.
# See {{ container_sshd_hostkeys }} below.
# Create the tree as podman_user: anything left to the image stays subuid-owned and misses a plain tar backup.
- name: Ensure the Gitea data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -48,27 +20,12 @@
- { path: gitea/conf, mode: '0755' }
- { path: gitea/log, mode: '0755' }
- { path: git, mode: '0755' }
# 0700 because that is what the image's own setup script chmods it to
# (`chmod 700 /data/git/.ssh` in /etc/s6/gitea/setup, which runs as root
# before `su-exec git`). Set here so the mode never depends on that script
# having run.
# The image's setup chmods this to 0700; setting it here does not depend on that script.
- { path: git/.ssh, mode: '0700' }
# The in-image sshd's host keys are mounted over /data/ssh from
# {{ container_sshd_hostkeys }}, so this directory is never written to — it
# has to *exist*, because it is the mount point. If it does not, Podman
# creates it during container set-up, inside the container's user namespace,
# and it lands here owned by the subuid rather than by {{ podman_user }}:
#
# drwxr-xr-t 2 165536 165536 4096 ssh
#
# which the check at the end of this role then (correctly) fails on. Creating
# it first is what keeps the data tree wholly {{ podman_user }}'s.
# This is a mount point, so it must exist; letting Podman create it leaves it subuid-owned and fails the owner check.
- { path: ssh, mode: '0755' }
# The image's setup creates this file if it is missing, as root — and it is the
# one file under /data the setup never chowns back to `git`, so on a fresh host it
# would be left subuid-owned. Writing it here, as {{ podman_user }} and 0600,
# both avoids that and states the value the image's own environment carries.
# The image would create this as root and never chown it back, leaving it subuid-owned.
- name: Write the container-side .ssh/environment file
ansible.builtin.copy:
dest: "{{ host_directory }}/git/.ssh/environment"
@@ -78,16 +35,7 @@
group: "{{ podman_user }}"
mode: '0600'
# The image always starts an sshd of its own inside the container, and that sshd
# wants host keys. This deployment does not use it — git-over-SSH arrives on the
# host's sshd on port 22 — but it has to have somewhere to write, and the process
# that runs it maps to a subuid that cannot touch the data directory. Keeping it
# out here means no subuid-owned file ever lands in {{ host_directory }}, so a
# plain tar/rsync of the data tree stays complete and error-free, and the check
# at the end of this role stays meaningful.
#
# 0777 is deliberate and bounded: /home/{{ podman_user }} is 0750, so no other
# host user can reach this path at all. The mode only matters inside the userns.
# The in-image sshd runs as a subuid that cannot touch the data directory, and 0777 is safe because the home directory is 0750.
- name: Ensure the scratch host key directory exists
ansible.builtin.file:
path: "{{ container_sshd_hostkeys }}"
@@ -107,16 +55,7 @@
- Reload systemd user units
- Restart Gitea
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
#
# All three steps are in one block because they fail the same way and want the
# same evidence. `systemctl start` reports success as soon as it has handed the
# unit to systemd, so a container that dies a second later — a `User=0` missing,
# an unwritable /data, a subuid that cannot run the image's start-up — surfaces
# only as `activating` from `is-active`, or as a health check that never passes,
# with the reason in the journal and nowhere else. Restart=always makes that a
# crash loop rather than an error.
# Quadlet honours [Install] WantedBy=, so the generated unit is never enabled.
- name: Start and verify Gitea
block:
- name: Start Gitea
@@ -126,9 +65,7 @@
state: started
daemon_reload: true
# Retried rather than checked once: podman reports `activating` for the
# interval between systemd starting the container and its readiness
# notification.
# Retried because podman reports `activating` between start and readiness.
- name: Verify Gitea is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: gitea_active
@@ -137,9 +74,7 @@
delay: 5
changed_when: false
# `is-active` says the container is running; it does not say the app can
# serve. The image ships no HEALTHCHECK, so the unit defines one (HealthCmd=,
# see the template) and this runs exactly that command once, synchronously.
# The image ships no HEALTHCHECK, so the unit defines one and this runs it synchronously.
- name: Wait for Gitea to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: gitea_health
@@ -148,10 +83,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures. The journal is where the image's
# own start-up output is — that is where a s6 or entrypoint permission
# failure shows up.
# systemd only reports "control process exited with error code", so read the journal for the real reason.
- name: Collect Gitea diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -170,13 +102,7 @@
ansible.builtin.fail:
msg: "Gitea did not start — see the diagnostics above."
# A wrong keep-id is the one failure here that does not look like one: Gitea
# still serves, but from files it owns as an unmapped subuid on the host — files
# that then disappear from a tar backup and cannot be deleted without
# `podman unshare`. Check the whole tree, not a sample.
#
# Deliberately not `failed_when: false`: if `find` cannot read something in the
# data directory, the tree is not provably clean, and that is the finding.
# A wrong keep-id still serves, but writes subuid-owned files a tar backup misses, so check the whole tree and let find fail.
- name: List anything in the data tree not owned by {{ podman_user }}
ansible.builtin.command: >-
find {{ host_directory }} ! -user {{ podman_user }}
@@ -198,9 +124,7 @@
path: "{{ host_directory }}/gitea/gitea.db"
register: gitea_db
# Not a failure: a fresh instance legitimately has no database yet. But on a
# migration it means the copy in readme.md has not run, and the instance that is
# now serving is an empty installer rather than this repository's Gitea.
# Not a failure on a fresh instance, but a migration whose copy never ran will serve an empty installer.
- name: Report a data directory with no database in it
ansible.builtin.debug:
msg: >-
+5 -49
View File
@@ -1,44 +1,11 @@
---
# The host half of Gitea's git-over-SSH support.
#
# Port 22 belongs to the host's sshd and stays that way: clients keep using
# git@git.tanshu.com:user/repo.git, and no remote URL changes. Four host-side
# pieces make that work:
#
# 1. a `{{ git_user }}` login user with no interactive access — every key
# Gitea accepts carries a forced command, so sshd runs the shim and
# nothing else;
# 2. {{ shim_path }}, the shim those forced commands invoke;
# 3. {{ podman_exec_wrapper }} plus a one-command sudoers rule — how the shim,
# running as `{{ git_user }}`, reaches a container owned by
# {{ podman_user }};
# 4. the sshd and sudoers drop-ins that tie the above together.
#
# What differs from the Docker-era role is the second hop. It used to be an ssh
# into an sshd running *inside* the container; under keep-id that sshd runs as a
# subuid and cannot read the authorized_keys Gitea writes as {{ podman_user }},
# so the hop is a `podman exec` instead. readme.md, "How git over SSH works
# now", is where the reasoning lives.
#
# Every task here is root-only (users, /usr/local/bin, /etc/ssh, /etc/sudoers),
# so each elevates on its own. The play carries no become, because the Quadlet
# units have to belong to the connection user.
# Git-over-SSH uses the host's sshd on port 22, and the shim reaches the container through a one-command sudoers rule.
- name: Ensure the git group exists
ansible.builtin.group:
name: "{{ git_user }}"
become: true
# The shell matters even though nobody ever gets one: sshd runs a forced command
# through the login user's shell (`/bin/bash -c '...'`), so a nologin shell would
# break git-over-SSH outright.
#
# The uid is deliberately left to the system. On monoco the host git account was
# uid 1003 because the container's git account was 1003 and the two shared one
# uid space; under keep-id the container's 1003 is mapped onto {{ podman_user }}
# instead, so nothing on this host depends on this number any more — not the
# key file (owned by name), not the sudoers rule (matched by name), not the
# authorized_keys hook (run as {{ podman_user }}).
# sshd runs the forced command through the login user's shell, so it must not be nologin; the uid is left to the system.
- name: Add the git login user
ansible.builtin.user:
name: "{{ git_user }}"
@@ -49,11 +16,6 @@
create_home: true
become: true
# There is deliberately no ~/.ssh for this user any more. Its old contents were
# the shim's client key and the authorized_keys Gitea maintains; the key is
# obsolete (nothing authenticates over ssh in this design) and the file lives in
# the container's data tree now, where Gitea writes it. The migration notes in
# readme.md move the file into place.
- name: Install the git-over-SSH passthrough shim
ansible.builtin.template:
@@ -82,9 +44,7 @@
mode: '0755'
become: true
# valid with `visudo -cf %s`: a syntax error in /etc/sudoers.d does not fail one
# rule, it fails *every* sudo invocation on the host, which on a server reached
# by ssh is a nasty way to find out.
# A syntax error in /etc/sudoers.d breaks every sudo on the host, so validate before installing.
- name: Let the git user reach the container as {{ podman_user }}
ansible.builtin.template:
src: sudoers-gitea.j2
@@ -107,10 +67,7 @@
register: gitea_sshd_dropin
become: true
# A drop-in that does not parse is not a failed play, it is a host nobody can
# log in to after the next reboot: sshd reads the whole directory at start-up.
# Validate the resulting configuration before it is ever loaded, and take the
# file away again if it does not parse.
# A drop-in that does not parse locks everyone out on the next reboot, so validate it and remove the file if it fails.
- name: Validate the resulting sshd configuration
ansible.builtin.command: /usr/sbin/sshd -t
changed_when: false
@@ -130,8 +87,7 @@
sshd is still running — and still restarting — on its previous
configuration. Run `/usr/sbin/sshd -t` on the host to see why.
# `reload`, not `restart`: a reload re-execs sshd without dropping the connection
# this play is running over.
# A reload re-execs sshd without dropping the connection this play runs over.
- name: Reload sshd
ansible.builtin.systemd_service:
name: "{{ sshd_service }}"
+15 -69
View File
@@ -1,114 +1,60 @@
---
# ---------------------------------------------------------------------------
# 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: Gitea publishes
# nothing host-side.)
#
# podman_user is derived from the connection: this play carries no become at
# play level, so the Quadlet units belong to whoever Ansible connects as.
# Requires gather_facts (set in playbook.yml).
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
# podman_user comes from the connection user, so the play needs gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Gitea
# ---------------------------------------------------------------------------
host: git.tanshu.com
container_name: gitea
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
image: docker.io/gitea/gitea:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: gitea.network, gitea_net).
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is gitea.network.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Port inside the container that Caddy proxies to — app.ini's HTTP_PORT. Not
# published on the host: Caddy reaches it by container name.
# The port Caddy proxies to inside the container, where app.ini's HTTP_PORT listens. Nothing is published on the host.
container_port: 3000
# The uid/gid of the `git` account the image creates for itself
# (git:x:1003:1003, verified in the image), and therefore the uid Gitea runs as.
# The number is load-bearing three times over:
#
# * UserNS in the Quadlet unit maps it onto podman_user, which is what keeps
# every file Gitea writes owned by podman_user on the host — plain tar and
# rsync backups, no subuids;
# * USER_UID/USER_GID force the entrypoint to keep it, so the file layout and
# the mapping cannot drift apart;
# * roles/ssh execs into the container as exactly this uid.
#
# Change one and the other two have to change with it.
# The uid/gid of the image's `git` account, and therefore the uid Gitea runs as.
# UserNS maps it onto podman_user, USER_UID/GID keep it, and roles/ssh execs as it, so change all three together.
gitea_uid: 1003
gitea_gid: 1003
# Where the image keeps its data *inside* the container, and therefore the
# GITEA_CUSTOM the image's own environment already sets.
# The image's data directory inside the container, and the GITEA_CUSTOM it already sets.
gitea_custom: /data/gitea
# Gitea's own unauthenticated health endpoint. The image ships no HEALTHCHECK,
# so the unit defines one from this and the play gates on it.
# The image ships no HEALTHCHECK, so the unit defines one from this endpoint and the play gates on it.
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/healthz"
# Only log timestamps are affected: Gitea stores each user's own timezone. The
# Docker-era container bind-mounted the host's /etc/localtime and /etc/timezone
# instead; TZ is the same intent without depending on either file existing.
# Only log timestamps are affected: Gitea stores each user's own timezone.
timezone: Asia/Kolkata
# A scratch directory for the sshd *inside* the image, which this deployment
# does not use but which still has to write host keys somewhere. It is kept
# outside the data tree on purpose: the process that runs it is the container's
# root, which under keep-id is a subuid, and a subuid-owned file inside
# {{ host_directory }} would break both a plain tar backup and the play's
# "nothing in here belongs to another user" check. See readme.md.
# The in-image sshd needs host keys somewhere, but it runs as a subuid, so keep them outside the data tree.
container_sshd_hostkeys: "{{ podman_data_dir }}/{{ container_name }}-sshd"
# ---------------------------------------------------------------------------
# The git-over-SSH entry point on the host
# ---------------------------------------------------------------------------
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the
# *host's* sshd, exactly as they did on monoco. No sshd runs inside the
# container: under keep-id the container's root is a subuid that cannot read the
# authorized_keys Gitea writes as podman_user, so the second hop is a `podman
# exec` instead of an ssh. See readme.md, "How git over SSH works now".
#
# `git_home` only has to exist — sshd runs the forced command from it — and its
# uid is deliberately left to the system: nothing in this design pairs the host
# account with the container's uid. See roles/ssh/tasks/main.yaml.
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the host's sshd.
# `git_home` only has to exist, and its uid is left to the system.
git_user: git
git_home: /home/git
# The command Gitea writes into every authorized_keys line it maintains. This is
# a contract, not a knob: it is already baked into the lines sitting in the file
# copied from monoco, and renaming it locks every user out of git-over-SSH until
# Gitea rewrites them. See roles/ssh/templates/gitea.j2.
# Renaming this path locks users out because every authorized_keys line Gitea writes already contains it.
shim_path: /usr/local/bin/gitea
# Runs as podman_user, via the sudoers drop-in, and does the podman exec.
# Runs as podman_user through the sudoers drop-in and does the podman exec.
podman_exec_wrapper: /usr/local/bin/gitea-container-exec
# What sshd calls to obtain the git user's authorized_keys.
authorized_keys_command: /usr/local/bin/gitea-authorized-keys
sshd_dropin_path: /etc/ssh/sshd_config.d/60-gitea.conf
sshd_service: ssh
sudoers_path: /etc/sudoers.d/gitea-ssh-passthrough
# ---------------------------------------------------------------------------
# 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"
-19
View File
@@ -25,9 +25,6 @@ query_range:
enabled: true
max_size_mb: 100
# limits_config:
# metric_aggregation_enabled: truez
schema_config:
configs:
- from: 2020-10-24
@@ -40,25 +37,9 @@ schema_config:
pattern_ingester:
enabled: true
# metric_aggregation:
# enabled: true
# loki_address: localhost:3100
ruler:
alertmanager_url: http://localhost:9093
frontend:
encoding: protobuf
# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration
# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/
#
# Statistics help us better understand how Loki is used, and they show us performance
# levels for most users. This helps us prioritize features and documentation.
# For more information on what's sent, look at
# https://github.com/grafana/loki/blob/main/pkg/analytics/stats.go
# Refer to the buildReport method to see what goes into a report.
#
# If you would like to disable reporting, uncomment the following lines:
#analytics:
# reporting_enabled: false
+1 -12
View File
@@ -1,9 +1,6 @@
---
# The image is configured to run by default as user loki with UID 10001 and GID 10001.
# You can use a different user, specially if you are using bind mounts, by specifying
# the UID with a docker run command and using --user=UID with a numeric UID suited to your needs.
# The image runs as UID and GID 10001, so the data directory must be owned by 10001.
# Tasks for Loki role
- name: Pull Loki image
docker_image:
name: "{{ loki_image }}"
@@ -18,14 +15,6 @@
owner: "{{ loki_user }}"
mode: 0755
# - name: Ensure Loki Config Directory exists
# file:
# path: "{{ loki_directory }}/config"
# state: directory
# group: "{{ loki_user }}"
# owner: "{{ loki_user }}"
# mode: 0755
- name: Copy the Loki conf file
template:
src: "files/loki-config.yaml"
-23
View File
@@ -21,26 +21,3 @@ promtail_image: grafana/promtail:latest
promtail_directory: "{{ host_directory }}/promtail"
promtail_user: "{{ loki_user }}"
promtail_port: 9080
# mosquitto_container: mosquitto
# mosquitto_image: eclipse-mosquitto:latest
# mosquitto_directory: "{{ host_directory }}/mosquitto"
# mosquitto_user: 'root'
# nodered_container: nodered
# nodered_image: nodered/node-red:latest
# nodered_directory: "{{ host_directory }}/nodered"
# nodered_user: "{{ user }}"
# influx_container: influxtestplay
# influx_image: influxdb:latest
# influx_directory: "{{ host_directory }}/influxtestplaydir"
# influx_user: "{{ user }}"
# grafana_container: grafana
# grafana_image: grafana/grafana-enterprise:latest
# grafana_directory: "{{ host_directory }}/grafana"
# grafana_user: "{{ user }}"
# http_host: "iot.tanshu.com"
# http_conf: "iot.tanshu.com.conf"
+2 -37
View File
@@ -1,23 +1,5 @@
---
# Nextcloud as a rootless Podman Quadlet unit, behind the shared Caddy, with its
# database in the shared Postgres and its cache in the shared Valkey. Each of
# those is its own playbook and has to have been run first: caddy, postgres,
# valkey, then this.
#
# Idempotent and standalone: the first run creates and starts the container (a
# fresh webroot then serves the setup wizard), and later runs converge it —
# Quadlet recreates the container when the unit or its environment changes.
#
# Runs as the connection user with no become: the units belong to that user, so
# the SSH session's own PAM session is what makes `systemctl --user` work.
# gather_facts is required — podman_user and the home directory come from the
# user_id / user_dir facts.
#
# The rootless-Podman prerequisites (podman, lingering, the low-port sysctl, the
# Quadlet directory) are not applied here; they are the shared task file at
# caddy/roles/podman, and a host that has run caddy/playbook.yml has them.
#
# Run with: ansible-playbook -u tanshu nextcloud/playbook.yml
# Requires caddy, postgres, and valkey to have run first.
- name: Deploy Nextcloud as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -25,29 +7,12 @@
- 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, and both attaches
# below need the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Nextcloud's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# Attaches this app's network to the shared Postgres unit, which is what
# makes config.php's `db` host resolve, and owns the "Restart Postgres"
# handler that postgres/roles/attach notifies. Runs first so the database is
# reachable before the app starts.
- postgres
# The same at the cache end: attaches this app's network to the shared Valkey
# unit, which is what makes config.php's `redis` host resolve, and owns the
# "Restart Valkey" handler that valkey/roles/attach notifies.
- valkey
# The app: the webroot, the 0600 environment file, the Quadlet unit, the
# background-job timer, start + verify.
- nextcloud
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -17
View File
@@ -1,27 +1,14 @@
---
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
# there. These handlers run as the connection user, which is what makes
# `systemctl --user` and `podman exec` work here.
# 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.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- 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.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-13
View File
@@ -1,17 +1,4 @@
---
# 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.
#
# The marker is stable, so re-runs replace this site's block in place rather than
# appending a second copy of it.
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
+2 -8
View File
@@ -1,17 +1,11 @@
---
# Handlers run as the connection user, which is what makes `systemctl --user`
# work here.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
# A changed environment file or unit file changes the generated unit, so
# Nextcloud is recreated. Nothing is lost by that: config.php, data/ and
# custom_apps/ are on the bind mount and the database is in Postgres. It is still
# a few seconds of downtime for the site, and the entrypoint re-runs its version
# check — including `occ upgrade` if the image has moved on — on the way back up.
# Config, data, and custom_apps are on the bind mount, and the database is in Postgres.
# The entrypoint reruns its version check, including occ upgrade, on the way back up.
- name: Restart Nextcloud
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+7 -29
View File
@@ -1,14 +1,4 @@
---
# Nextcloud as a rootless Podman Quadlet unit.
#
# Everything runs as the connection user; requires gather_facts (set in
# playbook.yml), which is where podman_user and the home directory come from.
#
# A fresh webroot is a valid state. The image initialises the code and serves the
# setup wizard, and this play succeeds. Set nextcloud_admin_user and
# nextcloud_admin_password in vars/default.yml to have it installed unattended
# instead.
- name: Ensure the webroot exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -17,9 +7,7 @@
group: "{{ podman_user }}"
mode: '0755'
# Read by the entrypoint, not by Ansible, so it has to exist before the unit
# starts. Mode 0600: POSTGRES_PASSWORD is in it, and the unit file is
# world-readable.
# The entrypoint reads this before the unit starts, and POSTGRES_PASSWORD means it stays 0600.
- name: Write the environment file
ansible.builtin.template:
src: nextcloud.env.j2
@@ -40,8 +28,7 @@
- Reload systemd user units
- Restart Nextcloud
# Quadlet applies [Install] WantedBy= when it generates the unit, so a generated
# unit is never `systemctl enable`d.
# Quadlet applies [Install] WantedBy= when it generates the unit, so it is never enabled.
- name: Start Nextcloud and wait for it to serve
block:
- name: Start Nextcloud
@@ -51,13 +38,8 @@
state: started
daemon_reload: true
# `systemctl start` returns while the unit is still `activating`, and a
# crash-looping container is `activating` most of the time as well, so
# is-active proves nothing. status.php is Nextcloud's own endpoint: it answers
# only once the entrypoint has finished (including any `occ upgrade`) and
# Apache is up, it reports `installed` either way — so one gate fits both a
# fresh webroot and an update — and `maintenance` is in it too, because a
# maintenance-mode 503 on every page is otherwise invisible to a probe.
# `systemctl start` returns while the unit is still activating, and a crash loop looks the same.
# status.php answers after the entrypoint and Apache are ready, and it reports maintenance mode.
- name: Wait for Nextcloud to serve status.php
ansible.builtin.command: >-
podman exec {{ container_name }} php -r
@@ -70,8 +52,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing else.
# The reason is in the entrypoint's own output.
# systemd only reports "control process exited with error code", so read the entrypoint output.
- name: Collect diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -94,11 +75,8 @@
this playbook has produced before. If the unit is in the `failed` state
from an earlier attempt: systemctl --user reset-failed {{ unit_name }}
# Nextcloud wants `cron.php` every five minutes. This has to be *this user's*
# crontab, not root's: root cannot `podman exec` into another user's rootless
# containers. It works without any environment plumbing because the caddy
# playbook enables lingering, so /run/user/<uid> exists whether or not anyone is
# logged in — which is where podman keeps the state `exec` needs.
# This must be the connection user's crontab. Root cannot podman exec into another user's rootless containers.
# Lingering keeps /run/user/<uid> present, which is where podman keeps the state exec needs.
- name: Schedule Nextcloud's background jobs
ansible.builtin.cron:
name: Nextcloud background jobs
+1 -9
View File
@@ -1,13 +1,5 @@
---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, which is what makes
# `systemctl --user` work here.
#
# Required by postgres/roles/attach, which notifies it whenever this app's
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
# recreated to join the network — Quadlet has no live equivalent of
# `podman network connect`. That is a few seconds of downtime for every attached
# app, not just this one. See postgres/readme.md, "The trade-off".
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
+2 -11
View File
@@ -1,21 +1,12 @@
---
# Attach this app's network to the shared Postgres unit, and make sure the
# database exists.
- name: Attach Nextcloud to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# The restart that joins Postgres to nextcloud_net is a handler, so by default it
# would run at the end of the play — after the app has started and failed to
# resolve `db`. Flushing first also applies the Caddy restart queued by the
# register pre_task. Nothing is pending on later runs.
# The restart that joins Postgres to nextcloud_net is a handler, so flush it before the app starts. This also applies the queued Caddy restart.
- name: Apply the network change before starting Nextcloud
ansible.builtin.meta: flush_handlers
# Postgres ships one default database and only this app knows the name of its own,
# so on a fresh cluster the installer would otherwise fail with "database
# nextcloud does not exist". The retry is for the restart the flush above may have
# just done.
# On a fresh cluster the installer would fail without this database, and the retry covers the restart the flush did.
- name: Check whether the {{ pg_database }} database exists
ansible.builtin.command: >-
podman exec -e PGPASSWORD={{ pg_password }} {{ postgres_container }}
+2 -14
View File
@@ -1,18 +1,6 @@
---
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
# there. This handler runs as the connection user, which is what makes
# `systemctl --user` work here.
#
# Required by valkey/roles/attach, which notifies it whenever this app's drop-in
# changes: a new drop-in changes the generated unit, so Valkey must be recreated
# to join the network — Quadlet has no live equivalent of
# `podman network connect`.
#
# Data survives the recreate: RDB persistence is on by default, the stop is
# graceful, and the new container loads the snapshot back off the bind mount. What
# does not survive is availability — for the seconds the container is down,
# Nextcloud's PHP sessions and `memcache.locking` are unreachable. See
# valkey/readme.md, "The trade-off".
# A new drop-in changes the generated unit, so Valkey is recreated to join the network.
# Data survives the recreate, but Nextcloud's cache and locks are unavailable for the seconds it is down.
- name: Restart Valkey
ansible.builtin.systemd_service:
name: "{{ valkey_unit }}"
+2 -22
View File
@@ -1,28 +1,8 @@
---
# Attach this app's network to the shared Valkey unit.
#
# Valkey knows nothing about its clients, exactly like Caddy and Postgres: a
# service joins by dropping 50-nextcloud.conf and the shared
# 10-network-aliases.conf next to valkey.container. The canonical way to do that
# is the shared include below — it writes the same bytes every service writes,
# asserts that nextcloud.network already exists, and notifies the "Restart
# Valkey" handler defined in this role.
#
# `redis` is the alias the app connects to, not the container name: it is the host
# an instance configured for Redis has in config.php. This playbook does not write
# that configuration, it only makes the name resolvable.
# The app connects to `redis`, the alias the attach role adds, not the container name.
- name: Attach Nextcloud to Valkey
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
# On a first attach the drop-in is new, so Valkey has to be recreated to join
# nextcloud_net — and that restart is a *handler*, which by default runs at the
# end of the play, after the nextcloud role has already started the app. The app
# would then come up before `redis` resolves; with `memcache.locking` on Redis it
# would fail on the first write rather than at startup, which is a worse failure
# to debug.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-in is unchanged, nothing is pending, and this is a no-op.
# The restart that joins Valkey to nextcloud_net is a handler, so flush it before the app starts or it fails on the first cache write.
- name: Apply the Valkey network change before starting Nextcloud
ansible.builtin.meta: flush_handlers
+9 -44
View File
@@ -1,41 +1,26 @@
---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated from caddy/vars/default.yml (which also sets `host_directory` for
# Caddy, so it cannot be included wholesale). Keep the three in sync. Its fourth,
# podman_unprivileged_port_start, is not needed here: Nextcloud publishes nothing
# host-side.
# Duplicated from caddy/vars/default.yml, so keep them in sync.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Nextcloud
# ---------------------------------------------------------------------------
# The site's hostname: what Caddy serves, and the trusted domain for an unattended
# install.
# What Caddy serves, and the trusted domain for an unattended install.
host: cloud.hopsngrains.com
container_name: nextcloud
unit_name: "{{ container_name }}.service"
# Fully qualified: rootless Podman resolves short names against
# unqualified-search-registries, and a unit has no TTY to answer the prompt.
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
image: docker.io/library/nextcloud:latest
# caddy/roles/register pins the Podman network name to <network_name>_net, so the
# file is nextcloud.network and the network is nextcloud_net.
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is nextcloud.network.
network_name: "{{ container_name }}"
# The image's VOLUME and Apache document root, and — unlike the other services
# here — the code as well as the state: config.php, data/, custom_apps/ and every
# PHP file live under it. See readme.md, "What is state, and what is not".
# The image's VOLUME and Apache docroot, and unlike other services it holds the code as well as the state.
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
webroot: /var/www/html
# www-data. The entrypoint rsyncs the code in as this uid and Apache runs its
# workers as it, so this is the one uid to map onto the connection user.
# www-data. The entrypoint rsyncs the code in as this uid, and Apache workers run as it.
app_uid: 33
app_gid: 33
@@ -44,48 +29,28 @@ container_port: 80
env_file: "{{ host_directory }}/nextcloud.env"
# ---------------------------------------------------------------------------
# Shared services (each its own playbook)
# ---------------------------------------------------------------------------
# Postgres, and the alias its attach role adds.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
db_host: db
# The database this play creates if it is missing, and the credentials in
# postgres/vars/default.yml that the cluster was initialised with.
# The database this play creates, and the credentials the cluster was initialised with.
pg_user: postgres
pg_password: '123456'
pg_database: nextcloud
# Valkey, which the attach role puts this app's network on with the alias
# `redis`. Whether Nextcloud uses it is config.php's decision: the cache and
# `memcache.locking` live there, not in this playbook. See readme.md.
# Valkey's attach role adds the `redis` alias, which config.php uses for cache and locking.
valkey_container: valkey
valkey_unit: "{{ valkey_container }}.service"
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# ---------------------------------------------------------------------------
# First-run install (optional)
# ---------------------------------------------------------------------------
# Empty by default: a fresh webroot is initialised by the image and the setup
# wizard takes over. Set both to have the image install unattended instead, with
# trusted_domains set from `host`. Inert once config.php exists.
# Empty keeps the setup wizard. Set both to install unattended, with trusted_domains from `host`.
nextcloud_admin_user: ""
nextcloud_admin_password: ""
# ---------------------------------------------------------------------------
# Background jobs
# ---------------------------------------------------------------------------
# Nextcloud wants cron.php every five minutes. Not a variable worth exposing
# beyond the interval, since the schedule is the part someone may change.
cron_minute: "*/5"
# ---------------------------------------------------------------------------
# Caddy site block
# ---------------------------------------------------------------------------
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}
+1 -23
View File
@@ -1,25 +1,5 @@
---
# Deploys PostgreSQL as a rootless Podman Quadlet unit.
#
# 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 those tasks need are duplicated at the top of vars/default.yml,
# so this play stands on its own.
#
# Run with: ansible-playbook -u tanshu postgres/playbook.yml
# Run this before the app playbooks. Each service attaches itself.
- name: Deploy PostgreSQL as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -27,6 +7,4 @@
- vars/default.yml
roles:
# The base postgres unit. It does NOT know which app networks it serves.
# Each service attaches itself via postgres/roles/attach.
- postgres
+6 -45
View File
@@ -1,45 +1,12 @@
---
# Attach a service's network to Postgres.
#
# Postgres is the *base* unit and knows nothing about its clients, the same way
# Caddy knows nothing about its upstreams. A service that needs a database adds
# itself by dropping files next to postgres.container, and Quadlet merges them in.
#
# Include this from a service playbook:
#
# - name: Attach this service to Postgres
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# vars:
# network_name: "{{ network_name }}"
#
# The including play must:
#
# 1. have created {{ network_name }}.network already — caddy/roles/register
# does that, and every service here that needs a database is also behind
# Caddy, so its register include comes first;
# 2. run the `podman` role (or include its task file and define the same four
# vars), for podman_user and quadlet_dir;
# 3. define a "Restart Postgres" handler — see
# roles/postgres/handlers/main.yaml for the exact snippet; and
# 4. flush handlers before starting the app, on a first attach. This include
# notifies that restart, so by default it runs at the end of the play —
# after the app has already started and failed to resolve `db`. A
# `meta: flush_handlers` immediately after this include is enough; see
# tandoor/roles/postgres for the working example.
#
# It runs as the connection user, which must be the rootless podman_user.
#
# A service may instead write these two files from its own role; the bytes have
# to match this file exactly, or the two will overwrite each other on every run.
- name: "Check that the {{ network_name }} network unit exists"
ansible.builtin.stat:
path: "{{ quadlet_dir }}/{{ network_name }}.network"
register: postgres_attach_network
# A drop-in naming a .network file that does not exist makes Quadlet fail to
# generate the whole postgres unit, with a message that does not mention the
# drop-in at all. Fail here instead, where the cause is obvious.
# A drop-in naming a missing network breaks unit generation with an unrelated
# error, so fail here instead.
- name: "Fail if {{ network_name }} has no network unit"
ansible.builtin.assert:
that: postgres_attach_network.stat.exists
@@ -64,20 +31,14 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# Added by the {{ network_name }} playbook. Do not edit by hand.
#
# Deleting this file removes the network from {{ postgres_container | default('postgres') }} on
# the next daemon-reload; nothing else changes.
# Added by {{ network_name }}. Deleting it detaches the network from
# {{ postgres_container | default('postgres') }} on the next daemon-reload.
[Container]
Network={{ network_name }}.network
notify: Restart Postgres
# NetworkAlias= applies to every network the container joins, which is exactly
# right for a hub: every app in this repo reaches postgres as `db` (nextcloud,
# gitea, tandoor and outline use the alias; the rest use the container name).
# It is a fixed value in one shared file, not a per-service option, so the same
# bytes are written no matter how many services attach — two services writing
# their own aliases would make Quadlet pass --network-alias twice.
# NetworkAlias applies to every network, so one shared file holds the fixed
# alias. Two services writing their own would pass --network-alias twice.
- name: "Give {{ postgres_container | default('postgres') }} the shared 'db' alias"
ansible.builtin.copy:
dest: >-
+1 -17
View File
@@ -1,12 +1,11 @@
---
# 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
# A new drop-in needs a recreate, because Quadlet has no live network connect.
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
@@ -14,18 +13,3 @@
state: restarted
daemon_reload: true
listen: Restart Postgres
# Reference implementation for service playbooks. A service that attaches its
# network with postgres/roles/attach must notify "Restart Postgres", and defines
# its own copy of this handler, e.g.:
#
# - name: Restart Postgres
# ansible.builtin.systemd_service:
# name: postgres.service
# scope: user
# state: restarted
# daemon_reload: true
# listen: Restart Postgres
#
# A new drop-in changes the generated unit, so Postgres has to be recreated to
# join the network — Quadlet has no live equivalent of `podman network connect`.
+9 -29
View File
@@ -1,9 +1,4 @@
---
# Start PostgreSQL
#
# Everything here runs as {{ podman_user }} — the connection user. No become,
# no XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager
# is reachable via the standard environment.
- name: Ensure the Postgres data directory exists
ansible.builtin.file:
@@ -13,14 +8,8 @@
group: "{{ podman_user }}"
mode: '0755'
# PGDATA and its version directory are created here rather than left to the
# container's own `mkdir -p`. The entrypoint runs as container-root, which under
# keep-id is a subuid on the host, and it only chowns PGDATA and its contents —
# so the version directory would be left owned by 165536 and the host user could
# not even `rm -rf` it without podman unshare. Creating the whole chain up front
# keeps every directory in the data root owned by podman_user.
#
# Empty PGDATA is exactly right: the 18 entrypoint reads that as "run initdb".
# The entrypoint runs as a subuid under keep-id, so create PGDATA here to keep
# it owned by podman_user rather than 165536.
- name: Ensure the versioned data directory exists
ansible.builtin.file:
path: "{{ pgdata_host_dir | dirname }}"
@@ -45,8 +34,7 @@
group: "{{ podman_user }}"
mode: '0755'
# The unit file is 0644 in ~/.config, so the password does not belong in it.
# This file is 0600 and is read by the container through EnvironmentFile=.
# The unit file is world-readable, so the password goes in a 0600 env file.
- name: Write the Postgres environment file
ansible.builtin.template:
src: postgres.env.j2
@@ -67,10 +55,8 @@
- Reload systemd user units
- Restart Postgres
# The wait is part of the block on purpose. `Restart=always` means systemd
# reports the unit active even while the container crash-loops — a wrong
# keep-id shows up as an entrypoint permission error over and over — so
# is-active alone would call a broken database healthy.
# `Restart=always` reports the unit active while the container crash-loops, so
# wait for a real connection instead of trusting is-active.
- name: Start Postgres and wait for it to accept connections
block:
- name: Start Postgres
@@ -80,8 +66,7 @@
state: started
daemon_reload: true
# pg_isready is the server's own answer, and it fails while initdb is still
# running on a fresh data directory (it exits 2, so the retry loop holds).
# pg_isready fails with exit 2 while initdb runs, so the retry loop holds.
- name: Wait for Postgres to accept connections
ansible.builtin.command: >-
podman exec {{ postgres_container }}
@@ -92,9 +77,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing
# else, which is useless for the usual failures (a bad uid mapping, an
# unwritable PGDATA, a missing image). Pull the real reason out.
# systemd only reports a generic exit code, so collect the real reason.
- name: Collect Postgres diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -112,8 +95,7 @@
ansible.builtin.fail:
msg: "Postgres did not come up — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl enable` is neither needed nor possible on a generated unit.
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
- name: Verify the Postgres unit is active
ansible.builtin.command: "systemctl --user is-active {{ postgres_unit }}"
register: postgres_active
@@ -143,9 +125,7 @@
{{ postgres_dropins.files | map(attribute='path') | map('basename') | join(', ')
| default('(nothing yet)', true) }}.
# Say this out loud rather than let it be discovered by a failing app: with no
# drop-in, Postgres has no Network= at all, so it sits on the default Podman
# network and no app network can resolve it.
# With no drop-in there is no Network=, so it sits on the default Podman network.
- name: Note that no service has attached yet
ansible.builtin.debug:
msg: >-
+16 -60
View File
@@ -1,94 +1,50 @@
---
# ---------------------------------------------------------------------------
# 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: Postgres listens on
# 5432 and publishes nothing.)
#
# 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).
# A play cannot include caddy/vars/default.yml, which also sets host_directory,
# so keep these in sync with it. Set gather_facts: true for the facts below.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# PostgreSQL
# ---------------------------------------------------------------------------
# The name the rest of the repo already uses for this container in its
# `postgres_container` vars, and the name every app resolves over the network.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The major version is the one knob, because the image tag and the PGDATA
# directory have to agree.
#
# 18 is deliberate. monoco runs 17.5 and the data is brought across with a
# logical dump — a PGDATA copy cannot cross a major version. See
# migrate-from-monoco.md for the one-time move.
#
# `-alpine` still floats within 18.x, which is what you want for security
# patches; pin a digest here if the image itself must be immutable.
# 18 is deliberate: monoco runs 17.5 and a PGDATA copy cannot cross a major
# version, so the data moves with a logical dump. The alpine tag still floats
# within 18.x for security patches.
postgres_major: '18'
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified, because short names prompt for a registry and the unit has
# no TTY.
postgres_image: "docker.io/library/postgres:{{ postgres_major }}-alpine"
# The uid the server actually writes as — not the image's configured user. The
# Alpine image creates the "postgres" account at uid/gid 70 ("70 is the standard
# uid/gid for postgres in Alpine"), and the entrypoint starts as root, chowns
# PGDATA to it, then drops privileges. keep-id maps that uid onto podman_user,
# so PGDATA ends up owned by podman_user on the host and never by a subuid. That
# is what keeps backups working without podman unshare or sudo.
# The image's postgres account is uid and gid 70. keep-id maps it onto
# podman_user, so PGDATA and backups stay owned by the host user.
postgres_uid: 70
postgres_gid: 70
host_directory: "{{ podman_data_dir }}/{{ postgres_container }}"
# Postgres 18 moved both the image's VOLUME and its default PGDATA:
#
# 17 Volume /var/lib/postgresql/data PGDATA /var/lib/postgresql/data/pgdata
# 18+ Volume /var/lib/postgresql PGDATA /var/lib/postgresql/<major>/docker
#
# So the mount is now the parent directory and the version lives in the path.
# That is what the 18 entrypoint expects: it refuses to start — "there appears
# to be PostgreSQL data in ..." — if it finds a cluster in the old layout, and
# the versioned subdirectory is what makes a later `pg_upgrade --link` work
# without mount-boundary trouble.
# Postgres 18 moved PGDATA under <major>/docker, and its entrypoint refuses a
# cluster in the old layout. The versioned path also keeps pg_upgrade --link on
# one filesystem.
postgres_mount: /var/lib/postgresql
# Everything under the mount that identifies this cluster, kept as one value so
# the container path and the host path cannot drift apart.
# One value keeps the container and host paths from drifting apart.
postgres_versioned_subdir: "{{ postgres_major }}/docker"
pgdata: "{{ postgres_mount }}/{{ postgres_versioned_subdir }}"
pgdata_host_dir: "{{ host_directory }}/{{ postgres_versioned_subdir }}"
# Secrets do not go in the unit file: it is world-readable, and `podman inspect`
# prints the environment anyway. They go in a 0600 file the unit reads with
# EnvironmentFile=.
# The unit file is world-readable, so secrets go in a 0600 EnvironmentFile=.
postgres_env_file: "{{ host_directory }}/postgres.env"
# Quadlet drop-in directory for the Postgres unit. Services attach themselves
# here; see the "How services attach themselves" section of readme.md.
# Postgres itself never lists its app networks.
postgres_dropin_dir: "{{ quadlet_dir }}/{{ postgres_container }}.container.d"
# Credentials. This is the placeholder the app playbooks already embed in their
# db_url / POSTGRES_PASSWORD, kept as-is so a migrated app connects unchanged.
# monoco's cluster uses the same one, so the dump restores and the apps connect
# with no password reset. Replace it here and in those playbooks together when
# the fleet moves to a real secret.
# Placeholder password. Replace with a real secret.
pg_user: postgres
pg_password: '123456'
pg_database: postgres
# The first start runs initdb, and a database wants time to shut down cleanly.
# First start runs initdb, and a database needs time to stop cleanly.
postgres_timeout_start: 120
postgres_timeout_stop: 60
+1 -7
View File
@@ -1,11 +1,5 @@
---
# Tasks for the common role.
#
# Runs in the second play, against the account the server was prepared for
# ({{ user }}). The .ssh directory and the authorized_keys entries are already
# in place by the time this runs: the user role installs them during the
# bootstrap play, because they are what makes logging in as {{ user }} --
# and therefore this play -- possible in the first place.
# Runs in the second play, after the user role has installed the keys this play logs in with.
- name: Update the sshd config file to disable password logins
ansible.builtin.replace:
+4 -22
View File
@@ -1,18 +1,5 @@
---
# Tasks for the user role.
#
# Runs in the first (bootstrap) play, against the provider's bootstrap account
# ({{ bootstrap_user }}), and does the two things that must happen before
# anything else can: create the account this server is being prepared for
# ({{ user }}), and install the public keys that make it reachable. Every
# later task logs in as {{ user }} rather than as the bootstrap account, so
# without those keys the second play cannot connect at all.
#
# Authentication is key based only: no password is set, which leaves the
# account locked for password logins. The shell, group membership and the rest
# of the account's configuration belong to the wheel/zsh roles. Setting a
# shell here would fight the zsh role and change it back on every run, so this
# role deliberately does not touch it.
# The zsh role owns the login shell, so this role must not set one.
- name: Create the desired user
ansible.builtin.user:
@@ -37,14 +24,9 @@
group: "{{ user }}"
mode: "0644"
# The second play logs in as {{ user }} with the private half of this key on
# the control node, so this entry is what makes that login possible. It is
# installed here, during the bootstrap play, rather than being left to depend
# on one of the named keys below happening to be the control node's own.
#
# lineinfile rather than ansible.builtin.authorized_key: the latter redirects
# to ansible.posix, whose 2.1.0 module imports a deprecated helper and emits a
# deprecation warning on every run. This is the same guarantee without it.
# The second play logs in with this key, so bootstrap installs it here rather
# than relying on a named key below. It uses lineinfile because authorized_key
# redirects to ansible.posix 2.1.0, which imports a deprecated helper and warns on every run.
- name: Authorize the control node's key for the new user
ansible.builtin.lineinfile:
dest: "/home/{{ user }}/.ssh/authorized_keys"
+10 -12
View File
@@ -3,9 +3,8 @@ docker_container: rustdesk
image_name: "rustdesk/rustdesk-server-s6"
image_version: "latest"
# Public name clients use, and the name Caddy serves. hbbs hands this out as the
# relay address, and a WebSocket client rewrites a hostname to
# wss://<host>/ws/relay (an IP would get neither TLS nor the path).
# Clients use this name and Caddy serves it. hbbs hands it out as the relay
# address, so it must be a hostname and not an IP.
host: rust.tanshu.com
rustdesk_relay_server: "{{ host }}:21117"
@@ -15,10 +14,9 @@ data_location: "/var/lib/{{ host_directory }}/data"
docker_network: "{{ docker_container }}_net"
caddy_container: caddy
# hbbs/hbbr also speak their own TCP/UDP protocol on 21115-21117, which is not
# HTTP and so cannot go through Caddy. Leave this off to keep the host's only
# open ports at 80/443: clients then have to enable WebSocket, and every session
# is relayed. Turn it on for clients that do not use WebSocket.
# hbbs and hbbr speak their own protocol on 21115-21117, which Caddy cannot
# carry. Leave this off to keep only 80/443 open. Every session is then relayed
# and clients must use WebSocket. Turn it on for clients that do not.
rustdesk_publish_raw_ports: false
rustdesk_raw_ports:
- "21115:21115"
@@ -28,11 +26,11 @@ rustdesk_raw_ports:
rustdesk_published_ports: "{{ rustdesk_raw_ports if rustdesk_publish_raw_ports | bool else [] }}"
# hbbs writes id_ed25519/id_ed25519.pub under /data on first start. Clients are
# configured with the public key, and ENCRYPTED_ONLY makes hbbr check it too.
# hbbs writes its key pair under /data on first start. Clients need the public
# half, and ENCRYPTED_ONLY makes hbbr check it too.
rustdesk_encrypted_only: true
# "Y" pushes every session through the relay. WebSocket clients are relay-only
# Y pushes every session through the relay. WebSocket clients are relay-only
# regardless, so this only matters for clients on the raw ports.
rustdesk_always_use_relay: false
@@ -41,8 +39,8 @@ rustdesk_env:
ENCRYPTED_ONLY: "{{ '1' if rustdesk_encrypted_only | bool else '0' }}"
ALWAYS_USE_RELAY: "{{ 'Y' if rustdesk_always_use_relay | bool else 'N' }}"
# Caddy terminates TLS on 443 and carries the WebSocket transports: 21118 is
# hbbs's WebSocket listener, 21119 hbbr's. Neither is published on the host.
# Caddy terminates TLS on 443 and carries the WebSockets: 21118 is hbbs's
# listener, 21119 hbbr's. Neither port is published on the host.
caddyfile_path: /var/lib/caddy/conf/Caddyfile
snippet_block: |
{{ host }} {
+2 -45
View File
@@ -1,39 +1,5 @@
---
# sms-wa-bridge as a rootless Podman Quadlet unit on emily, behind the shared
# Caddy unit (caddy/playbook.yml).
#
# The app is SMS webhook -> routing rules -> WhatsApp, with no LLM in the path:
# an Android automation (MacroDroid) POSTs each SMS to https://bridge.tanshu.dev/sms
# with an X-Auth header, rules.json decides who gets it, and whatsapp-web.js
# sends it from a linked WhatsApp account. It was deployed on monoco as a plain
# `docker run` container built from the Dockerfile now vendored at
# files/build/ — see readme.md.
#
# 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.)
#
# The one prerequisite it *does* need beyond Caddy's is buildah, and only on the
# build path: `podman` Recommends it on Ubuntu, so caddy/roles/podman has already
# installed it. Nothing needs it when the image is loaded from monoco, which is
# the recommended route in readme.md.
#
# Run with: ansible-playbook -u tanshu sms-wa-bridge/playbook.yml
# Requires caddy to have run first.
- name: Deploy sms-wa-bridge as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -41,19 +7,10 @@
- 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.
# Quadlet needs the network unit before it generates the app unit.
- name: Register the bridge's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# The app: the data tree and the two bind-mounted files, the environment
# file, the image (loaded or built), the Quadlet unit, start + verify.
- sms-wa-bridge
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+5 -21
View File
@@ -1,32 +1,16 @@
---
# 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.
#
# Restart Caddy carries `when: not ansible_check_mode` — unlike the other copies
# of this file in the repository, which predate the idea — so that a `--check`
# run of this playbook is a preview and not a partial deploy. The `command`
# handler below needs no guard: Ansible skips those under --check on its own.
# 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.
# A reload keeps live connections; a restart would drop every hosted site.
# The path is inside the container, not the host {{ caddyfile_path }}.
- 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.
# Quadlet cannot attach a network live, so Caddy must be recreated.
# Defined after the reload so the cheap reload runs first when both are notified.
# Under --check the unit was never written, so skip the restart.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-10
View File
@@ -1,14 +1,4 @@
---
# 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:
@@ -1,24 +1,12 @@
---
# All handlers run as the connection user ({{ podman_user }}), so systemctl
# --user works without setting XDG_RUNTIME_DIR.
#
# The `systemd_service` handlers — this one and Caddy's, in roles/caddy — carry
# `when: not ansible_check_mode`: under --check the unit file was never written,
# so systemd cannot find the unit and a restart would fail rather than be
# skipped. The `command` handler below needs no guard: Ansible skips those under
# --check on its own.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
# A changed unit file, a changed environment file, or a rebuilt image — all of
# them mean the app is recreated. Everything it owns is on the four bind mounts,
# so a recreate loses nothing: the linked-device session, the routing rules and
# the log all live on the host. It is a few seconds during which incoming SMS
# get a connection error from Caddy rather than a 200, which MacroDroid will
# retry or drop depending on how the action is configured.
# A recreate keeps state on the bind mounts; incoming SMS fail for a few seconds.
# Under --check the unit file was never written, so skip the restart.
- name: Restart sms-wa-bridge
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+25 -121
View File
@@ -1,23 +1,7 @@
---
# sms-wa-bridge 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.
# ---------------------------------------------------------------------------
# The data tree, and the two bind-mounted files
# ---------------------------------------------------------------------------
# The order matters here, and it is not housekeeping: all four mount sources in
# the unit have to exist before it starts. Podman creates a missing bind-mount
# source as a *directory*, so a missing bridge.log would come up as a directory
# where the app expects a file and every append to it would fail, and a missing
# rules.json would break the re-read that happens on every request.
#
# The copy in readme.md makes the two directories itself, because it has to run
# *before* this play. These tasks are what make a fresh install and a migrated
# one the same shape, and what guarantees the file sources exist on the day the
# unit is first started.
# All four mount sources must exist before the unit starts.
# Podman creates a missing bind-mount source as a directory, which breaks the app.
- name: Ensure the sms-wa-bridge data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -34,16 +18,12 @@
group: "{{ podman_user }}"
mode: '0755'
loop:
# The paired WhatsApp session: the Chromium profile whatsapp-web.js logged
# in as. 336 MB on monoco, and not reproducible without scanning a QR code.
# The paired session cannot be recreated without scanning a QR code.
- wwebjs_auth
# Empty on monoco and unused by the current whatsapp-web.js, but mounted by
# the Docker-era container, so the migrated tree keeps its shape.
# Empty and unused, kept so the tree matches the Docker-era layout.
- wwebjs_session
# The app's own log file, bind-mounted as a *file*. state=touch with preserve is
# what makes this idempotent: without the two preserve flags every run would
# bump the mtime of a log the app is appending to and report itself changed.
# preserve stops every run from bumping the log's mtime and reporting a change.
- name: Ensure the log file the app appends to exists
ansible.builtin.file:
path: "{{ host_directory }}/bridge.log"
@@ -54,13 +34,7 @@
modification_time: preserve
access_time: preserve
# Seeded from the image's own rules.json, and then never touched again: the
# app re-reads this file from disk on every request, so live edits are the
# normal way to work and an overwriting play would silently revert them.
# force=no means "only transfer when the destination does not exist".
#
# On a migration this task finds the file the copy in readme.md put there and
# leaves it alone — which is the point.
# The app re-reads rules.json on every request, so never overwrite live edits.
- name: Seed the routing rules if there are none yet
ansible.builtin.copy:
src: "{{ playbook_dir }}/files/build/rules.json"
@@ -70,24 +44,8 @@
mode: '0644'
force: false
# The one failure the migration is most likely to arrive with, checked before
# anything expensive happens — and before the unit starts, because it cannot
# start usefully.
#
# With keep-id:uid=0 the app is container-root, which is {{ podman_user }} on the
# host. Host uid 0 is *not* mapped into the user namespace at all, so a
# root-owned file copied straight out of /var/lib/sms-wa-bridge (where everything
# is root-owned) is unwritable from inside the container: the app starts, then
# fails to write the session or append to the log. `tar --no-same-owner` in
# readme.md is what avoids it.
#
# Deliberately not `failed_when: false`: if `find` cannot read something in the
# tree, the tree is not provably clean, and that is the finding.
#
# Skipped under --check rather than run with `check_mode: false`: the directory
# above was not actually created in a check run, so `find` would fail on a
# missing path rather than answer the question. The assert below is written to
# tolerate that, and `default([])` is why.
# Container root is {{ podman_user }}, so a root-owned file copied from monoco is unwritable.
# Skip the find under --check because the directory above was never created.
- name: List anything in the data tree not owned by {{ podman_user }}
ansible.builtin.command: >-
find {{ host_directory }} ! -user {{ podman_user }}
@@ -107,12 +65,8 @@
`chown -R {{ podman_user }}:{{ podman_user }} {{ host_directory }}` if the
copy was otherwise good. See readme.md, "Migrating the data from monoco".
# ---------------------------------------------------------------------------
# The environment file (AUTH_SECRET)
# ---------------------------------------------------------------------------
# Consumed by podman through EnvironmentFile= in the Quadlet unit, not by
# Ansible, so it has to be written before the unit starts. 0600 because the unit
# that references it is world-readable and this is a shared secret.
# Podman reads this through EnvironmentFile=, so it must be written before the unit starts.
# 0600 keeps the shared secret out of the world-readable unit.
- name: Write the environment file
ansible.builtin.template:
src: sms-wa-bridge.env.j2
@@ -122,18 +76,8 @@
mode: '0600'
notify: Restart sms-wa-bridge
# ---------------------------------------------------------------------------
# The image
# ---------------------------------------------------------------------------
# The image exists in no registry, so it is either loaded from monoco
# (`docker save | podman load`, the recommended route — see readme.md) or built
# here from the vendored context. This looks for it under both names, re-tags a
# loaded one, and only then falls back to building.
#
# `check_mode: false` on the two `podman image exists` probes below is what makes
# `--check` an honest preview: a command task is normally skipped under --check,
# and a skipped probe would turn the build decision into a guess. They only read
# the host, so running them in a check run changes nothing.
# The image exists in no registry, so load it from monoco or build it here.
# Run the probes in check mode so --check reports the real build decision.
- name: Check for the locally built image
ansible.builtin.command: "podman image exists {{ image }}"
register: sms_image_local
@@ -141,15 +85,8 @@
failed_when: false
check_mode: false
# `docker save` writes an unqualified RepoTags and podman load qualifies it
# against the default registry, so an image loaded from monoco lands as
# docker.io/library/sms-wa-bridge:latest rather than the localhost/... name the
# unit uses. Verified on emily with a real docker-archive round trip.
#
# Re-tagging rather than rebuilding is the point: the loaded image is the exact
# one whose WhatsApp session was paired. A rebuild re-resolves whatsapp-web.js
# (^1.26.0 in package.json), and a differently-versioned client can refuse that
# session and force a new QR scan.
# podman load qualifies a docker-saved tag against docker.io, not localhost.
# Re-tag rather than rebuild: a rebuild can break the paired WhatsApp session.
- name: Re-tag an image that was loaded from monoco
when: (sms_image_local.rc | default(1)) != 0
block:
@@ -166,10 +103,6 @@
changed_when: true
notify: Restart sms-wa-bridge
# One decision, made once, so the build context is only materialised on the host
# when something is actually going to read it. Set with `podman tag`/`podman
# build` staying check-mode aware: under --check they are skipped, while the two
# probes above really ran, so this reflects the host as it is.
- name: Decide whether the image has to be built
ansible.builtin.set_fact:
sms_build_image: >-
@@ -177,15 +110,11 @@
and (sms_image_docker_hub.rc | default(1)) != 0)
or (rebuild_image | bool) }}
# Only reached when no image is present under either name, or when the operator
# asked for a rebuild. `podman build` needs buildah, which `podman` Recommends on
# Ubuntu and caddy/roles/podman therefore installed.
# Only runs when no image exists or rebuild_image is set.
- name: Build the image from the vendored Dockerfile
when: sms_build_image | bool
block:
# The context is copied rather than built from the repository working copy,
# so the host always builds from a fixed, tanshu-owned path — and so the
# files the running image came from are visible next to their data.
# Copy the context so the host builds from a fixed path next to its data.
- name: Ensure the image build context directory exists
ansible.builtin.file:
path: "{{ build_dir }}"
@@ -208,9 +137,6 @@
changed_when: true
notify: Restart sms-wa-bridge
# ---------------------------------------------------------------------------
# The unit
# ---------------------------------------------------------------------------
- name: Install the sms-wa-bridge Quadlet unit
ansible.builtin.template:
src: sms-wa-bridge.container.j2
@@ -222,11 +148,7 @@
- Reload systemd user units
- Restart sms-wa-bridge
# Everything from here on inspects a *running* container, and none of it is
# meaningful under --check: the unit was never written, so systemd cannot find
# it. Guarding the runtime half rather than tolerating its failures is what makes
# `ansible-playbook --check` a usable preview of a deploy — it reports the files,
# templates and image decisions without touching the host.
# The runtime checks need a real unit, so skip them under --check.
- name: Start sms-wa-bridge
when: not ansible_check_mode
block:
@@ -237,9 +159,7 @@
state: started
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a missing network unit, an
# unwritable /app, an image that is not there). Pull the real reason out.
# systemd reports only a generic exit error, so collect the real reason.
- name: Collect sms-wa-bridge diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -258,8 +178,7 @@
ansible.builtin.fail:
msg: "sms-wa-bridge 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.
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
- name: Verify sms-wa-bridge is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: sms_active
@@ -267,10 +186,8 @@
failed_when: sms_active.stdout != 'active'
when: not ansible_check_mode
# `is-active` says the container is running; it does not say the app is serving.
# `Restart=always` means even a crash-looping container is reported active, and
# the image ships no HEALTHCHECK, so the unit defines one (see the template) and
# this runs exactly that command once, synchronously.
# is-active still reports active for a crash-looping container, and the image ships no HEALTHCHECK.
# The unit defines one instead, and this runs exactly that command once.
- name: Wait for sms-wa-bridge to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: sms_health
@@ -280,14 +197,8 @@
changed_when: false
when: not ansible_check_mode
# ---------------------------------------------------------------------------
# What a running container does not tell you
# ---------------------------------------------------------------------------
# The unit's health check proves the HTTP server is up. It does not prove
# WhatsApp is linked, and an unlinked bridge is the one failure here that is
# completely silent: the process is healthy, every SMS is accepted with a 200,
# and nothing is ever delivered. /health publishes exactly that as waReady, so
# read it back and say so out loud.
# The health check proves HTTP is up, not that WhatsApp is linked.
# An unlinked bridge accepts every SMS with 200 and delivers nothing.
- name: Read the app's own health payload
ansible.builtin.command: >-
podman exec {{ container_name }} wget -q -O -
@@ -302,10 +213,7 @@
sms_health_json: "{{ sms_health_payload.stdout | from_json }}"
when: (sms_health_payload.rc | default(1)) == 0
# Reported, not asserted: an unpaired bridge is a legitimate state — a fresh
# install sits here until someone scans the QR code — and the deployment is
# still correct. Two tasks rather than one message with a conditional inside it,
# because the interesting case deserves its own sentence.
# An unpaired bridge is a valid fresh-install state, so report it without failing.
- name: Report what the bridge says about itself
ansible.builtin.debug:
msg: >-
@@ -337,11 +245,7 @@
- sms_health_json is defined
- sms_health_json.waReady | default(false)
# The data half of the same question, asked from the host rather than from the
# app: wwebjs_auth/session is the Chromium profile the linked device lives in, and
# it is what the copy in readme.md puts there. Missing means the bridge will
# print a fresh QR code — correct on a brand-new host, and the sign that the
# copy has not run yet on a migration.
# Missing wwebjs_auth/session means the bridge will print a fresh QR code.
- name: Check whether a linked WhatsApp session was copied in
ansible.builtin.stat:
path: "{{ host_directory }}/wwebjs_auth/session"
+12 -76
View File
@@ -1,112 +1,48 @@
---
# ---------------------------------------------------------------------------
# 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: the bridge
# 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).
# Derived from the connection user; requires gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# sms-wa-bridge
# ---------------------------------------------------------------------------
host: bridge.tanshu.dev
container_name: sms-wa-bridge
unit_name: "{{ container_name }}.service"
# The Quadlet file is <network_name>.network, and caddy/roles/register pins the
# Podman network name to <network_name>_net — so: sms-wa-bridge.network,
# sms-wa-bridge_net, which is the name the Docker-era deployment used.
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
network_name: "{{ container_name }}"
# There is no registry: the image was built locally on monoco with
# `docker build -t sms-wa-bridge` and exists nowhere else. Two ways to get it
# onto emily, and the playbook supports both:
#
# * `docker save sms-wa-bridge:latest | podman load` — the recommended route,
# and the one readme.md documents. It carries over the *exact* image whose
# WhatsApp session was paired; a rebuild re-resolves whatsapp-web.js
# (^1.26.0 in package.json), and a differently-versioned client can refuse
# that session and force a new QR scan.
# * `podman build` from the vendored context in files/build/ — what the play
# does by itself when no image is present, so a host with no monoco still
# gets a working deployment.
#
# Fully qualified either way: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when a
# systemd unit starts. The unit also sets Pull=never, so an image that is not
# there fails loudly instead of silently becoming a Docker Hub lookup.
# The image exists in no registry; load it from monoco or build it here.
# A rebuild can re-resolve whatsapp-web.js and break the paired session, so prefer podman load.
# Keep it fully qualified so rootless Podman never prompts; there is no TTY.
image: localhost/sms-wa-bridge:latest
# What `podman load` actually calls a docker-saved unqualified tag: it qualifies
# it against the default registry instead of `localhost`. Verified on emily with
# a real `docker save` round trip. The play re-tags this name to `image` above,
# so loading the image before the play is enough — see readme.md.
# What podman load calls a docker-saved tag; the play re-tags it to image.
image_docker_hub: docker.io/library/sms-wa-bridge:latest
# `podman build` is skipped when the image is already present. Set this to true
# to rebuild from files/build/ anyway, e.g. after editing server.js:
#
# ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml -e rebuild_image=true
# Set true to rebuild from files/build/ after editing server.js.
rebuild_image: false
# The whole of this service's state, and a faithful analogue of monoco's
# /var/lib/sms-wa-bridge — same four entries, same layout, so the tar in
# readme.md lands as-is:
#
# wwebjs_auth/ the paired WhatsApp session (336 MB of the 336 MB tree)
# wwebjs_session/ empty, and vestigial; carried over to keep the shape
# rules.json routing rules, re-read from disk on every request
# bridge.log the app's own log file
# Same four entries and layout as monoco's /var/lib/sms-wa-bridge, so the tar lands as-is.
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image build context, kept out of host_directory so the data tree stays a
# faithful copy of the old one. Only used when the play builds the image.
build_dir: "{{ podman_data_dir }}/{{ container_name }}-build"
# The port server.js listens on inside the container (PORT's default). Caddy
# proxies to it; nothing is published on the host, exactly as on monoco.
container_port: 8080
# The shared secret MacroDroid sends as `X-Auth`, carried over verbatim from the
# Docker-era container so the cutover is a URL change and nothing else. It is
# committed in plaintext because it always has been (the old app readme had it,
# and the Docker-era container took it as an -e flag); the unit keeps it out of
# its own world-readable file via EnvironmentFile=.
# Kept verbatim so the cutover only changes the URL.
auth_secret: "64f205031acc01744b6edc23"
# AUTH_SECRET lives here rather than as an Environment= line in the unit: that
# file is world-readable in ~/.config. Read by podman (--env-file), not by
# Ansible, so it has to be written before the unit starts.
# Read by podman through EnvironmentFile=, not by Ansible, so write it before the unit starts.
env_file: "{{ host_directory }}/{{ container_name }}.env"
# The image ships no HEALTHCHECK, so the unit defines one and the play gates on
# it. /health is the app's own endpoint and `wget` is what the runtime image has
# — the Dockerfile installs wget, not curl. It answers 200 whenever the HTTP
# server is up, so this proves the server is serving; it does *not* prove
# WhatsApp is paired. That is what the /health *payload* shows, and the play
# reports it.
# The runtime image has wget, not curl.
# /health answers 200 whenever the server is up, not when WhatsApp is paired.
health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/health"
# ---------------------------------------------------------------------------
# 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"
# The same two lines monoco's Caddyfile has had since the bridge was deployed
# (`bridge.tanshu.dev -> sms-wa-bridge:8080`), so nothing about the public
# contract changes but the host it runs on.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}
+2 -38
View File
@@ -1,28 +1,5 @@
---
# Tandoor Recipes as a rootless Podman Quadlet unit, behind the shared Caddy
# unit (see caddy/playbook.yml) and the shared Postgres unit
# (postgres/playbook.yml).
#
# 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 tandoor/playbook.yml
# Requires caddy and postgres to have run first.
- name: Deploy Tandoor as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -30,24 +7,11 @@
- 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, and the Postgres
# attach below needs the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Tandoor's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# Attaches this app's network to the shared Postgres unit, which is what
# makes POSTGRES_HOST=db resolve, and owns the "Restart Postgres" handler
# that postgres/roles/attach notifies. Runs first so the database is
# reachable before the app starts.
- postgres
# The app: data directories, .env, the Quadlet unit, start + verify.
- tandoor
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -17
View File
@@ -1,27 +1,14 @@
---
# 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.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- 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.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-11
View File
@@ -1,15 +1,4 @@
---
# 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 }}"
+1 -9
View File
@@ -1,13 +1,5 @@
---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, so `systemctl --user`
# works without setting XDG_RUNTIME_DIR.
#
# Required by postgres/roles/attach, which notifies it whenever this app's
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
# recreated to join the network — Quadlet has no live equivalent of
# `podman network connect`. That is a few seconds of downtime for every attached
# app, not just this one. See postgres/readme.md, "The trade-off".
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
+1 -20
View File
@@ -1,26 +1,7 @@
---
# Attach this app's network to the shared Postgres unit.
#
# Postgres knows nothing about its clients, exactly like Caddy: a service joins
# by dropping 50-tandoor.conf and the shared 10-network-aliases.conf next to
# postgres.container. The canonical way to do that is the shared include below —
# it writes the same bytes every service writes, asserts that tandoor.network
# already exists, and notifies the "Restart Postgres" handler defined in this
# role.
#
# This runs in the roles phase, which is after the register include in
# playbook.yml's pre_tasks has created tandoor.network.
- name: Attach Tandoor to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# On a first attach both drop-ins are new, so Postgres has to be recreated to
# join tandoor_net — and that restart is a *handler*, which by default runs at
# the end of the play, after the tandoor role has already started the app. The
# app would then come up before `db` resolves, fail its own is-active check, and
# fail the play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-ins are unchanged, nothing is pending, and this is a no-op.
# The restart that joins Postgres to tandoor_net is a handler, so flush it before the app starts or its is-active check fails.
- name: Apply the Postgres network change before starting Tandoor
ansible.builtin.meta: flush_handlers
+7 -28
View File
@@ -1,10 +1,4 @@
---
# Tandoor Recipes 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 Tandoor data directories exist
ansible.builtin.file:
path: "{{ host_directory }}/{{ item }}"
@@ -13,16 +7,12 @@
group: "{{ podman_user }}"
mode: '0755'
loop:
# mediafiles is the irreplaceable one; the other two are regenerated by the
# app and are bind-mounted anyway because the image expects them to exist.
# mediafiles is irreplaceable. The other two are regenerated and bind-mounted because the image expects them.
- mediafiles
- staticfiles
- externalfiles
# The app's secrets and database coordinates are consumed by systemd
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
# written before the unit starts. Mode 0600: it holds SECRET_KEY and the
# postgres password.
# systemd reads it through EnvironmentFile= before the unit starts, and it holds SECRET_KEY and the Postgres password.
- name: Write the environment file
ansible.builtin.template:
src: tandoor.env.j2
@@ -52,9 +42,7 @@
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
# unreachable database, a bad volume). Pull the real reason out.
# systemd only reports "control process exited with error code", so pull the real reason.
- name: Collect Tandoor diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -73,16 +61,14 @@
ansible.builtin.fail:
msg: "Tandoor 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.
# Quadlet honours [Install] WantedBy=, so `systemctl --user enable` is not needed on a generated unit.
- name: Verify Tandoor is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: tandoor_active
changed_when: false
failed_when: tandoor_active.stdout != 'active'
# `db` is the alias the environment file actually asks for; the container name
# is checked too because it is what the Quadlet network gives us for free.
# The environment file asks for `db`; the container name is checked too because the network provides it.
- name: Confirm Tandoor can resolve its database
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ item }}
@@ -100,15 +86,8 @@
{{ item.stdout | default('(no answer)') }}
loop: "{{ tandoor_db_lookup.results }}"
# `is-active` says the container is running; it does not say the app can serve.
# Tandoor's entrypoint runs Django migrations before gunicorn answers, and until
# it does the image's own nginx returns 502 to Caddy — which Caddy relays as-is,
# complete with `server: nginx`. That is exactly the confusing "nginx 502" a
# first deploy shows after a play that reported success.
#
# python3 is used rather than curl or wget because this is a Django image: the
# interpreter is guaranteed present, the others are not. A 502 raises HTTPError,
# so the retry loop holds until a real page comes back.
# `is-active` only says the container is running. Tandoor migrates before gunicorn answers, and its nginx returns 502 until then.
# The probe uses python3 because the Django image guarantees it and may not ship curl or wget.
- name: Wait for Tandoor to serve HTTP
ansible.builtin.command: >-
podman exec {{ container_name }}
+5 -26
View File
@@ -1,50 +1,29 @@
---
# ---------------------------------------------------------------------------
# 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: Tandoor listens on
# 80 inside the container and 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).
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
# podman_user comes from the connection user, so the play needs gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Tandoor
# ---------------------------------------------------------------------------
host: recipes.tanshu.com
container_name: tandoor
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
image: docker.io/vabene1111/recipes:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: tandoor.network, tandoor_net).
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is tandoor.network.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# Port Caddy proxies to, inside the container (the image's own nginx).
# The port Caddy proxies to inside the container, where the image's own nginx listens.
container_port: 80
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
# handler that applies them.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# 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"
+2 -38
View File
@@ -1,33 +1,5 @@
---
# TriliumNext Notes 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 (document.db, with its WAL sidecars) plus config.ini,
# session_secret.txt and an app-managed backup/ directory, all in a single
# directory — 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 trilium/playbook.yml
# Requires caddy to have run first. State is one SQLite file, not Postgres.
- name: Deploy TriliumNext Notes as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -35,18 +7,10 @@
- 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.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Trilium's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# The app: the data directory, the Quadlet unit, start + verify.
- trilium
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -17
View File
@@ -1,27 +1,14 @@
---
# 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.
# A reload keeps existing connections. A restart would briefly drop every hosted site.
# The config path is inside the container, not caddyfile_path on the host.
- 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.
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
# Defined after the reload so the cheap reload runs first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-11
View File
@@ -1,15 +1,4 @@
---
# 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 }}"
+12 -63
View File
@@ -1,13 +1,5 @@
---
# TriliumNext Notes 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.
# The image's data directory is one tree: document.db with its WAL sidecars,
# config.ini, session_secret.txt, and the three subdirectories below. It is
# created here as the copy target as much as for a fresh install.
# The image keeps document.db, config.ini, session_secret.txt and the subdirectories below in this one tree.
- name: Ensure the Trilium data directory exists
ansible.builtin.file:
path: "{{ data_dir }}"
@@ -16,12 +8,8 @@
group: "{{ podman_user }}"
mode: '0755'
# Trilium creates these itself on first start, at 0700 — which is exactly how
# they are on monoco. Creating them here means a fresh host and a migrated one
# look the same, and that the destinations named in readme.md exist before the
# copy. log/ is where the app writes its daily log — which the play reads back
# later as evidence that the container is writing to the host — and backup/ is
# where its own zipped document.db backups land.
# Trilium creates these at 0700 on first start. Creating them here makes a fresh host and a migrated one look the same.
# log/ holds the daily log the play reads back as evidence, and backup/ holds the app's own zipped backups.
- name: Ensure the app-managed subdirectories exist
ansible.builtin.file:
path: "{{ item }}"
@@ -34,11 +22,7 @@
- "{{ tmp_dir }}"
- "{{ backup_dir }}"
# Checked before the unit starts, because this is the one thing the play cannot
# tell after the fact: with no document.db the app serves a brand new, empty
# document, which is right for a fresh install and wrong for a migration whose
# copy has not run yet. Once the app has started it creates the file itself and
# the evidence is gone.
# Check before the unit starts, because an empty document it creates hides whether the copy ever landed.
- name: Check whether a document has been copied in
ansible.builtin.stat:
path: "{{ data_dir }}/document.db"
@@ -64,15 +48,7 @@
- Reload systemd user units
- Restart Trilium
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
#
# All three steps are in one block because they fail the same way and want the
# same evidence. `systemctl start` reports success as soon as it has handed the
# unit to systemd, so a container that dies a second later — `User=0` missing, a
# network that was never attached, an unwritable data directory — surfaces here
# as `activating`, or as a health check that never passes, with the real reason
# only in the journal. Restart=always makes that a crash loop, not an error.
# Quadlet honours [Install] WantedBy=, so the generated unit is never enabled.
- name: Start and verify Trilium
block:
- name: Start Trilium
@@ -82,9 +58,7 @@
state: started
daemon_reload: true
# Retried rather than checked once: the container is coming up, and podman
# reports `activating` for the interval between systemd starting it and the
# container's own readiness notification.
# Retried because podman reports `activating` until the container signals readiness.
- name: Verify Trilium is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: trilium_active
@@ -93,11 +67,7 @@
delay: 5
changed_when: false
# `is-active` says the container is running; it does not say the app can
# serve. The unit defines the probe (HealthCmd=, see the template) because
# the image ships none, so this runs exactly that command once,
# synchronously, and fails while the app is still migrating or not yet
# listening.
# The image ships no health check, so the unit defines one and this runs it synchronously.
- name: Wait for Trilium to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: trilium_health
@@ -106,10 +76,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing
# else, which is useless for the usual failures. The journal is where the
# entrypoint's own output is — that is where "su: Authentication failure"
# or a failed bind mount shows up.
# systemd only reports "control process exited with error code", so read the journal for the real reason.
- name: Collect Trilium diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -128,19 +95,8 @@
ansible.builtin.fail:
msg: "Trilium did not start — see the diagnostics above."
# The check that catches a missing or mistyped Volume= line, which is the one
# failure here that is silent: the app would run perfectly well with document.db
# in the container's writable layer — or in an anonymous volume, if the image
# declares one — and lose it on the next recreate.
#
# It has to be a mount check and not a file check. Audiobookshelf can assert that
# its database appeared on the host, because the app creates it; here the
# migration puts document.db on the host *before* the app ever runs, so its
# presence proves nothing about whether the container can see it.
#
# Both halves matter: the destination alone would still be satisfied by an
# anonymous volume mounted where the bind mount should be, which is exactly the
# silent case. `Source` is the path Quadlet passed to podman, verbatim.
# A missing or mistyped Volume= line would run fine and lose document.db on the next recreate, so check the mount.
# Both Source and Destination must match, since a destination-only check would pass an anonymous volume.
- name: Read the running container's configuration back
ansible.builtin.command: "podman inspect {{ container_name }}"
register: trilium_inspect
@@ -162,12 +118,7 @@
check the Volume= line in the generated unit
({{ quadlet_dir }}/{{ container_name }}.container).
# Evidence rather than a gate: with keep-id:uid=1000 mapped onto
# {{ podman_user }}, what the app writes lands in {{ data_dir }} owned by
# {{ podman_user }} — and the app writes a daily log the moment it starts. A log
# touched in the last few minutes is that, visible from the host. It is reported
# rather than asserted because a slow start can legitimately leave nothing here
# yet; the mount assertion above is the hard check.
# Evidence, not a gate: a log touched in the last five minutes shows the app writing into the host data directory, while a slow start may leave none.
- name: Look for a log written since the unit started
ansible.builtin.find:
paths: "{{ log_dir }}"
@@ -190,9 +141,7 @@
path: "{{ data_dir }}/document.db"
register: trilium_document_db_after
# A migrated document.db is a few megabytes; the empty one the app creates on a
# fresh install is kilobytes. Worth printing, because it is the difference
# between "the notes came across" and "the copy never ran".
# A migrated document.db is megabytes; the empty one from a fresh install is kilobytes.
- name: Report the document on the host
ansible.builtin.debug:
msg: >-
+12 -63
View File
@@ -1,94 +1,43 @@
---
# ---------------------------------------------------------------------------
# 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: Trilium 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).
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
# podman_user comes from the connection user, so the play needs gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# TriliumNext Notes
# ---------------------------------------------------------------------------
# The site is unchanged from the Docker era. The *service* name is not: this
# directory and the Docker-era playbook call it `trilium`, while the live
# container on monoco — which nobody deployed from here — is `trillium`, with
# the extra `l`. Everything on emily is `trilium`, following the app's real name
# and this playbook's own history; the only place the misspelling survives is
# the source path /var/lib/trillium in the copy commands in readme.md.
host: notes.tanshu.com
container_name: trilium
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts.
#
# This is the *TriliumNext* image, not the retired `zadam/trilium` the old
# playbook pulled: monoco has been running TriliumNext since June 2025, and its
# document.db carries the schema migrations that came with it. Pulling the old
# image against that database would not work.
#
# `latest` has floated, and by more than a patch: monoco runs v0.95.0 (built
# 2025-06-15) and the pull on emily is v0.106.0 (2026-09-25). TriliumNext
# migrates document.db forward on first start, so the cutover is also an app
# upgrade -- one-way for the copy on emily, which is why the readme says to
# compare digests before the first start and to leave monoco's own tree alone.
# Pin a digest or a version tag here if the migration should move the runtime
# only and leave the app version to a separate, deliberate step.
# This is the TriliumNext image, not the retired zadam/trilium. Its document.db carries migrations the old image cannot run.
# `latest` has floated well beyond monoco's version, so pin a digest or tag if the runtime should not move.
image: docker.io/triliumnext/notes:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: trilium.network,
# trilium_net).
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is trilium.network.
network_name: "{{ container_name }}"
# The image's data directory, and the whole of this service's state. Unlike
# audiobookshelf/ there is one mount target rather than four, because Trilium
# keeps document.db, config.ini and its own subdirectories together.
# The image's data directory and the whole of this service's state, all in one tree.
data_dir: "{{ podman_data_dir }}/{{ container_name }}"
container_data_dir: /home/node/trilium-data
# Subdirectories the app creates and owns itself, at mode 0700 — that is exactly
# what they are on monoco. Created by the role so a fresh host and a migrated one
# look the same, and so the copy target in readme.md exists:
#
# log/ one file per day, 91 MB of the 106 MB tree on monoco. Regenerable.
# tmp/ scratch, empty at rest.
# backup/ Trilium's own zipped document.db backups, 12 MB. Worth keeping.
# The app creates these at 0700 on monoco. Creating them here makes a fresh host and a migrated one look the same.
# log/ is regenerable, tmp/ is scratch, and backup/ holds the app's own zipped backups worth keeping.
log_dir: "{{ data_dir }}/log"
tmp_dir: "{{ data_dir }}/tmp"
backup_dir: "{{ data_dir }}/backup"
# The port the app listens on inside the container, from config.ini's
# `port = 8080`, which the Docker-era container used unchanged. Caddy proxies to
# it; nothing is published on the host.
# The port from config.ini. Caddy proxies to it, and nothing is published on the host.
container_port: 8080
# The unit's own HEALTHCHECK, because the image does not ship one: v0.106.0
# dropped the docker_healthcheck.cjs that v0.95.0 had, so `podman healthcheck
# run` otherwise fails with "no defined healthcheck". /api/health-check is the
# endpoint the app advertises itself, writing it to `healthcheck-url` in the data
# directory at startup. `curl` is in the image; `wget` is not.
# The image ships no health check, so the unit defines one from the endpoint the app advertises.
# `curl` is in the image; `wget` is not.
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/health-check"
# 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"
# Caddy handles the WebSocket upgrade Trilium's live-updating editor needs, and
# imposes no request-body limit, so there is nothing to carry over from the old
# nginx front end.
# Caddy handles the WebSocket upgrade the live editor needs and imposes no request body limit.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}
+1 -23
View File
@@ -1,25 +1,5 @@
---
# Deploys Valkey as a rootless Podman Quadlet unit.
#
# 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 those tasks need are duplicated at the top of vars/default.yml,
# so this play stands on its own.
#
# Run with: ansible-playbook -u tanshu valkey/playbook.yml
# Run this before the app playbooks. Each service attaches itself.
- name: Deploy Valkey as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -27,6 +7,4 @@
- vars/default.yml
roles:
# The base Valkey unit. It does NOT know which app networks it serves.
# Each service attaches itself via valkey/roles/attach.
- valkey
+6 -46
View File
@@ -1,45 +1,12 @@
---
# Attach a service's network to Valkey.
#
# Valkey is the *base* unit and knows nothing about its clients, the same way
# Caddy knows nothing about its upstreams. A service that needs a cache adds
# itself by dropping files next to valkey.container, and Quadlet merges them in.
#
# Include this from a service playbook:
#
# - name: Attach this service to Valkey
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
# vars:
# network_name: "{{ network_name }}"
#
# The including play must:
#
# 1. have created {{ network_name }}.network already — caddy/roles/register
# does that, and every service here that needs a cache is also behind
# Caddy, so its register include comes first;
# 2. run the `podman` role (or include its task file and define the same four
# vars), for podman_user and quadlet_dir;
# 3. define a "Restart Valkey" handler — see
# roles/valkey/handlers/main.yaml for the exact snippet; and
# 4. flush handlers before starting the app, on a first attach. This include
# notifies that restart, so by default it runs at the end of the play —
# after the app has already started and failed to reach `redis`. A
# `meta: flush_handlers` immediately after this include is enough; see
# nextcloud/roles/valkey for the working example.
#
# It runs as the connection user, which must be the rootless podman_user.
#
# A service may instead write these two files from its own role; the bytes have
# to match this file exactly, or the two will overwrite each other on every run.
- name: "Check that the {{ network_name }} network unit exists"
ansible.builtin.stat:
path: "{{ quadlet_dir }}/{{ network_name }}.network"
register: valkey_attach_network
# A drop-in naming a .network file that does not exist makes Quadlet fail to
# generate the whole valkey unit, with a message that does not mention the
# drop-in at all. Fail here instead, where the cause is obvious.
# A drop-in naming a missing network breaks unit generation with an unrelated
# error, so fail here instead.
- name: "Fail if {{ network_name }} has no network unit"
ansible.builtin.assert:
that: valkey_attach_network.stat.exists
@@ -64,21 +31,14 @@
group: "{{ podman_user }}"
mode: '0644'
content: |
# Added by the {{ network_name }} playbook. Do not edit by hand.
#
# Deleting this file removes the network from {{ valkey_container | default('valkey') }} on
# the next daemon-reload; nothing else changes.
# Added by {{ network_name }}. Deleting it detaches the network from
# {{ valkey_container | default('valkey') }} on the next daemon-reload.
[Container]
Network={{ network_name }}.network
notify: Restart Valkey
# NetworkAlias= applies to every network the container joins, which is exactly
# right for a hub: this repo's cache clients reach Valkey as `redis` (nextcloud,
# which writes REDIS_HOST=redis into its config, plus outline and overleaf),
# while the rest use the container name. It is a fixed value in one shared file,
# not a per-service option, so the same bytes are written no matter how many
# services attach — two services writing their own aliases would make Quadlet
# pass --network-alias twice.
# NetworkAlias applies to every network, so one shared file holds the fixed
# alias. Two services writing their own would pass --network-alias twice.
- name: "Give {{ valkey_container | default('valkey') }} the shared 'redis' alias"
ansible.builtin.copy:
dest: >-
+2 -27
View File
@@ -1,12 +1,12 @@
---
# 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
# A new drop-in needs a recreate, which keeps data through the final RDB but
# takes Nextcloud sessions and Yamtrack's Celery queue offline for seconds.
- name: Restart Valkey
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
@@ -14,28 +14,3 @@
state: restarted
daemon_reload: true
listen: Restart Valkey
# Reference implementation for service playbooks. A service that attaches its
# network with valkey/roles/attach must notify "Restart Valkey", and defines its
# own copy of this handler, e.g.:
#
# - name: Restart Valkey
# ansible.builtin.systemd_service:
# name: valkey.service
# scope: user
# state: restarted
# daemon_reload: true
# listen: Restart Valkey
#
# A new drop-in changes the generated unit, so Valkey has to be recreated to join
# the network — Quadlet has no live equivalent of `podman network connect`.
#
# The cost is lighter than Postgres's in one way and heavier in another. Lighter:
# no app is left without its data. RDB persistence is on by default, the recreate
# is a graceful stop, and valkey-server writes a final `dump.rdb` on the way down
# that the new container loads back from the bind mount, so keys survive. Heavier
# to reason about: two clients do not treat this as a pure cache — Nextcloud keeps
# its PHP sessions and `memcache.locking` here, and Yamtrack uses it as its Celery
# broker — so the seconds the container is down are seconds in which logins and
# queued tasks are unavailable. Only a hard kill or a power loss costs changes
# since the last save point. See readme.md, "The trade-off".
+8 -29
View File
@@ -1,19 +1,7 @@
---
# Start Valkey
#
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: SSH gave us a real PAM session, so systemd's user manager is
# reachable via the standard environment.
# Created here rather than left to the container's entrypoint. That entrypoint
# runs as container-root, which under keep-id is a subuid on the host, so a
# directory it created would be owned by e.g. 165536 and the host user could not
# `rm -rf` it without `podman unshare`. Creating it up front keeps everything in
# the data root owned by {{ podman_user }}.
#
# Mode 0755 rather than Postgres's 0700: Valkey's entrypoint runs `find` in this
# directory as root before dropping to uid {{ app_uid }}, and nothing sensitive
# lives here — it is a cache.
# The entrypoint runs as a subuid under keep-id, so create the data directory
# here to keep it owned by podman_user rather than 165536.
- name: Ensure the Valkey data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -41,11 +29,8 @@
- Reload systemd user units
- Restart Valkey
# The wait is part of the block on purpose. `Restart=always` means systemd
# reports the unit active even while the container crash-loops — a wrong keep-id
# shows up as an entrypoint permission error over and over — so is-active alone
# would call a broken cache healthy, and every app that uses it would fail later
# with a DNS or connection error that points nowhere near the cause.
# `Restart=always` reports the unit active while the container crash-loops, so
# wait for a real PING instead of trusting is-active.
- name: Start Valkey and wait for it to answer
block:
- name: Start Valkey
@@ -55,8 +40,7 @@
state: started
daemon_reload: true
# valkey-cli is the server's own client and is guaranteed present, the same
# way pg_isready is in the Postgres image.
# valkey-cli ships in the image, so it is always available for the check.
- name: Wait for Valkey to answer PING
ansible.builtin.command: "podman exec {{ container_name }} valkey-cli ping"
register: valkey_ping
@@ -65,9 +49,7 @@
delay: 5
changed_when: false
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a bad uid mapping, an unwritable
# data directory, a missing image). Pull the real reason out.
# systemd only reports a generic exit code, so collect the real reason.
- name: Collect Valkey diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -85,8 +67,7 @@
ansible.builtin.fail:
msg: "Valkey did not come up — 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.
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
- name: Verify the Valkey unit is active
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: valkey_active
@@ -116,9 +97,7 @@
{{ valkey_dropins.files | map(attribute='path') | map('basename') | join(', ')
| default('(nothing yet)', true) }}.
# Say this out loud rather than let it be discovered by a failing app: with no
# drop-in, Valkey has no Network= at all, so it sits on the default Podman
# network and no app network can resolve it.
# With no drop-in there is no Network=, so it sits on the default Podman network.
- name: Note that no service has attached yet
ansible.builtin.debug:
msg: >-
+9 -38
View File
@@ -1,58 +1,29 @@
---
# ---------------------------------------------------------------------------
# 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: Valkey 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).
# A play cannot include caddy/vars/default.yml, which also sets host_directory,
# so keep these in sync with it. Set gather_facts: true for the facts below.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Valkey
# ---------------------------------------------------------------------------
container_name: valkey
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when the
# systemd unit starts. The Docker-era playbook said `valkey/valkey:alpine`.
# Fully qualified, because short names prompt for a registry and the unit has
# no TTY.
image: docker.io/valkey/valkey:alpine
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image's WORKDIR and VOLUME: the RDB snapshot and, if it is ever enabled,
# the AOF live here. The contents are disposable — nothing needs to be copied
# off monoco — but the mount is not: RDB persistence is on by default, and the
# graceful stop of a rebuild writes a final snapshot here that the next
# container loads. Keep it unless you also set `save ""`. See readme.md, "The
# trade-off".
# The image volume holds the RDB snapshot. A graceful stop writes a final one
# here, so keep the mount unless you also set `save ""`.
data_mount: /data
# The uid and gid of the image's `valkey` account. The uid is 999 — the one the
# anonymous Docker volume was owned by (docs/podman-compat-audit.md §3.2) — but
# the gid is *1000*, not 999: Alpine already has a gid 999, and the image's
# Dockerfile says so outright ("alpine already has a gid 999, so we'll use the
# next id") before dropping to the account with `setpriv --regid=valkey`.
# Mapping both ids onto podman_user is what keeps {{ host_directory }} — owner
# and group alike — with the connection user, instead of stranding the group on
# a subgid the moment valkey-server writes a snapshot.
# The image account is uid 999 and gid 1000, not 999: Alpine already has a gid
# 999. Mapping both keeps host_directory owned by podman_user.
app_uid: 999
app_gid: 1000
# No PublishPort, on purpose: the Docker-era playbook published 127.0.0.1:6379,
# and nothing on the host needs it. Clients reach Valkey over the app networks
# that attach themselves; {{ container_port }} is the port inside the container.
# No PublishPort: clients reach Valkey over the app networks, not the host.
container_port: 6379
# Quadlet drop-in directory for the Valkey unit. Services attach themselves
# here; see the "How services attach themselves" section of readme.md.
valkey_dropin_dir: "{{ quadlet_dir }}/{{ container_name }}.container.d"
+2 -39
View File
@@ -1,28 +1,5 @@
---
# Wiki.js as a rootless Podman Quadlet unit, behind the shared Caddy unit
# (caddy/playbook.yml) and with its data in the shared Postgres unit
# (postgres/playbook.yml).
#
# 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 wikijs/playbook.yml
# Requires caddy and postgres to have run first.
- name: Deploy Wiki.js as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -30,25 +7,11 @@
- 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, and the Postgres
# attach below needs the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Wiki.js's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# Attaches this app's network to the shared Postgres unit, which is what
# makes DB_HOST=db resolve, and owns the "Restart Postgres" handler that
# postgres/roles/attach notifies. Runs first so the database is reachable
# before the app starts.
- postgres
# The app: content and upload directories, the 0600 environment file, the
# Quadlet unit, start + verify.
- wikijs
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -16
View File
@@ -1,27 +1,15 @@
---
# 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.
# A reload keeps live connections; a restart would drop every hosted site.
# The path is inside the container, not the host {{ caddyfile_path }}.
- 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.
# Quadlet cannot attach a network live, so Caddy must be recreated.
# Defined after the reload so the cheap reload runs first when both are notified.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-10
View File
@@ -1,14 +1,4 @@
---
# 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:
+2 -9
View File
@@ -1,13 +1,6 @@
---
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
# unit there. This handler runs as the connection user, so `systemctl --user`
# works without setting XDG_RUNTIME_DIR.
#
# Required by postgres/roles/attach, which notifies it whenever this app's
# drop-in changes: a new drop-in changes the generated unit, so Postgres must be
# recreated to join the network — Quadlet has no live equivalent of
# `podman network connect`. That is a few seconds of downtime for every attached
# app, not just this one. See postgres/readme.md, "The trade-off".
# A new drop-in changes the generated unit, so Postgres must be recreated to join the network.
# That restarts every attached app, not just this one.
- name: Restart Postgres
ansible.builtin.systemd_service:
name: "{{ postgres_unit }}"
+4 -25
View File
@@ -1,32 +1,11 @@
---
# Attach this app's network to the shared Postgres unit.
#
# Postgres knows nothing about its clients, exactly like Caddy: a service joins
# by dropping 50-hiwikijs.conf and the shared 10-network-aliases.conf next to
# postgres.container. The canonical way to do that is the shared include below —
# it writes the same bytes every service writes, asserts that hiwikijs.network
# already exists, and notifies the "Restart Postgres" handler defined in this
# role.
#
# This runs in the roles phase, which is after the register include in
# playbook.yml's pre_tasks has created hiwikijs.network.
# Runs in the roles phase, after the register include in pre_tasks created the network.
- name: Attach Wiki.js to Postgres
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
# On a first attach both drop-ins are new, so Postgres has to be recreated to
# join hiwikijs_net — and the shared 10-network-aliases.conf is what creates the
# `db` alias DB_HOST asks for. That restart is a *handler*, which by default runs
# at the end of the play, after the wikijs role has already started the app. The
# app would then come up before `db` resolves, fail its own HTTP probe, and fail
# the play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-ins are unchanged, nothing is pending, and this is a no-op.
#
# Note that flush_handlers flushes *everything* pending, which includes the
# "Restart Caddy" that the register include notified in pre_tasks — and that is
# the right thing here: the Caddy drop-in is new too, and Caddy has to be
# recreated to join the network before it can proxy to hiwikijs.
# The restart is a handler, so it would run after the wikijs role starts the app.
# Flushing applies the new drop-in before the app starts; later runs are a no-op.
# Flush also applies Caddy's pending restart, which is right: Caddy must join the network to proxy.
- name: Apply the Postgres network change before starting Wiki.js
ansible.builtin.meta: flush_handlers
+1 -6
View File
@@ -1,16 +1,11 @@
---
# 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
# A changed environment file or unit file changes the generated unit, so Wiki.js
# is recreated. The wiki itself is in Postgres and the two data directories are
# bind mounts, so a recreate loses nothing — but it is a few seconds of downtime
# for the one site.
# A recreate keeps the wiki in Postgres and the bind mounts, but costs a few seconds of downtime.
- name: Restart Wiki.js
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+8 -44
View File
@@ -1,19 +1,6 @@
---
# Wiki.js 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.
# /wiki/data/content is the image's declared VOLUME, and /wiki/data/uploads is
# where Wiki.js's default "Local File System" storage target writes. Both are
# empty on monoco, because the whole wiki — pages, history, users, and the 5.8 MB
# of uploaded assets in the `assetData` table — lives in Postgres (see readme.md).
#
# They are created here anyway. The database is the store, but nothing the app
# does write may end up in the container's writable layer, where no backup would
# ever find it — that is the audit's finding #1 (docs/podman-compat-audit.md
# §3.1), and the reason the old hiwikijs-backup.sh archived nothing.
# Both must be bind mounts so nothing the app writes lands in the container layer.
- name: Ensure the Wiki.js data directories exist
ansible.builtin.file:
path: "{{ item }}"
@@ -25,9 +12,7 @@
- "{{ content_dir }}"
- "{{ uploads_dir }}"
# The app's database coordinates are consumed by systemd (EnvironmentFile= in the
# Quadlet unit), not by Ansible, so this has to be written before the unit starts.
# Mode 0600: DB_PASS carries the Postgres password.
# Written before the unit starts; 0600 keeps the Postgres password from a world-readable unit.
- name: Write the environment file
ansible.builtin.template:
src: wikijs.env.j2
@@ -57,9 +42,7 @@
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
# unreachable database, a bad volume). Pull the real reason out.
# systemd reports only a generic exit error, so collect the real reason.
- name: Collect Wiki.js diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -78,26 +61,15 @@
ansible.builtin.fail:
msg: "Wiki.js 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.
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
- name: Verify Wiki.js is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: wikijs_active
changed_when: false
failed_when: wikijs_active.stdout != 'active'
# The database is the whole wiki, and it arrives over the network that
# roles/postgres attached Postgres to. Check the name the environment file
# actually asks for, plus the container name.
#
# This runs before the HTTP probe on purpose: an unresolvable database is the one
# failure that would otherwise show up as two minutes of retries and a bare curl
# error. It is also checked *before* the app is expected to answer, because the
# network is attached when the container is created, not when the app is ready.
#
# getent is verified present in this image (/usr/bin/getent, via the musl
# libc-utils package) and exits 2 with no output when the name does not resolve,
# which is what the assert below keys off.
# An unresolvable database would otherwise show up as two minutes of retries and a bare curl error.
# getent exits 2 when the name does not resolve.
- name: Confirm Wiki.js can resolve its database
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ item }}
@@ -123,16 +95,8 @@
That means hiwikijs_net was never attached to the postgres unit — see
roles/postgres and postgres/readme.md, "How services attach themselves".
# `is-active` says the container is running; it does not say the app can serve.
# Wiki.js runs its own schema migrations against Postgres before it binds port
# {{ container_port }}, and `Restart=always` means even a crash-looping container
# is reported active.
#
# `podman healthcheck run` is not available here: the image declares no
# HEALTHCHECK (verified — `docker inspect hiwikijs` reports null), unlike
# Vaultwarden's. So probe HTTP directly with the image's own /usr/bin/curl; -f
# turns the 4xx/5xx Wiki.js serves while the database is unreachable into a
# failed retry instead of a success.
# The image declares no HEALTHCHECK, so probe HTTP with its own curl.
# -f turns the 4xx/5xx served while the database is unreachable into a failed retry.
- name: Wait for Wiki.js to serve HTTP
ansible.builtin.command: >-
podman exec {{ container_name }}
+12 -63
View File
@@ -1,102 +1,51 @@
---
# ---------------------------------------------------------------------------
# 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: Wiki.js 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).
# Derived from the connection user; requires gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Wiki.js
# ---------------------------------------------------------------------------
# The service keeps the Docker-era name everywhere — container, unit, Podman
# network and Postgres database are all `hiwikijs`, and the Caddyfile upstream is
# `hiwikijs:3000`. Only the directory is `wikijs`, because that is the playbook's
# name. Renaming the container would touch the Caddyfile, the network name and
# the database for no gain.
# The service keeps its Docker-era name hiwikijs everywhere; only the directory is wikijs.
host: wiki.hinchco.in
container_name: hiwikijs
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: registry + org + repo, so rootless Podman never
# consults unqualified-search-registries. There is no TTY to answer a short-name
# prompt when the systemd unit starts.
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
image: ghcr.io/requarks/wiki:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: hiwikijs.network,
# hiwikijs_net — the name the Docker-era container used).
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The uid the app actually runs as: the image's `USER node`, and `node` is uid
# 1000 there (verified: `docker exec hiwikijs id` → uid=1000(node) gid=1000).
# keep-id maps that uid onto podman_user, so anything written into the mounts
# below stays owned by podman_user on the host and never by a subuid.
#
# Note this is *not* root: unlike bitwarden and tandoor (uid 0), Wiki.js runs
# unprivileged, so the mapping goes through 1000. `tanshu` is uid 1001 — "the
# data is already mine" is false, which is the trap in
# docs/podman-migration.md §4.1.
# The image runs as uid 1000, and keep-id maps it onto podman_user.
# Unlike bitwarden and tandoor, Wiki.js runs unprivileged.
wikijs_uid: 1000
wikijs_gid: 1000
# The image declares VOLUME /wiki/data/content. On Docker that silently became an
# *anonymous* volume under /var/lib/docker/volumes, which is why nothing under
# /var/lib ever held the wiki and why the old backup script archived an empty
# directory (docs/podman-compat-audit.md §3.1). Binding it to a real path is the
# fix. It is empty on monoco today — see readme.md.
# The image declares this as a volume; bind it to a real path so backups can find it.
content_dir: "{{ host_directory }}/content"
# Not a declared volume, but it is where Wiki.js's default "Local File System"
# storage target writes uploads. Empty on monoco today — the wiki's 49 uploaded
# assets are rows in Postgres (`assetData`, 5.8 MB of the 16 MB database) — so
# this is insurance: if the storage target is ever switched to local files, the
# bytes land in a directory that gets backed up instead of in the container's
# writable layer, where nothing would ever find them.
# Not a declared volume, but a local storage target would write here.
uploads_dir: "{{ host_directory }}/uploads"
# DB_PASS carries the Postgres password and the Quadlet unit is world-readable in
# ~/.config, so it goes in this 0600 file instead and is read by systemd through
# EnvironmentFile=.
# The unit is world-readable, so the password goes in this 0600 file.
env_file: "{{ host_directory }}/{{ container_name }}.env"
# From the image's own config.yml (`port: 3000`, `bindIP: 0.0.0.0`). Caddy
# proxies to it over the app network; nothing is published host-side.
# The image's config.yml sets port 3000.
container_port: 3000
# The shared Postgres unit this app attaches itself to (postgres/playbook.yml).
# roles/postgres owns this app's drop-ins there and the "Restart Postgres"
# handler that applies them.
postgres_container: postgres
postgres_unit: "{{ postgres_container }}.service"
# The credentials the Docker-era playbook embedded in its .env file, kept as-is
# so the already-restored `hiwikijs` database and the app's own connection string
# agree. Postgres is authoritative for this service; nothing here is new.
# Kept as-is so the restored database and the app's connection string agree.
pg_user: postgres
pg_password: '123456'
pg_database: hiwikijs
# `db`, not `postgres`: that is literally what the Docker-era container ran with
# (it was `--link postgres:db`), and postgres/roles/attach writes the shared
# NetworkAlias=db drop-in that makes it resolve on hiwikijs_net. The container
# name resolves too, and the play checks both.
# The image expects the db alias; postgres/roles/attach writes the NetworkAlias=db drop-in.
db_host: db
# 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"
+2 -39
View File
@@ -1,28 +1,5 @@
---
# Yamtrack as a rootless Podman Quadlet unit, behind the shared Caddy unit
# (caddy/playbook.yml), with its cache in the shared Valkey unit
# (valkey/playbook.yml). The database stays SQLite, inside the app's own data
# directory — see readme.md, "The database stays SQLite".
#
# 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.
#
# Run with: ansible-playbook -u tanshu yamtrack/playbook.yml
# Requires caddy and valkey to have run first. The database is SQLite.
- name: Deploy Yamtrack as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -30,25 +7,11 @@
- 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, and the Valkey
# attach below needs the network unit to exist as well.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register Yamtrack's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# Attaches this app's network to the shared Valkey unit, which is what makes
# REDIS_URL's host resolve, and owns the "Restart Valkey" handler that
# valkey/roles/attach notifies. Runs first so the cache is reachable before
# the app starts.
- valkey
# The app: data directory, the 0600 environment file, the Quadlet unit,
# start + verify.
- yamtrack
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+4 -16
View File
@@ -1,27 +1,15 @@
---
# 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.
# A reload keeps live connections; a restart would drop every hosted site.
# The path is inside the container, not the host {{ caddyfile_path }}.
- 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.
# Quadlet cannot attach a network live, so Caddy must be recreated.
# Defined after the reload so the cheap reload runs first when both are notified.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-10
View File
@@ -1,14 +1,4 @@
---
# 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. (The Docker-era version of this role did exactly that.)
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
+2 -12
View File
@@ -1,16 +1,6 @@
---
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
# there. This handler runs as the connection user, so `systemctl --user` works
# without setting XDG_RUNTIME_DIR.
#
# Required by valkey/roles/attach, which notifies it whenever this app's drop-in
# changes: a new drop-in changes the generated unit, so Valkey must be recreated
# to join the network — Quadlet has no live equivalent of
# `podman network connect`.
#
# The cost is a cold cache for every attached app, not lost data — but note that
# attached means every cache client, not just Yamtrack. See valkey/readme.md,
# "The trade-off".
# A new drop-in changes the generated unit, so Valkey must be recreated to join the network.
# That restarts every attached client, not just Yamtrack.
- name: Restart Valkey
ansible.builtin.systemd_service:
name: "{{ valkey_unit }}"
+3 -24
View File
@@ -1,31 +1,10 @@
---
# Attach this app's network to the shared Valkey unit.
#
# Valkey knows nothing about its clients, exactly like Caddy and Postgres: a
# service joins by dropping 50-yamtrack.conf and the shared
# 10-network-aliases.conf next to valkey.container. The canonical way to do that
# is the shared include below — it writes the same bytes every service writes,
# asserts that yamtrack.network already exists, and notifies the "Restart Valkey"
# handler defined in this role.
#
# `valkey` is the host REDIS_URL asks for — the container name, not the shared
# `redis` alias, which is there for the clients whose config.php says `redis`.
# The Docker-era container was given `redis://valkey:6379` too, so the URL is
# unchanged.
#
# This runs in the roles phase, which is after the register include in
# playbook.yml's pre_tasks has created yamtrack.network.
# Runs in the roles phase, after the register include in pre_tasks created the network.
- name: Attach Yamtrack to Valkey
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
# On a first attach the drop-in is new, so Valkey has to be recreated to join
# yamtrack_net — and that restart is a *handler*, which by default runs at the
# end of the play, after the yamtrack role has already started the app. The app
# would then come up before `valkey` resolves, fail its own checks, and fail the
# play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-in is unchanged, nothing is pending, and this is a no-op.
# The restart is a handler, so it would run after the yamtrack role starts the app.
# Flushing applies the new drop-in before the app starts; later runs are a no-op.
- name: Apply the Valkey network change before starting Yamtrack
ansible.builtin.meta: flush_handlers
+1 -6
View File
@@ -1,16 +1,11 @@
---
# 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
# A changed environment file or unit file changes the generated unit, so
# Yamtrack is recreated. The database is a bind mount and the rest of /yamtrack
# is image content, so a recreate loses nothing — but it is a few seconds of
# downtime for the one site.
# A recreate keeps state in the bind mount but costs a few seconds of downtime.
- name: Restart Yamtrack
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+9 -37
View File
@@ -1,13 +1,6 @@
---
# Yamtrack 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.
# /yamtrack/db is the image's state directory: db.sqlite3 plus its -wal/-shm
# sidecars, and nothing else worth keeping — staticfiles/ is built into the
# image and the code is read-only.
# /yamtrack/db holds db.sqlite3 and its WAL sidecars; everything else is image content.
- name: Ensure the Yamtrack data directory exists
ansible.builtin.file:
path: "{{ data_dir }}"
@@ -16,9 +9,7 @@
group: "{{ podman_user }}"
mode: '0755'
# The app's secrets and cache coordinates are consumed by systemd
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
# written before the unit starts. Mode 0600: it holds SECRET.
# Written before the unit starts; 0600 keeps SECRET out of the world-readable unit.
- name: Write the environment file
ansible.builtin.template:
src: yamtrack.env.j2
@@ -48,9 +39,7 @@
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
# unreachable cache, a bad volume). Pull the real reason out.
# systemd reports only a generic exit error, so collect the real reason.
- name: Collect Yamtrack diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -69,22 +58,15 @@
ansible.builtin.fail:
msg: "Yamtrack 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.
#
# `is-active` says the container is running; it does not say the app can serve.
# The entrypoint runs migrations before supervisord starts nginx, gunicorn,
# celery and beat, and `Restart=always` means even a crash-looping container is
# reported active — so the checks below are the ones that matter.
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
# is-active still reports active for a crash-looping container, so the checks below matter.
- name: Verify Yamtrack is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: yamtrack_active
changed_when: false
failed_when: yamtrack_active.stdout != 'active'
# `valkey` is the host the environment file actually asks for; it resolves from
# ContainerName= on the shared unit, which valkey/roles/attach joined to this
# app's network.
# The app expects the valkey host; attach joined the shared unit to this app's network.
- name: Confirm Yamtrack can resolve its cache
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ valkey_container }}
@@ -98,19 +80,9 @@
{{ valkey_container }} resolves as
{{ yamtrack_cache_lookup.stdout | default('(no answer)') }}
# `is-active` says the container is running; the image's own HEALTHCHECK is what
# says the app works. It wgets http://127.0.0.1:8000/health/, and that view
# checks all four things this deployment can get wrong — the cache, the database,
# Redis and Celery — so a green result here is the app being genuinely ready.
#
# Running the image's check rather than one written here means depending on
# nothing the play has to know: no curl or wget wrapper of our own, and no
# hardcoded path that a later image tag could move. `podman exec <c> wget -qO-
# http://127.0.0.1:{{ container_port }}/health/` prints the per-check breakdown
# when this fails; see readme.md, "Verification".
#
# The start period (30s) plus the entrypoint's migrations are why this allows
# three minutes rather than one.
# The image's own HEALTHCHECK covers the cache, database, Redis and Celery, so a pass means ready.
# Running the image's check means the play depends on no path or tool of its own.
# The start period plus migrations is why this allows three minutes.
- name: Wait for Yamtrack to pass its own health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: yamtrack_health
+12 -71
View File
@@ -1,109 +1,50 @@
---
# ---------------------------------------------------------------------------
# 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: Yamtrack listens on
# 8000 inside the container and 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).
# Derived from the connection user; requires gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# Yamtrack
# ---------------------------------------------------------------------------
host: track.tanshu.com
container_name: yamtrack
unit_name: "{{ container_name }}.service"
# Fully qualified on purpose: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when
# the systemd unit starts. ghcr.io was already qualified; the tag is explicit
# for the same reason. Nothing is pinned — see readme.md, "Notes and gotchas".
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
image: ghcr.io/fuzzygrim/yamtrack:latest
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_net (so: yamtrack.network, yamtrack_net,
# which is the name the Docker-era deployment used).
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
network_name: "{{ container_name }}"
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image's bind mount: /yamtrack/db holds db.sqlite3 and its WAL sidecars.
# Nothing else on /yamtrack is state — staticfiles/ is baked into the image and
# the code is read-only — so this is the only directory worth keeping.
# /yamtrack/db holds db.sqlite3 and its WAL sidecars; nothing else on /yamtrack is state.
data_dir: "{{ host_directory }}/data"
# The port inside the container that Caddy proxies to: the image's own nginx
# listens there and forwards to gunicorn. Reproduces the Docker-era Caddyfile
# entry (`reverse_proxy yamtrack:8000`) exactly.
# The image's nginx listens on 8000 and forwards to gunicorn.
container_port: 8000
# SECRET, TZ and REDIS_URL live here rather than as Environment= lines in the
# unit file: that file is world-readable in ~/.config. Read by systemd through
# EnvironmentFile=, not by Ansible, so it has to be written before the unit
# starts.
# The unit is world-readable, so secrets go in this 0600 file.
env_file: "{{ host_directory }}/{{ container_name }}.env"
# The uid this unit maps onto podman_user, and the PUID/PGID the image's
# entrypoint is given. It is 0 — container root — and that is deliberate:
#
# 1. `--userns=keep-id` does two things: it maps one container uid onto the
# connecting user, and it runs the container *as that uid*. The entrypoint's
# first act is `groupmod`/`usermod`, which rewrite /etc/group and
# /etc/passwd — files owned by container-root in the image. With
# `keep-id:uid=1000` those run as `abc` and die on "cannot lock /etc/group"
# (the unit exits 10) before supervisord is ever reached. Root is required.
# 2. Mapping container root onto podman_user is what keeps
# ~/podman-data/yamtrack owned by podman_user rather than by a subuid. It is
# the same choice caddy, bitwarden and tandoor make.
#
# It is also rendered as PUID/PGID in the environment file: the entrypoint's own
# default is 1000, which under this mapping is a subuid on the host, so its
# `chown -R abc:abc db` would strand the database on an unmapped uid. See
# readme.md, "Who runs as whom".
# It is 0 because the entrypoint rewrites /etc/group and /etc/passwd as container root.
# keep-id:uid=1000 would run that as abc and exit before supervisord starts.
# Mapping root onto podman_user keeps the data owned by podman_user, not a subuid.
app_uid: 0
app_gid: 0
yamtrack_tz: "Asia/Kolkata"
# The Django SECRET_KEY, as the image calls it (`SECRET`). Carried over verbatim
# from the Docker-era playbook so existing sessions and password-reset tokens
# stay valid — rotating it logs every user out. Committed in plaintext as
# before; flagged as a follow-up, not changed here.
# Carried over so existing sessions and password-reset tokens stay valid.
yamtrack_secret: "c14c5970eb728fbf8b2325fd3004069d"
# What the old container passed as URLS, which Yamtrack turns into its CSRF
# trusted origins. ALLOWED_HOSTS is deliberately not set: its default is `*`,
# which is what the Docker-era container relied on.
# ALLOWED_HOSTS is deliberately unset; its default is *.
urls: "https://{{ host }}"
# ---------------------------------------------------------------------------
# Valkey (the shared cache / Celery broker)
# ---------------------------------------------------------------------------
# Yamtrack hard-requires Redis: its cache backend is django_redis with no
# fallback, the Celery worker and beat need a broker, and several metadata
# providers talk to Redis directly. It is a *separate* playbook, deployed before
# this one: yamtrack/roles/valkey only attaches its network to that shared unit.
#
# The Docker-era container was given `redis://valkey:6379`, and valkey is what
# the container is called, so the URL is unchanged.
# The container is called valkey, so the Redis URL is unchanged.
valkey_container: valkey
valkey_unit: "{{ valkey_container }}.service"
redis_url: "redis://{{ valkey_container }}:6379"
# ---------------------------------------------------------------------------
# Caddy
# ---------------------------------------------------------------------------
# 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"