diff --git a/audiobookshelf/playbook.yml b/audiobookshelf/playbook.yml index 9b5d788..255508a 100644 --- a/audiobookshelf/playbook.yml +++ b/audiobookshelf/playbook.yml @@ -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 diff --git a/audiobookshelf/roles/audiobookshelf/tasks/main.yaml b/audiobookshelf/roles/audiobookshelf/tasks/main.yaml index e9efb73..846c425 100644 --- a/audiobookshelf/roles/audiobookshelf/tasks/main.yaml +++ b/audiobookshelf/roles/audiobookshelf/tasks/main.yaml @@ -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 diff --git a/audiobookshelf/roles/caddy/handlers/main.yaml b/audiobookshelf/roles/caddy/handlers/main.yaml index 5fe87df..2c6e25e 100644 --- a/audiobookshelf/roles/caddy/handlers/main.yaml +++ b/audiobookshelf/roles/caddy/handlers/main.yaml @@ -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" diff --git a/audiobookshelf/roles/caddy/tasks/main.yaml b/audiobookshelf/roles/caddy/tasks/main.yaml index ffbe0d8..b0698b1 100644 --- a/audiobookshelf/roles/caddy/tasks/main.yaml +++ b/audiobookshelf/roles/caddy/tasks/main.yaml @@ -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: diff --git a/audiobookshelf/vars/default.yml b/audiobookshelf/vars/default.yml index d9c64da..7f6424a 100644 --- a/audiobookshelf/vars/default.yml +++ b/audiobookshelf/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: audiobookshelf.network, -# audiobookshelf_net). +# The Quadlet file is .network; register pins _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" diff --git a/bitwarden/playbook.yml b/bitwarden/playbook.yml index 34ed25c..aa03179 100755 --- a/bitwarden/playbook.yml +++ b/bitwarden/playbook.yml @@ -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 diff --git a/bitwarden/roles/bitwarden/handlers/main.yaml b/bitwarden/roles/bitwarden/handlers/main.yaml index 439c095..285609b 100644 --- a/bitwarden/roles/bitwarden/handlers/main.yaml +++ b/bitwarden/roles/bitwarden/handlers/main.yaml @@ -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 }}" diff --git a/bitwarden/roles/bitwarden/tasks/main.yaml b/bitwarden/roles/bitwarden/tasks/main.yaml index a013f8c..1742cda 100644 --- a/bitwarden/roles/bitwarden/tasks/main.yaml +++ b/bitwarden/roles/bitwarden/tasks/main.yaml @@ -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: >- diff --git a/bitwarden/roles/caddy/handlers/main.yaml b/bitwarden/roles/caddy/handlers/main.yaml index 5fe87df..4269215 100644 --- a/bitwarden/roles/caddy/handlers/main.yaml +++ b/bitwarden/roles/caddy/handlers/main.yaml @@ -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" diff --git a/bitwarden/roles/caddy/tasks/main.yaml b/bitwarden/roles/caddy/tasks/main.yaml index ffbe0d8..923aba7 100644 --- a/bitwarden/roles/caddy/tasks/main.yaml +++ b/bitwarden/roles/caddy/tasks/main.yaml @@ -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 }}" diff --git a/bitwarden/roles/postgres/handlers/main.yaml b/bitwarden/roles/postgres/handlers/main.yaml index 31a5d44..f0c4fed 100644 --- a/bitwarden/roles/postgres/handlers/main.yaml +++ b/bitwarden/roles/postgres/handlers/main.yaml @@ -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 }}" diff --git a/bitwarden/roles/postgres/tasks/main.yaml b/bitwarden/roles/postgres/tasks/main.yaml index 0043938..2c3faa3 100644 --- a/bitwarden/roles/postgres/tasks/main.yaml +++ b/bitwarden/roles/postgres/tasks/main.yaml @@ -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 diff --git a/bitwarden/vars/default.yml b/bitwarden/vars/default.yml index bd142c7..ce42f1b 100644 --- a/bitwarden/vars/default.yml +++ b/bitwarden/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: bitwarden.network, -# bitwarden_net). +# caddy/roles/register pins the Podman network to _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 }} diff --git a/caddy/roles/caddy/handlers/main.yaml b/caddy/roles/caddy/handlers/main.yaml index f366401..b7fb388 100644 --- a/caddy/roles/caddy/handlers/main.yaml +++ b/caddy/roles/caddy/handlers/main.yaml @@ -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 }} diff --git a/caddy/roles/caddy/tasks/main.yaml b/caddy/roles/caddy/tasks/main.yaml index 4c3c35a..aefa86f 100644 --- a/caddy/roles/caddy/tasks/main.yaml +++ b/caddy/roles/caddy/tasks/main.yaml @@ -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 diff --git a/caddy/roles/podman/tasks/main.yaml b/caddy/roles/podman/tasks/main.yaml index 631f74b..58d9d3b 100644 --- a/caddy/roles/podman/tasks/main.yaml +++ b/caddy/roles/podman/tasks/main.yaml @@ -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 diff --git a/caddy/roles/register/tasks/main.yaml b/caddy/roles/register/tasks/main.yaml index c3ff6bd..61a9519 100644 --- a/caddy/roles/register/tasks/main.yaml +++ b/caddy/roles/register/tasks/main.yaml @@ -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 .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 diff --git a/caddy/vars/default.yml b/caddy/vars/default.yml index bb0406a..20ed1e5 100644 --- a/caddy/vars/default.yml +++ b/caddy/vars/default.yml @@ -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/. 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" diff --git a/gitea/playbook-pg.yml b/gitea/playbook-pg.yml index 4324f3c..dd85792 100755 --- a/gitea/playbook-pg.yml +++ b/gitea/playbook-pg.yml @@ -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 diff --git a/gitea/playbook.yml b/gitea/playbook.yml index 8aed1eb..e74eeb1 100755 --- a/gitea/playbook.yml +++ b/gitea/playbook.yml @@ -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 diff --git a/gitea/roles/caddy/handlers/main.yaml b/gitea/roles/caddy/handlers/main.yaml index d12aa38..4269215 100644 --- a/gitea/roles/caddy/handlers/main.yaml +++ b/gitea/roles/caddy/handlers/main.yaml @@ -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" diff --git a/gitea/roles/caddy/tasks/main.yaml b/gitea/roles/caddy/tasks/main.yaml index ffbe0d8..923aba7 100644 --- a/gitea/roles/caddy/tasks/main.yaml +++ b/gitea/roles/caddy/tasks/main.yaml @@ -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 }}" diff --git a/gitea/roles/gitea/tasks/main.yaml b/gitea/roles/gitea/tasks/main.yaml index a8b6697..9645b41 100644 --- a/gitea/roles/gitea/tasks/main.yaml +++ b/gitea/roles/gitea/tasks/main.yaml @@ -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: >- diff --git a/gitea/roles/ssh/tasks/main.yaml b/gitea/roles/ssh/tasks/main.yaml index 7db6e0a..a2e0c0d 100644 --- a/gitea/roles/ssh/tasks/main.yaml +++ b/gitea/roles/ssh/tasks/main.yaml @@ -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 }}" diff --git a/gitea/vars/default.yml b/gitea/vars/default.yml index 290d7e3..9d9bacc 100644 --- a/gitea/vars/default.yml +++ b/gitea/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: gitea.network, gitea_net). +# caddy/roles/register pins the Podman network to _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" diff --git a/monitoring/files/loki-config.yaml b/monitoring/files/loki-config.yaml index b5f9cb6..899f081 100644 --- a/monitoring/files/loki-config.yaml +++ b/monitoring/files/loki-config.yaml @@ -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 diff --git a/monitoring/roles/loki/tasks/main.yaml b/monitoring/roles/loki/tasks/main.yaml index 768a139..76445b3 100644 --- a/monitoring/roles/loki/tasks/main.yaml +++ b/monitoring/roles/loki/tasks/main.yaml @@ -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" diff --git a/monitoring/vars/default.yml b/monitoring/vars/default.yml index 3de9957..991886e 100644 --- a/monitoring/vars/default.yml +++ b/monitoring/vars/default.yml @@ -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" diff --git a/nextcloud/playbook.yml b/nextcloud/playbook.yml index a39bcce..9b7f9cc 100755 --- a/nextcloud/playbook.yml +++ b/nextcloud/playbook.yml @@ -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 diff --git a/nextcloud/roles/caddy/handlers/main.yaml b/nextcloud/roles/caddy/handlers/main.yaml index 2f899d7..4269215 100644 --- a/nextcloud/roles/caddy/handlers/main.yaml +++ b/nextcloud/roles/caddy/handlers/main.yaml @@ -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" diff --git a/nextcloud/roles/caddy/tasks/main.yaml b/nextcloud/roles/caddy/tasks/main.yaml index 7ce0af9..923aba7 100644 --- a/nextcloud/roles/caddy/tasks/main.yaml +++ b/nextcloud/roles/caddy/tasks/main.yaml @@ -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 }}" diff --git a/nextcloud/roles/nextcloud/handlers/main.yaml b/nextcloud/roles/nextcloud/handlers/main.yaml index c3f837a..140d49c 100644 --- a/nextcloud/roles/nextcloud/handlers/main.yaml +++ b/nextcloud/roles/nextcloud/handlers/main.yaml @@ -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 }}" diff --git a/nextcloud/roles/nextcloud/tasks/main.yaml b/nextcloud/roles/nextcloud/tasks/main.yaml index 714d1a8..df583a5 100644 --- a/nextcloud/roles/nextcloud/tasks/main.yaml +++ b/nextcloud/roles/nextcloud/tasks/main.yaml @@ -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/ 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/ present, which is where podman keeps the state exec needs. - name: Schedule Nextcloud's background jobs ansible.builtin.cron: name: Nextcloud background jobs diff --git a/nextcloud/roles/postgres/handlers/main.yaml b/nextcloud/roles/postgres/handlers/main.yaml index 98f8953..f0c4fed 100644 --- a/nextcloud/roles/postgres/handlers/main.yaml +++ b/nextcloud/roles/postgres/handlers/main.yaml @@ -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 }}" diff --git a/nextcloud/roles/postgres/tasks/main.yaml b/nextcloud/roles/postgres/tasks/main.yaml index 9843ec5..690b682 100644 --- a/nextcloud/roles/postgres/tasks/main.yaml +++ b/nextcloud/roles/postgres/tasks/main.yaml @@ -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 }} diff --git a/nextcloud/roles/valkey/handlers/main.yaml b/nextcloud/roles/valkey/handlers/main.yaml index 256927e..3e988e2 100644 --- a/nextcloud/roles/valkey/handlers/main.yaml +++ b/nextcloud/roles/valkey/handlers/main.yaml @@ -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 }}" diff --git a/nextcloud/roles/valkey/tasks/main.yaml b/nextcloud/roles/valkey/tasks/main.yaml index fc7774c..738c1bb 100644 --- a/nextcloud/roles/valkey/tasks/main.yaml +++ b/nextcloud/roles/valkey/tasks/main.yaml @@ -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 diff --git a/nextcloud/vars/default.yml b/nextcloud/vars/default.yml index f6371ab..b1d0b5a 100644 --- a/nextcloud/vars/default.yml +++ b/nextcloud/vars/default.yml @@ -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 _net, so the -# file is nextcloud.network and the network is nextcloud_net. +# caddy/roles/register pins the Podman network to _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 }} diff --git a/postgres/playbook.yml b/postgres/playbook.yml index bead677..3e6284f 100755 --- a/postgres/playbook.yml +++ b/postgres/playbook.yml @@ -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 diff --git a/postgres/roles/attach/tasks/main.yaml b/postgres/roles/attach/tasks/main.yaml index 323ef31..d5e7afa 100644 --- a/postgres/roles/attach/tasks/main.yaml +++ b/postgres/roles/attach/tasks/main.yaml @@ -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: >- diff --git a/postgres/roles/postgres/handlers/main.yaml b/postgres/roles/postgres/handlers/main.yaml index a2f22e7..ef914e2 100644 --- a/postgres/roles/postgres/handlers/main.yaml +++ b/postgres/roles/postgres/handlers/main.yaml @@ -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`. diff --git a/postgres/roles/postgres/tasks/main.yaml b/postgres/roles/postgres/tasks/main.yaml index 688925a..203f300 100644 --- a/postgres/roles/postgres/tasks/main.yaml +++ b/postgres/roles/postgres/tasks/main.yaml @@ -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: >- diff --git a/postgres/vars/default.yml b/postgres/vars/default.yml index 6a96014..16e7503 100644 --- a/postgres/vars/default.yml +++ b/postgres/vars/default.yml @@ -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//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 /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 diff --git a/prepare-server/roles/common/tasks/main.yaml b/prepare-server/roles/common/tasks/main.yaml index 3ce20fc..4c373ea 100644 --- a/prepare-server/roles/common/tasks/main.yaml +++ b/prepare-server/roles/common/tasks/main.yaml @@ -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: diff --git a/prepare-server/roles/user/tasks/main.yaml b/prepare-server/roles/user/tasks/main.yaml index 48d1657..a893de8 100644 --- a/prepare-server/roles/user/tasks/main.yaml +++ b/prepare-server/roles/user/tasks/main.yaml @@ -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" diff --git a/rustdesk/vars/default.yml b/rustdesk/vars/default.yml index d48ba89..27b68ec 100644 --- a/rustdesk/vars/default.yml +++ b/rustdesk/vars/default.yml @@ -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:///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 }} { diff --git a/sms-wa-bridge/playbook.yml b/sms-wa-bridge/playbook.yml index c0c6747..86dc113 100644 --- a/sms-wa-bridge/playbook.yml +++ b/sms-wa-bridge/playbook.yml @@ -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 diff --git a/sms-wa-bridge/roles/caddy/handlers/main.yaml b/sms-wa-bridge/roles/caddy/handlers/main.yaml index 9c4d17f..ceaae25 100644 --- a/sms-wa-bridge/roles/caddy/handlers/main.yaml +++ b/sms-wa-bridge/roles/caddy/handlers/main.yaml @@ -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" diff --git a/sms-wa-bridge/roles/caddy/tasks/main.yaml b/sms-wa-bridge/roles/caddy/tasks/main.yaml index ffbe0d8..b0698b1 100644 --- a/sms-wa-bridge/roles/caddy/tasks/main.yaml +++ b/sms-wa-bridge/roles/caddy/tasks/main.yaml @@ -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: diff --git a/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml b/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml index 8e9df2d..99aa908 100644 --- a/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml +++ b/sms-wa-bridge/roles/sms-wa-bridge/handlers/main.yaml @@ -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 }}" diff --git a/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml b/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml index 0db2c76..cc836ad 100644 --- a/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml +++ b/sms-wa-bridge/roles/sms-wa-bridge/tasks/main.yaml @@ -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" diff --git a/sms-wa-bridge/vars/default.yml b/sms-wa-bridge/vars/default.yml index d477e81..0260981 100644 --- a/sms-wa-bridge/vars/default.yml +++ b/sms-wa-bridge/vars/default.yml @@ -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, and caddy/roles/register pins the -# Podman network name to _net — so: sms-wa-bridge.network, -# sms-wa-bridge_net, which is the name the Docker-era deployment used. +# The Quadlet file is .network; register pins _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 }} diff --git a/tandoor/playbook.yml b/tandoor/playbook.yml index d3d9227..c7ee8b5 100755 --- a/tandoor/playbook.yml +++ b/tandoor/playbook.yml @@ -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 diff --git a/tandoor/roles/caddy/handlers/main.yaml b/tandoor/roles/caddy/handlers/main.yaml index 5fe87df..4269215 100644 --- a/tandoor/roles/caddy/handlers/main.yaml +++ b/tandoor/roles/caddy/handlers/main.yaml @@ -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" diff --git a/tandoor/roles/caddy/tasks/main.yaml b/tandoor/roles/caddy/tasks/main.yaml index ffbe0d8..923aba7 100644 --- a/tandoor/roles/caddy/tasks/main.yaml +++ b/tandoor/roles/caddy/tasks/main.yaml @@ -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 }}" diff --git a/tandoor/roles/postgres/handlers/main.yaml b/tandoor/roles/postgres/handlers/main.yaml index 31a5d44..f0c4fed 100644 --- a/tandoor/roles/postgres/handlers/main.yaml +++ b/tandoor/roles/postgres/handlers/main.yaml @@ -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 }}" diff --git a/tandoor/roles/postgres/tasks/main.yaml b/tandoor/roles/postgres/tasks/main.yaml index d0b2943..d622576 100644 --- a/tandoor/roles/postgres/tasks/main.yaml +++ b/tandoor/roles/postgres/tasks/main.yaml @@ -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 diff --git a/tandoor/roles/tandoor/tasks/main.yaml b/tandoor/roles/tandoor/tasks/main.yaml index 130c8af..61458c9 100644 --- a/tandoor/roles/tandoor/tasks/main.yaml +++ b/tandoor/roles/tandoor/tasks/main.yaml @@ -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 }} diff --git a/tandoor/vars/default.yml b/tandoor/vars/default.yml index 1365bbd..7a3d2bb 100644 --- a/tandoor/vars/default.yml +++ b/tandoor/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: tandoor.network, tandoor_net). +# caddy/roles/register pins the Podman network to _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" diff --git a/trilium/playbook.yml b/trilium/playbook.yml index 46d220f..0e633a0 100755 --- a/trilium/playbook.yml +++ b/trilium/playbook.yml @@ -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 diff --git a/trilium/roles/caddy/handlers/main.yaml b/trilium/roles/caddy/handlers/main.yaml index 5fe87df..4269215 100644 --- a/trilium/roles/caddy/handlers/main.yaml +++ b/trilium/roles/caddy/handlers/main.yaml @@ -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" diff --git a/trilium/roles/caddy/tasks/main.yaml b/trilium/roles/caddy/tasks/main.yaml index ffbe0d8..923aba7 100644 --- a/trilium/roles/caddy/tasks/main.yaml +++ b/trilium/roles/caddy/tasks/main.yaml @@ -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 }}" diff --git a/trilium/roles/trilium/tasks/main.yaml b/trilium/roles/trilium/tasks/main.yaml index 62a287c..3a7c6d5 100644 --- a/trilium/roles/trilium/tasks/main.yaml +++ b/trilium/roles/trilium/tasks/main.yaml @@ -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: >- diff --git a/trilium/vars/default.yml b/trilium/vars/default.yml index 40bdaca..9c4b209 100644 --- a/trilium/vars/default.yml +++ b/trilium/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: trilium.network, -# trilium_net). +# caddy/roles/register pins the Podman network to _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 }} diff --git a/valkey/playbook.yml b/valkey/playbook.yml index 455f32a..1118113 100755 --- a/valkey/playbook.yml +++ b/valkey/playbook.yml @@ -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 diff --git a/valkey/roles/attach/tasks/main.yaml b/valkey/roles/attach/tasks/main.yaml index d8c37e9..bc0e233 100644 --- a/valkey/roles/attach/tasks/main.yaml +++ b/valkey/roles/attach/tasks/main.yaml @@ -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: >- diff --git a/valkey/roles/valkey/handlers/main.yaml b/valkey/roles/valkey/handlers/main.yaml index 0b32f7d..e9b6704 100644 --- a/valkey/roles/valkey/handlers/main.yaml +++ b/valkey/roles/valkey/handlers/main.yaml @@ -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". diff --git a/valkey/roles/valkey/tasks/main.yaml b/valkey/roles/valkey/tasks/main.yaml index f3aaf38..e1c9338 100644 --- a/valkey/roles/valkey/tasks/main.yaml +++ b/valkey/roles/valkey/tasks/main.yaml @@ -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: >- diff --git a/valkey/vars/default.yml b/valkey/vars/default.yml index 3893632..5716b57 100644 --- a/valkey/vars/default.yml +++ b/valkey/vars/default.yml @@ -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" diff --git a/wikijs/playbook.yml b/wikijs/playbook.yml index b138e36..8f34d87 100755 --- a/wikijs/playbook.yml +++ b/wikijs/playbook.yml @@ -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 diff --git a/wikijs/roles/caddy/handlers/main.yaml b/wikijs/roles/caddy/handlers/main.yaml index d12aa38..2c6e25e 100644 --- a/wikijs/roles/caddy/handlers/main.yaml +++ b/wikijs/roles/caddy/handlers/main.yaml @@ -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" diff --git a/wikijs/roles/caddy/tasks/main.yaml b/wikijs/roles/caddy/tasks/main.yaml index ffbe0d8..b0698b1 100644 --- a/wikijs/roles/caddy/tasks/main.yaml +++ b/wikijs/roles/caddy/tasks/main.yaml @@ -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: diff --git a/wikijs/roles/postgres/handlers/main.yaml b/wikijs/roles/postgres/handlers/main.yaml index 31a5d44..d2c5321 100644 --- a/wikijs/roles/postgres/handlers/main.yaml +++ b/wikijs/roles/postgres/handlers/main.yaml @@ -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 }}" diff --git a/wikijs/roles/postgres/tasks/main.yaml b/wikijs/roles/postgres/tasks/main.yaml index 5ffda36..0e57549 100644 --- a/wikijs/roles/postgres/tasks/main.yaml +++ b/wikijs/roles/postgres/tasks/main.yaml @@ -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 diff --git a/wikijs/roles/wikijs/handlers/main.yaml b/wikijs/roles/wikijs/handlers/main.yaml index 327de54..4da9fdc 100644 --- a/wikijs/roles/wikijs/handlers/main.yaml +++ b/wikijs/roles/wikijs/handlers/main.yaml @@ -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 }}" diff --git a/wikijs/roles/wikijs/tasks/main.yaml b/wikijs/roles/wikijs/tasks/main.yaml index 770d5b0..2ac1f34 100644 --- a/wikijs/roles/wikijs/tasks/main.yaml +++ b/wikijs/roles/wikijs/tasks/main.yaml @@ -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 }} diff --git a/wikijs/vars/default.yml b/wikijs/vars/default.yml index 961ff7a..e62a30b 100644 --- a/wikijs/vars/default.yml +++ b/wikijs/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: hiwikijs.network, -# hiwikijs_net — the name the Docker-era container used). +# The Quadlet file is .network; register pins _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" diff --git a/yamtrack/playbook.yml b/yamtrack/playbook.yml index e33e020..53f2d12 100755 --- a/yamtrack/playbook.yml +++ b/yamtrack/playbook.yml @@ -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 diff --git a/yamtrack/roles/caddy/handlers/main.yaml b/yamtrack/roles/caddy/handlers/main.yaml index 5fe87df..2c6e25e 100644 --- a/yamtrack/roles/caddy/handlers/main.yaml +++ b/yamtrack/roles/caddy/handlers/main.yaml @@ -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" diff --git a/yamtrack/roles/caddy/tasks/main.yaml b/yamtrack/roles/caddy/tasks/main.yaml index 5e74079..b0698b1 100644 --- a/yamtrack/roles/caddy/tasks/main.yaml +++ b/yamtrack/roles/caddy/tasks/main.yaml @@ -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: diff --git a/yamtrack/roles/valkey/handlers/main.yaml b/yamtrack/roles/valkey/handlers/main.yaml index c4d9cc9..fdbc23a 100644 --- a/yamtrack/roles/valkey/handlers/main.yaml +++ b/yamtrack/roles/valkey/handlers/main.yaml @@ -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 }}" diff --git a/yamtrack/roles/valkey/tasks/main.yaml b/yamtrack/roles/valkey/tasks/main.yaml index 0abe174..d8783ac 100644 --- a/yamtrack/roles/valkey/tasks/main.yaml +++ b/yamtrack/roles/valkey/tasks/main.yaml @@ -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 diff --git a/yamtrack/roles/yamtrack/handlers/main.yaml b/yamtrack/roles/yamtrack/handlers/main.yaml index 155b3f4..08e5366 100644 --- a/yamtrack/roles/yamtrack/handlers/main.yaml +++ b/yamtrack/roles/yamtrack/handlers/main.yaml @@ -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 }}" diff --git a/yamtrack/roles/yamtrack/tasks/main.yaml b/yamtrack/roles/yamtrack/tasks/main.yaml index fc3eda1..37c2bbd 100644 --- a/yamtrack/roles/yamtrack/tasks/main.yaml +++ b/yamtrack/roles/yamtrack/tasks/main.yaml @@ -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 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 diff --git a/yamtrack/vars/default.yml b/yamtrack/vars/default.yml index d5ef5e4..d3e888b 100644 --- a/yamtrack/vars/default.yml +++ b/yamtrack/vars/default.yml @@ -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. caddy/roles/register pins the -# Podman network name to _net (so: yamtrack.network, yamtrack_net, -# which is the name the Docker-era deployment used). +# The Quadlet file is .network; register pins _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"