Cut the comment bloat from the Podman playbooks

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

No files matched your search

+2 -45
View File
@@ -1,39 +1,5 @@
---
# sms-wa-bridge as a rootless Podman Quadlet unit on emily, behind the shared
# Caddy unit (caddy/playbook.yml).
#
# The app is SMS webhook -> routing rules -> WhatsApp, with no LLM in the path:
# an Android automation (MacroDroid) POSTs each SMS to https://bridge.tanshu.dev/sms
# with an X-Auth header, rules.json decides who gets it, and whatsapp-web.js
# sends it from a linked WhatsApp account. It was deployed on monoco as a plain
# `docker run` container built from the Dockerfile now vendored at
# files/build/ — see readme.md.
#
# Connects as the container user and runs everything as that user: the Quadlet
# units belong to it, so the connection's PAM session is what makes
# `systemctl --user` work without any environment juggling.
#
# gather_facts is required: podman_user and the home directory come from the
# user_id / user_dir facts.
#
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
# a host that has already run caddy/playbook.yml has them:
#
# - name: Rootless Podman prerequisites
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
#
# The variables this play needs are duplicated at the top of vars/default.yml, so
# it stands on its own. (Applying the commented include above would also need
# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.)
#
# The one prerequisite it *does* need beyond Caddy's is buildah, and only on the
# build path: `podman` Recommends it on Ubuntu, so caddy/roles/podman has already
# installed it. Nothing needs it when the image is loaded from monoco, which is
# the recommended route in readme.md.
#
# Run with: ansible-playbook -u tanshu sms-wa-bridge/playbook.yml
# Requires caddy to have run first.
- name: Deploy sms-wa-bridge as a rootless Podman Quadlet
hosts: emily
gather_facts: true
@@ -41,19 +7,10 @@
- vars/default.yml
pre_tasks:
# This must happen before the app unit is started, not after: the generated
# unit references {{ network_name }}.network, and Quadlet fails *unit
# generation* (so `systemctl --user start` reports "unit not found") when a
# referenced .network file does not exist yet. The same include writes the
# Caddy-side drop-in that attaches Caddy to this network.
#
# network_name is a play var, so the include can see it directly.
# Quadlet needs the network unit before it generates the app unit.
- name: Register the bridge's network with Caddy
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
roles:
# The app: the data tree and the two bind-mounted files, the environment
# file, the image (loaded or built), the Quadlet unit, start + verify.
- sms-wa-bridge
# Appends this site's block to the shared Caddyfile and reloads Caddy.
- caddy
+5 -21
View File
@@ -1,32 +1,16 @@
---
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
# there. These handlers run as the connection user, so `systemctl --user` and
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
#
# Restart Caddy carries `when: not ansible_check_mode` — unlike the other copies
# of this file in the repository, which predate the idea — so that a `--check`
# run of this playbook is a preview and not a partial deploy. The `command`
# handler below needs no guard: Ansible skips those under --check on its own.
# Notified by this role when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site.
#
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
# not {{ caddyfile_path }}, which is the host side of it.
# A reload keeps live connections; a restart would drop every hosted site.
# The path is inside the container, not the host {{ caddyfile_path }}.
- name: Reload Caddy configuration
ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy"
# Notified by caddy/roles/register when this service's network unit, or the
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
# of `docker network connect`, so the container has to be recreated — a brief
# restart of every hosted site, not just this one. See caddy/readme.md,
# "Trade-off worth knowing".
#
# Defined after the reload above so that when both are notified the cheap config
# reload happens first.
# Quadlet cannot attach a network live, so Caddy must be recreated.
# Defined after the reload so the cheap reload runs first when both are notified.
# Under --check the unit was never written, so skip the restart.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
-10
View File
@@ -1,14 +1,4 @@
---
# Append this site's block to the shared Caddyfile.
#
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
# change as soon as the file is written; the handler only asks Caddy to re-read
# it. That is a reload, not a restart, so the other hosted sites are untouched.
#
# blockinfile is idempotent on its own via the marker, so there is no need to
# slurp the file and compare first — which also matters because slurp *fails*
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
# not run.
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
@@ -1,24 +1,12 @@
---
# All handlers run as the connection user ({{ podman_user }}), so systemctl
# --user works without setting XDG_RUNTIME_DIR.
#
# The `systemd_service` handlers — this one and Caddy's, in roles/caddy — carry
# `when: not ansible_check_mode`: under --check the unit file was never written,
# so systemd cannot find the unit and a restart would fail rather than be
# skipped. The `command` handler below needs no guard: Ansible skips those under
# --check on its own.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
# A changed unit file, a changed environment file, or a rebuilt image — all of
# them mean the app is recreated. Everything it owns is on the four bind mounts,
# so a recreate loses nothing: the linked-device session, the routing rules and
# the log all live on the host. It is a few seconds during which incoming SMS
# get a connection error from Caddy rather than a 200, which MacroDroid will
# retry or drop depending on how the action is configured.
# A recreate keeps state on the bind mounts; incoming SMS fail for a few seconds.
# Under --check the unit file was never written, so skip the restart.
- name: Restart sms-wa-bridge
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
+25 -121
View File
@@ -1,23 +1,7 @@
---
# sms-wa-bridge as a rootless Podman Quadlet unit.
#
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# ---------------------------------------------------------------------------
# The data tree, and the two bind-mounted files
# ---------------------------------------------------------------------------
# The order matters here, and it is not housekeeping: all four mount sources in
# the unit have to exist before it starts. Podman creates a missing bind-mount
# source as a *directory*, so a missing bridge.log would come up as a directory
# where the app expects a file and every append to it would fail, and a missing
# rules.json would break the re-read that happens on every request.
#
# The copy in readme.md makes the two directories itself, because it has to run
# *before* this play. These tasks are what make a fresh install and a migrated
# one the same shape, and what guarantees the file sources exist on the day the
# unit is first started.
# All four mount sources must exist before the unit starts.
# Podman creates a missing bind-mount source as a directory, which breaks the app.
- name: Ensure the sms-wa-bridge data directory exists
ansible.builtin.file:
path: "{{ host_directory }}"
@@ -34,16 +18,12 @@
group: "{{ podman_user }}"
mode: '0755'
loop:
# The paired WhatsApp session: the Chromium profile whatsapp-web.js logged
# in as. 336 MB on monoco, and not reproducible without scanning a QR code.
# The paired session cannot be recreated without scanning a QR code.
- wwebjs_auth
# Empty on monoco and unused by the current whatsapp-web.js, but mounted by
# the Docker-era container, so the migrated tree keeps its shape.
# Empty and unused, kept so the tree matches the Docker-era layout.
- wwebjs_session
# The app's own log file, bind-mounted as a *file*. state=touch with preserve is
# what makes this idempotent: without the two preserve flags every run would
# bump the mtime of a log the app is appending to and report itself changed.
# preserve stops every run from bumping the log's mtime and reporting a change.
- name: Ensure the log file the app appends to exists
ansible.builtin.file:
path: "{{ host_directory }}/bridge.log"
@@ -54,13 +34,7 @@
modification_time: preserve
access_time: preserve
# Seeded from the image's own rules.json, and then never touched again: the
# app re-reads this file from disk on every request, so live edits are the
# normal way to work and an overwriting play would silently revert them.
# force=no means "only transfer when the destination does not exist".
#
# On a migration this task finds the file the copy in readme.md put there and
# leaves it alone — which is the point.
# The app re-reads rules.json on every request, so never overwrite live edits.
- name: Seed the routing rules if there are none yet
ansible.builtin.copy:
src: "{{ playbook_dir }}/files/build/rules.json"
@@ -70,24 +44,8 @@
mode: '0644'
force: false
# The one failure the migration is most likely to arrive with, checked before
# anything expensive happens — and before the unit starts, because it cannot
# start usefully.
#
# With keep-id:uid=0 the app is container-root, which is {{ podman_user }} on the
# host. Host uid 0 is *not* mapped into the user namespace at all, so a
# root-owned file copied straight out of /var/lib/sms-wa-bridge (where everything
# is root-owned) is unwritable from inside the container: the app starts, then
# fails to write the session or append to the log. `tar --no-same-owner` in
# readme.md is what avoids it.
#
# Deliberately not `failed_when: false`: if `find` cannot read something in the
# tree, the tree is not provably clean, and that is the finding.
#
# Skipped under --check rather than run with `check_mode: false`: the directory
# above was not actually created in a check run, so `find` would fail on a
# missing path rather than answer the question. The assert below is written to
# tolerate that, and `default([])` is why.
# Container root is {{ podman_user }}, so a root-owned file copied from monoco is unwritable.
# Skip the find under --check because the directory above was never created.
- name: List anything in the data tree not owned by {{ podman_user }}
ansible.builtin.command: >-
find {{ host_directory }} ! -user {{ podman_user }}
@@ -107,12 +65,8 @@
`chown -R {{ podman_user }}:{{ podman_user }} {{ host_directory }}` if the
copy was otherwise good. See readme.md, "Migrating the data from monoco".
# ---------------------------------------------------------------------------
# The environment file (AUTH_SECRET)
# ---------------------------------------------------------------------------
# Consumed by podman through EnvironmentFile= in the Quadlet unit, not by
# Ansible, so it has to be written before the unit starts. 0600 because the unit
# that references it is world-readable and this is a shared secret.
# Podman reads this through EnvironmentFile=, so it must be written before the unit starts.
# 0600 keeps the shared secret out of the world-readable unit.
- name: Write the environment file
ansible.builtin.template:
src: sms-wa-bridge.env.j2
@@ -122,18 +76,8 @@
mode: '0600'
notify: Restart sms-wa-bridge
# ---------------------------------------------------------------------------
# The image
# ---------------------------------------------------------------------------
# The image exists in no registry, so it is either loaded from monoco
# (`docker save | podman load`, the recommended route — see readme.md) or built
# here from the vendored context. This looks for it under both names, re-tags a
# loaded one, and only then falls back to building.
#
# `check_mode: false` on the two `podman image exists` probes below is what makes
# `--check` an honest preview: a command task is normally skipped under --check,
# and a skipped probe would turn the build decision into a guess. They only read
# the host, so running them in a check run changes nothing.
# The image exists in no registry, so load it from monoco or build it here.
# Run the probes in check mode so --check reports the real build decision.
- name: Check for the locally built image
ansible.builtin.command: "podman image exists {{ image }}"
register: sms_image_local
@@ -141,15 +85,8 @@
failed_when: false
check_mode: false
# `docker save` writes an unqualified RepoTags and podman load qualifies it
# against the default registry, so an image loaded from monoco lands as
# docker.io/library/sms-wa-bridge:latest rather than the localhost/... name the
# unit uses. Verified on emily with a real docker-archive round trip.
#
# Re-tagging rather than rebuilding is the point: the loaded image is the exact
# one whose WhatsApp session was paired. A rebuild re-resolves whatsapp-web.js
# (^1.26.0 in package.json), and a differently-versioned client can refuse that
# session and force a new QR scan.
# podman load qualifies a docker-saved tag against docker.io, not localhost.
# Re-tag rather than rebuild: a rebuild can break the paired WhatsApp session.
- name: Re-tag an image that was loaded from monoco
when: (sms_image_local.rc | default(1)) != 0
block:
@@ -166,10 +103,6 @@
changed_when: true
notify: Restart sms-wa-bridge
# One decision, made once, so the build context is only materialised on the host
# when something is actually going to read it. Set with `podman tag`/`podman
# build` staying check-mode aware: under --check they are skipped, while the two
# probes above really ran, so this reflects the host as it is.
- name: Decide whether the image has to be built
ansible.builtin.set_fact:
sms_build_image: >-
@@ -177,15 +110,11 @@
and (sms_image_docker_hub.rc | default(1)) != 0)
or (rebuild_image | bool) }}
# Only reached when no image is present under either name, or when the operator
# asked for a rebuild. `podman build` needs buildah, which `podman` Recommends on
# Ubuntu and caddy/roles/podman therefore installed.
# Only runs when no image exists or rebuild_image is set.
- name: Build the image from the vendored Dockerfile
when: sms_build_image | bool
block:
# The context is copied rather than built from the repository working copy,
# so the host always builds from a fixed, tanshu-owned path — and so the
# files the running image came from are visible next to their data.
# Copy the context so the host builds from a fixed path next to its data.
- name: Ensure the image build context directory exists
ansible.builtin.file:
path: "{{ build_dir }}"
@@ -208,9 +137,6 @@
changed_when: true
notify: Restart sms-wa-bridge
# ---------------------------------------------------------------------------
# The unit
# ---------------------------------------------------------------------------
- name: Install the sms-wa-bridge Quadlet unit
ansible.builtin.template:
src: sms-wa-bridge.container.j2
@@ -222,11 +148,7 @@
- Reload systemd user units
- Restart sms-wa-bridge
# Everything from here on inspects a *running* container, and none of it is
# meaningful under --check: the unit was never written, so systemd cannot find
# it. Guarding the runtime half rather than tolerating its failures is what makes
# `ansible-playbook --check` a usable preview of a deploy — it reports the files,
# templates and image decisions without touching the host.
# The runtime checks need a real unit, so skip them under --check.
- name: Start sms-wa-bridge
when: not ansible_check_mode
block:
@@ -237,9 +159,7 @@
state: started
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a missing network unit, an
# unwritable /app, an image that is not there). Pull the real reason out.
# systemd reports only a generic exit error, so collect the real reason.
- name: Collect sms-wa-bridge diagnostics
ansible.builtin.command: "{{ item }}"
loop:
@@ -258,8 +178,7 @@
ansible.builtin.fail:
msg: "sms-wa-bridge did not start — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
- name: Verify sms-wa-bridge is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: sms_active
@@ -267,10 +186,8 @@
failed_when: sms_active.stdout != 'active'
when: not ansible_check_mode
# `is-active` says the container is running; it does not say the app is serving.
# `Restart=always` means even a crash-looping container is reported active, and
# the image ships no HEALTHCHECK, so the unit defines one (see the template) and
# this runs exactly that command once, synchronously.
# is-active still reports active for a crash-looping container, and the image ships no HEALTHCHECK.
# The unit defines one instead, and this runs exactly that command once.
- name: Wait for sms-wa-bridge to pass its health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: sms_health
@@ -280,14 +197,8 @@
changed_when: false
when: not ansible_check_mode
# ---------------------------------------------------------------------------
# What a running container does not tell you
# ---------------------------------------------------------------------------
# The unit's health check proves the HTTP server is up. It does not prove
# WhatsApp is linked, and an unlinked bridge is the one failure here that is
# completely silent: the process is healthy, every SMS is accepted with a 200,
# and nothing is ever delivered. /health publishes exactly that as waReady, so
# read it back and say so out loud.
# The health check proves HTTP is up, not that WhatsApp is linked.
# An unlinked bridge accepts every SMS with 200 and delivers nothing.
- name: Read the app's own health payload
ansible.builtin.command: >-
podman exec {{ container_name }} wget -q -O -
@@ -302,10 +213,7 @@
sms_health_json: "{{ sms_health_payload.stdout | from_json }}"
when: (sms_health_payload.rc | default(1)) == 0
# Reported, not asserted: an unpaired bridge is a legitimate state — a fresh
# install sits here until someone scans the QR code — and the deployment is
# still correct. Two tasks rather than one message with a conditional inside it,
# because the interesting case deserves its own sentence.
# An unpaired bridge is a valid fresh-install state, so report it without failing.
- name: Report what the bridge says about itself
ansible.builtin.debug:
msg: >-
@@ -337,11 +245,7 @@
- sms_health_json is defined
- sms_health_json.waReady | default(false)
# The data half of the same question, asked from the host rather than from the
# app: wwebjs_auth/session is the Chromium profile the linked device lives in, and
# it is what the copy in readme.md puts there. Missing means the bridge will
# print a fresh QR code — correct on a brand-new host, and the sign that the
# copy has not run yet on a migration.
# Missing wwebjs_auth/session means the bridge will print a fresh QR code.
- name: Check whether a linked WhatsApp session was copied in
ansible.builtin.stat:
path: "{{ host_directory }}/wwebjs_auth/session"
+12 -76
View File
@@ -1,112 +1,48 @@
---
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
# file wholesale, because it also sets `host_directory` — to the Caddy data
# directory — and every playbook defines that for itself. Keep these three in
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
# belongs to the podman tasks, which this play does not run: the bridge
# publishes nothing host-side.)
#
# podman_user is derived from the connection: this play carries no become, so
# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts
# (set in playbook.yml).
# Derived from the connection user; requires gather_facts.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# ---------------------------------------------------------------------------
# sms-wa-bridge
# ---------------------------------------------------------------------------
host: bridge.tanshu.dev
container_name: sms-wa-bridge
unit_name: "{{ container_name }}.service"
# The Quadlet file is <network_name>.network, and caddy/roles/register pins the
# Podman network name to <network_name>_net — so: sms-wa-bridge.network,
# sms-wa-bridge_net, which is the name the Docker-era deployment used.
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
network_name: "{{ container_name }}"
# There is no registry: the image was built locally on monoco with
# `docker build -t sms-wa-bridge` and exists nowhere else. Two ways to get it
# onto emily, and the playbook supports both:
#
# * `docker save sms-wa-bridge:latest | podman load` — the recommended route,
# and the one readme.md documents. It carries over the *exact* image whose
# WhatsApp session was paired; a rebuild re-resolves whatsapp-web.js
# (^1.26.0 in package.json), and a differently-versioned client can refuse
# that session and force a new QR scan.
# * `podman build` from the vendored context in files/build/ — what the play
# does by itself when no image is present, so a host with no monoco still
# gets a working deployment.
#
# Fully qualified either way: rootless Podman resolves short names against
# unqualified-search-registries, and there is no TTY to answer a prompt when a
# systemd unit starts. The unit also sets Pull=never, so an image that is not
# there fails loudly instead of silently becoming a Docker Hub lookup.
# The image exists in no registry; load it from monoco or build it here.
# A rebuild can re-resolve whatsapp-web.js and break the paired session, so prefer podman load.
# Keep it fully qualified so rootless Podman never prompts; there is no TTY.
image: localhost/sms-wa-bridge:latest
# What `podman load` actually calls a docker-saved unqualified tag: it qualifies
# it against the default registry instead of `localhost`. Verified on emily with
# a real `docker save` round trip. The play re-tags this name to `image` above,
# so loading the image before the play is enough — see readme.md.
# What podman load calls a docker-saved tag; the play re-tags it to image.
image_docker_hub: docker.io/library/sms-wa-bridge:latest
# `podman build` is skipped when the image is already present. Set this to true
# to rebuild from files/build/ anyway, e.g. after editing server.js:
#
# ansible-playbook -u tanshu ../sms-wa-bridge/playbook.yml -e rebuild_image=true
# Set true to rebuild from files/build/ after editing server.js.
rebuild_image: false
# The whole of this service's state, and a faithful analogue of monoco's
# /var/lib/sms-wa-bridge — same four entries, same layout, so the tar in
# readme.md lands as-is:
#
# wwebjs_auth/ the paired WhatsApp session (336 MB of the 336 MB tree)
# wwebjs_session/ empty, and vestigial; carried over to keep the shape
# rules.json routing rules, re-read from disk on every request
# bridge.log the app's own log file
# Same four entries and layout as monoco's /var/lib/sms-wa-bridge, so the tar lands as-is.
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
# The image build context, kept out of host_directory so the data tree stays a
# faithful copy of the old one. Only used when the play builds the image.
build_dir: "{{ podman_data_dir }}/{{ container_name }}-build"
# The port server.js listens on inside the container (PORT's default). Caddy
# proxies to it; nothing is published on the host, exactly as on monoco.
container_port: 8080
# The shared secret MacroDroid sends as `X-Auth`, carried over verbatim from the
# Docker-era container so the cutover is a URL change and nothing else. It is
# committed in plaintext because it always has been (the old app readme had it,
# and the Docker-era container took it as an -e flag); the unit keeps it out of
# its own world-readable file via EnvironmentFile=.
# Kept verbatim so the cutover only changes the URL.
auth_secret: "64f205031acc01744b6edc23"
# AUTH_SECRET lives here rather than as an Environment= line in the unit: that
# file is world-readable in ~/.config. Read by podman (--env-file), not by
# Ansible, so it has to be written before the unit starts.
# Read by podman through EnvironmentFile=, not by Ansible, so write it before the unit starts.
env_file: "{{ host_directory }}/{{ container_name }}.env"
# The image ships no HEALTHCHECK, so the unit defines one and the play gates on
# it. /health is the app's own endpoint and `wget` is what the runtime image has
# — the Dockerfile installs wget, not curl. It answers 200 whenever the HTTP
# server is up, so this proves the server is serving; it does *not* prove
# WhatsApp is paired. That is what the /health *payload* shows, and the play
# reports it.
# The runtime image has wget, not curl.
# /health answers 200 whenever the server is up, not when WhatsApp is paired.
health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/health"
# ---------------------------------------------------------------------------
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
# The same two lines monoco's Caddyfile has had since the bridge was deployed
# (`bridge.tanshu.dev -> sms-wa-bridge:8080`), so nothing about the public
# contract changes but the host it runs on.
snippet_block: |
{{ host }} {
reverse_proxy {{ container_name }}:{{ container_port }}