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:
1 parent
2aab1aec1e
commit
476a6d7cd0
85 files changed
+372
-2433
No files matched your search
@@ -1,31 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Audiobookshelf as a rootless Podman Quadlet unit, behind the shared Caddy unit
|
# Requires caddy to have run first. State is SQLite, not Postgres.
|
||||||
# (see caddy/playbook.yml).
|
|
||||||
#
|
|
||||||
# Unlike bitwarden/ and tandoor/, this app has no database to attach: its state
|
|
||||||
# is one SQLite file plus files under ~/podman-data/audiobookshelf, so there is
|
|
||||||
# no postgres role here and nothing to restore from the shared cluster.
|
|
||||||
#
|
|
||||||
# Connects as the container user and runs everything as that user: the Quadlet
|
|
||||||
# units belong to it, so the connection's PAM session is what makes
|
|
||||||
# `systemctl --user` work without any environment juggling.
|
|
||||||
#
|
|
||||||
# gather_facts is required: podman_user and the home directory come from the
|
|
||||||
# user_id / user_dir facts.
|
|
||||||
#
|
|
||||||
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
|
|
||||||
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
|
|
||||||
# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and
|
|
||||||
# a host that has already run caddy/playbook.yml has them:
|
|
||||||
#
|
|
||||||
# - name: Rootless Podman prerequisites
|
|
||||||
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml"
|
|
||||||
#
|
|
||||||
# The variables this play needs are duplicated at the top of vars/default.yml, so
|
|
||||||
# it stands on its own. (Applying the commented include above would also need
|
|
||||||
# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.)
|
|
||||||
#
|
|
||||||
# Run with: ansible-playbook -u tanshu audiobookshelf/playbook.yml
|
|
||||||
- name: Deploy Audiobookshelf as a rootless Podman Quadlet
|
- name: Deploy Audiobookshelf as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -33,18 +7,10 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# unit references {{ network_name }}.network, and Quadlet fails *unit
|
|
||||||
# generation* (so `systemctl --user start` reports "unit not found") when a
|
|
||||||
# referenced .network file does not exist yet. The same include writes the
|
|
||||||
# Caddy-side drop-in that attaches Caddy to this network.
|
|
||||||
#
|
|
||||||
# network_name is a play var, so the include can see it directly.
|
|
||||||
- name: Register Audiobookshelf's network with Caddy
|
- name: Register Audiobookshelf's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
# The app: the four data directories, the Quadlet unit, start + verify.
|
|
||||||
- audiobookshelf
|
- audiobookshelf
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -1,9 +1,4 @@
|
|||||||
---
|
---
|
||||||
# Audiobookshelf as a rootless Podman Quadlet unit.
|
|
||||||
#
|
|
||||||
# Everything here runs as {{ podman_user }} — the connection user. No become, no
|
|
||||||
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
|
|
||||||
# user manager is reachable through the standard environment.
|
|
||||||
|
|
||||||
- name: Ensure the Audiobookshelf data directories exist
|
- name: Ensure the Audiobookshelf data directories exist
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -13,9 +8,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
loop:
|
loop:
|
||||||
# config/ and metadata/ are the irreplaceable 22 MB — absdatabase.sqlite,
|
# config/ and metadata/ hold irreplaceable state; the media directories can be re-downloaded.
|
||||||
# migrations, backups, cache, logs. audiobooks/ and podcasts/ are the
|
|
||||||
# library, which the app reads and podcast downloads are written into.
|
|
||||||
- "{{ config_dir }}"
|
- "{{ config_dir }}"
|
||||||
- "{{ metadata_dir }}"
|
- "{{ metadata_dir }}"
|
||||||
- "{{ audiobooks_dir }}"
|
- "{{ audiobooks_dir }}"
|
||||||
@@ -41,9 +34,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd reports only a generic exit error, so collect the real reason.
|
||||||
# else, which is useless for the usual failures (an unattached network, an
|
|
||||||
# unwritable /config, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Audiobookshelf diagnostics
|
- name: Collect Audiobookshelf diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -62,18 +53,15 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Audiobookshelf did not start — see the diagnostics above."
|
msg: "Audiobookshelf did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify Audiobookshelf is running
|
- name: Verify Audiobookshelf is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: audiobookshelf_active
|
register: audiobookshelf_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: audiobookshelf_active.stdout != 'active'
|
failed_when: audiobookshelf_active.stdout != 'active'
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can serve.
|
# is-active still reports active for a crash-looping container, and the image ships no HEALTHCHECK.
|
||||||
# `Restart=always` means even a crash-looping container is reported active. The
|
# The unit defines one instead, and this runs exactly that command once.
|
||||||
# image ships no HEALTHCHECK, so the unit defines one (HealthCmd=, see the
|
|
||||||
# template) and this runs exactly that command once, synchronously.
|
|
||||||
- name: Wait for Audiobookshelf to pass its health check
|
- name: Wait for Audiobookshelf to pass its health check
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: audiobookshelf_health
|
register: audiobookshelf_health
|
||||||
@@ -88,10 +76,7 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: audiobookshelf_mounts.rc != 0
|
failed_when: audiobookshelf_mounts.rc != 0
|
||||||
|
|
||||||
# The health check proves the server started — it exits 1 with EROFS if /config
|
# The health check proves the server started, not that /config is the bind mount.
|
||||||
# is not writable — but not that /config is the *bind mount*. This does: the
|
|
||||||
# database the app has just created has to appear on the host, not inside the
|
|
||||||
# container's writable layer.
|
|
||||||
- name: Check the database landed on the host
|
- name: Check the database landed on the host
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "{{ config_dir }}/absdatabase.sqlite"
|
path: "{{ config_dir }}/absdatabase.sqlite"
|
||||||
@@ -106,10 +91,7 @@
|
|||||||
container, visible on the host, so the bind mount is real.
|
container, visible on the host, so the bind mount is real.
|
||||||
when: audiobookshelf_db.stat.exists
|
when: audiobookshelf_db.stat.exists
|
||||||
|
|
||||||
# This is the check that catches a broken or missing Volume= line, which is the
|
# A missing Volume= would let the app write to the container layer and lose the data on recreate.
|
||||||
# one failure here that is silent: the app would run perfectly well with its
|
|
||||||
# data in the container's writable layer, and lose all of it on the next
|
|
||||||
# recreate.
|
|
||||||
- name: Fail if the database is not on the host
|
- name: Fail if the database is not on the host
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that: audiobookshelf_db.stat.exists
|
that: audiobookshelf_db.stat.exists
|
||||||
|
|||||||
@@ -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
|
# A reload keeps live connections; a restart would drop every hosted site.
|
||||||
# connections; a restart would briefly drop every hosted site.
|
# The path is inside the container, not the host {{ caddyfile_path }}.
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet cannot attach a network live, so Caddy must be recreated.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first when both are notified.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
---
|
---
|
||||||
# Append this site's block to the shared Caddyfile.
|
|
||||||
#
|
|
||||||
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
|
||||||
# change as soon as the file is written; the handler only asks Caddy to re-read
|
|
||||||
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
|
||||||
#
|
|
||||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
|
||||||
# slurp the file and compare first — which also matters because slurp *fails*
|
|
||||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
|
||||||
# not run.
|
|
||||||
|
|
||||||
- name: "Add the {{ host }} site block to the Caddyfile"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
|
|||||||
@@ -1,75 +1,36 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Derived from the connection user; requires gather_facts.
|
||||||
# Rootless Podman / Quadlet
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that
|
|
||||||
# file wholesale, because it also sets `host_directory` — to the Caddy data
|
|
||||||
# directory — and every playbook defines that for itself. Keep these three in
|
|
||||||
# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start,
|
|
||||||
# belongs to the podman tasks, which this play does not run: Audiobookshelf
|
|
||||||
# publishes nothing host-side.)
|
|
||||||
#
|
|
||||||
# podman_user is derived from the connection: this play carries no become, so
|
|
||||||
# the Quadlet units belong to whoever Ansible connects as. Requires gather_facts
|
|
||||||
# (set in playbook.yml).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Audiobookshelf
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
host: audiobooks.tanshu.com
|
host: audiobooks.tanshu.com
|
||||||
|
|
||||||
container_name: audiobookshelf
|
container_name: audiobookshelf
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Already fully qualified, so rootless Podman never resolves a short name
|
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
|
||||||
# against unqualified-search-registries — there is no TTY to answer a prompt
|
|
||||||
# when the systemd unit starts.
|
|
||||||
image: ghcr.io/advplyr/audiobookshelf:latest
|
image: ghcr.io/advplyr/audiobookshelf:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
|
||||||
# Podman network name to <network_name>_net (so: audiobookshelf.network,
|
|
||||||
# audiobookshelf_net).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
# The four mount targets, straight from the Docker-era `docker run`. Two are
|
# config/ and metadata/ hold irreplaceable state; the media directories can be re-downloaded.
|
||||||
# state, two are the library:
|
|
||||||
#
|
|
||||||
# config/ absdatabase.sqlite + migrations/ irreplaceable, 2.6 MB
|
|
||||||
# metadata/ backups/, cache/, logs/, streams/ irreplaceable, 19 MB
|
|
||||||
# audiobooks/ the book library 15 GB, re-downloadable media
|
|
||||||
# podcasts/ downloaded podcast episodes
|
|
||||||
#
|
|
||||||
# The image declares NO VOLUME (verified against the published image), so these
|
|
||||||
# are conventional bind-mount targets, not image volumes: without the Volume=
|
|
||||||
# lines in the unit Podman creates nothing, the data lands in the container's
|
|
||||||
# writable layer, and recreating the container destroys it.
|
|
||||||
config_dir: "{{ host_directory }}/config"
|
config_dir: "{{ host_directory }}/config"
|
||||||
metadata_dir: "{{ host_directory }}/metadata"
|
metadata_dir: "{{ host_directory }}/metadata"
|
||||||
audiobooks_dir: "{{ host_directory }}/audiobooks"
|
audiobooks_dir: "{{ host_directory }}/audiobooks"
|
||||||
podcasts_dir: "{{ host_directory }}/podcasts"
|
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
|
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
|
timezone: Asia/Kolkata
|
||||||
|
|
||||||
# The image ships no HEALTHCHECK, so this play defines one on the unit.
|
# The image ships no HEALTHCHECK; /healthcheck is the app's own endpoint.
|
||||||
# /healthcheck is the app's own unauthenticated endpoint and answers 200 OK.
|
# The runtime image has wget, not curl.
|
||||||
# `wget` is what the runtime image has — `curl` exists only in its builder
|
|
||||||
# stage.
|
|
||||||
health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/healthcheck"
|
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
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
|
|||||||
+2
-38
@@ -1,27 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet
|
# Requires caddy and postgres to have run first.
|
||||||
# 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
|
|
||||||
- name: Deploy Vaultwarden as a rootless Podman Quadlet
|
- name: Deploy Vaultwarden as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -29,25 +7,11 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# 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.
|
|
||||||
- name: Register Bitwarden's network with Caddy
|
- name: Register Bitwarden's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- postgres
|
||||||
# The app: data directory, the 0600 environment file, the Quadlet unit,
|
|
||||||
# start + verify.
|
|
||||||
- bitwarden
|
- bitwarden
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
# A changed environment file or unit file changes the generated unit, so
|
# The vault is in Postgres and the attachments are on a bind mount, so a recreate loses nothing.
|
||||||
# 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.
|
|
||||||
- name: Restart Bitwarden
|
- name: Restart Bitwarden
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
|
|||||||
@@ -1,15 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Vaultwarden (the Bitwarden-compatible server) as a rootless Podman Quadlet
|
# /data holds attachments, sends, signing keys, admin config and the icon cache. The vault is in Postgres.
|
||||||
# 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).
|
|
||||||
- name: Ensure the Vaultwarden data directory exists
|
- name: Ensure the Vaultwarden data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ data_dir }}"
|
path: "{{ data_dir }}"
|
||||||
@@ -18,10 +8,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# The app's secrets and database coordinates are consumed by systemd
|
# systemd reads this through EnvironmentFile= before the unit starts, and DATABASE_URL carries the Postgres password.
|
||||||
# (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.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: bitwarden.env.j2
|
src: bitwarden.env.j2
|
||||||
@@ -51,9 +38,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd only reports "control process exited with error code", so pull the real reason.
|
||||||
# else, which is useless for the usual failures (an unattached network, an
|
|
||||||
# unreachable database, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Vaultwarden diagnostics
|
- name: Collect Vaultwarden diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -72,35 +57,14 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Vaultwarden did not start — see the diagnostics above."
|
msg: "Vaultwarden did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so `systemctl --user enable` is not needed on a generated unit.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify Vaultwarden is running
|
- name: Verify Vaultwarden is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: bitwarden_active
|
register: bitwarden_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: bitwarden_active.stdout != 'active'
|
failed_when: bitwarden_active.stdout != 'active'
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can serve.
|
# `is-active` cannot tell whether the app serves, so run the image's own /healthcheck.sh.
|
||||||
# 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.
|
|
||||||
- name: Wait for Vaultwarden to answer its own health check
|
- name: Wait for Vaultwarden to answer its own health check
|
||||||
block:
|
block:
|
||||||
- name: Wait for Vaultwarden to answer its own health check
|
- name: Wait for Vaultwarden to answer its own health check
|
||||||
@@ -111,9 +75,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# A bare "non-zero return code" hides the one line that matters — curl's
|
# A bare "non-zero return code" hides curl's reason or a missing script, so print the probe output.
|
||||||
# reason, or "no such file or directory" for a moved script — so pull the
|
|
||||||
# probe's own output and what the app is saying.
|
|
||||||
- name: Collect readiness diagnostics
|
- name: Collect readiness diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -135,9 +97,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Vaultwarden is running but did not answer /alive — see the diagnostics above."
|
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
|
# `db` is the shared NetworkAlias, and the container name comes from the attach role, so both must resolve.
|
||||||
# roles/postgres attached Postgres to. Check the name the environment file
|
|
||||||
# actually asks for, plus the shared `db` alias.
|
|
||||||
- name: Confirm Vaultwarden can resolve its database
|
- name: Confirm Vaultwarden can resolve its database
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} getent hosts {{ item }}
|
podman exec {{ container_name }} getent hosts {{ item }}
|
||||||
@@ -160,12 +120,7 @@
|
|||||||
path: "{{ data_dir }}/db.sqlite3"
|
path: "{{ data_dir }}/db.sqlite3"
|
||||||
register: bitwarden_stale_sqlite
|
register: bitwarden_stale_sqlite
|
||||||
|
|
||||||
# SQLite in /data is the image's own default, and the Docker-era deployment left
|
# The Docker-era db.sqlite3 looks like a restore source, but it is inert while DATABASE_URL is set, so warn rather than fail.
|
||||||
# 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.
|
|
||||||
- name: Warn about the stale SQLite database
|
- name: Warn about the stale SQLite database
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
@@ -1,27 +1,14 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
# A reload keeps existing connections. A restart would briefly drop every hosted site.
|
||||||
# there. These handlers run as the connection user, so `systemctl --user` and
|
# The config path is inside the container, not caddyfile_path on the host.
|
||||||
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
|
|
||||||
|
|
||||||
# Notified by this role when the Caddyfile changes. A reload keeps existing
|
|
||||||
# connections; a restart would briefly drop every hosted site.
|
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -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"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
|
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
|
||||||
# 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".
|
|
||||||
- name: Restart Postgres
|
- name: Restart Postgres
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ postgres_unit }}"
|
name: "{{ postgres_unit }}"
|
||||||
|
|||||||
@@ -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
|
- name: Attach Bitwarden to Postgres
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
|
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
|
# The restart that joins Postgres to bitwarden_net is a handler, so flush it before the app starts or the health check fails.
|
||||||
# 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.
|
|
||||||
- name: Apply the Postgres network change before starting Vaultwarden
|
- name: Apply the Postgres network change before starting Vaultwarden
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
+11
-48
@@ -1,86 +1,49 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
|
||||||
# Rootless Podman / Quadlet
|
# podman_user comes from the connection user, so the play needs gather_facts.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The service keeps the Docker-era name everywhere. Only the image is vaultwarden/server.
|
||||||
# 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.
|
|
||||||
host: vault.tanshu.com
|
host: vault.tanshu.com
|
||||||
|
|
||||||
container_name: bitwarden
|
container_name: bitwarden
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
|
||||||
# the systemd unit starts.
|
|
||||||
image: docker.io/vaultwarden/server:latest
|
image: docker.io/vaultwarden/server:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is bitwarden.network.
|
||||||
# Podman network name to <network_name>_net (so: bitwarden.network,
|
|
||||||
# bitwarden_net).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ 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
|
# The image's VOLUME is /data and ROCKET_PORT is 80, so this is the bind mount target and the proxied port.
|
||||||
# mount target and the port Caddy proxies to.
|
|
||||||
data_dir: "{{ host_directory }}/data"
|
data_dir: "{{ host_directory }}/data"
|
||||||
container_port: 80
|
container_port: 80
|
||||||
|
|
||||||
# DATABASE_URL carries the Postgres password, and the Quadlet unit is
|
# DATABASE_URL carries the Postgres password, so keep it in this 0600 file that systemd reads.
|
||||||
# world-readable in ~/.config, so it goes in this 0600 file instead and is read
|
|
||||||
# by systemd through EnvironmentFile=.
|
|
||||||
env_file: "{{ host_directory }}/bitwarden.env"
|
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_container: postgres
|
||||||
postgres_unit: "{{ postgres_container }}.service"
|
postgres_unit: "{{ postgres_container }}.service"
|
||||||
|
|
||||||
# The credentials the Docker-era playbook embedded in its db_url, kept as-is so
|
# Kept as-is so the restored database and the app's connection string agree.
|
||||||
# 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).
|
|
||||||
pg_user: postgres
|
pg_user: postgres
|
||||||
pg_password: '123456'
|
pg_password: '123456'
|
||||||
pg_database: bitwarden
|
pg_database: bitwarden
|
||||||
|
|
||||||
# Hostname, not an IP, and the same `postgres` name the old db_url used:
|
# A hostname, not an IP: postgres/roles/attach joins the container to bitwarden_net, which makes it resolve.
|
||||||
# postgres/roles/attach joins the postgres container to bitwarden_net, which is
|
|
||||||
# what makes this resolve. (`db`, the shared NetworkAlias, resolves too.)
|
|
||||||
database_url: "postgresql://{{ pg_user }}:{{ pg_password }}@{{ postgres_container }}:5432/{{ pg_database }}"
|
database_url: "postgresql://{{ pg_user }}:{{ pg_password }}@{{ postgres_container }}:5432/{{ pg_database }}"
|
||||||
|
|
||||||
# Vaultwarden validates that DOMAIN carries a scheme and serves its web vault
|
# Vaultwarden requires a scheme here and builds its web vault and API links from it.
|
||||||
# and API links from it. The old container set exactly this.
|
|
||||||
domain: "https://{{ host }}"
|
domain: "https://{{ host }}"
|
||||||
|
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
# No request-body limit: Caddy streams request bodies and imposes none by
|
# Caddy streams request bodies with no limit by default, so there is no client_max_body_size equivalent.
|
||||||
# default, so the 525M `client_max_body_size` the dead nginx front end carried
|
|
||||||
# has no equivalent — and needs none.
|
|
||||||
snippet_block: |
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
reverse_proxy {{ container_name }}:{{ container_port }}
|
reverse_proxy {{ container_name }}:{{ container_port }}
|
||||||
|
|||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
@@ -15,15 +13,7 @@
|
|||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
listen: Restart Caddy
|
listen: Restart Caddy
|
||||||
|
|
||||||
# Reference implementation for service playbooks. A service that appends a site
|
# The container path is the mount, /etc/caddy/Caddyfile, not caddyfile_path.
|
||||||
# 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.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }}
|
podman exec {{ caddy_name }}
|
||||||
|
|||||||
@@ -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
|
- name: Ensure Caddy directories exist
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -23,13 +18,8 @@
|
|||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
register: caddyfile_stat
|
register: caddyfile_stat
|
||||||
|
|
||||||
# Caddy exits 1 with a bare "EOF" on an empty config file, so an empty
|
# Caddy exits 1 on an empty config file, so it needs a valid one first.
|
||||||
# Caddyfile means caddy.service crash-loops. Seed a minimal valid config.
|
# The global options block has to come before any site block.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
- name: Seed the Caddyfile with a valid empty config
|
- name: Seed the Caddyfile with a valid empty config
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ caddyfile_path }}"
|
dest: "{{ caddyfile_path }}"
|
||||||
@@ -72,9 +62,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd only reports a generic exit code, so collect the real reason.
|
||||||
# else, which is useless for the usual failures (a port already bound, a
|
|
||||||
# missing image, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Caddy diagnostics
|
- name: Collect Caddy diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -92,8 +80,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Caddy did not start — see the diagnostics above."
|
msg: "Caddy did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
|
||||||
# `systemctl enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify Caddy is running
|
- name: Verify Caddy is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ caddy_unit }}"
|
ansible.builtin.command: "systemctl --user is-active {{ caddy_unit }}"
|
||||||
register: caddy_active
|
register: caddy_active
|
||||||
|
|||||||
@@ -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
|
- name: Fail if the connecting user resolved to root
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
@@ -20,34 +15,21 @@
|
|||||||
ansible.builtin.apt:
|
ansible.builtin.apt:
|
||||||
name:
|
name:
|
||||||
- podman
|
- 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
|
state: present
|
||||||
update_cache: true
|
update_cache: true
|
||||||
cache_valid_time: 3600
|
cache_valid_time: 3600
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
# Without lingering, the user's systemd manager is torn down when the last
|
# Without lingering, the user manager stops with the last session and units
|
||||||
# session ends: Quadlet units stop, and they never start at boot.
|
# never start at boot.
|
||||||
- name: Enable systemd lingering for {{ podman_user }}
|
- name: Enable systemd lingering for {{ podman_user }}
|
||||||
ansible.builtin.command: "loginctl enable-linger {{ podman_user }}"
|
ansible.builtin.command: "loginctl enable-linger {{ podman_user }}"
|
||||||
args:
|
args:
|
||||||
creates: "/var/lib/systemd/linger/{{ podman_user }}"
|
creates: "/var/lib/systemd/linger/{{ podman_user }}"
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
# Rootless containers cannot bind host ports below 1024 unless the kernel
|
# Rootless containers cannot bind ports below 1024 unless the kernel floor is
|
||||||
# floor is lowered. Caddy publishes 80/443.
|
# lowered. Applied inline, because a handler would run after Caddy already bound.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
- name: Read the current unprivileged port floor
|
- name: Read the current unprivileged port floor
|
||||||
ansible.builtin.command: sysctl -n net.ipv4.ip_unprivileged_port_start
|
ansible.builtin.command: sysctl -n net.ipv4.ip_unprivileged_port_start
|
||||||
register: podman_port_floor
|
register: podman_port_floor
|
||||||
|
|||||||
@@ -1,33 +1,6 @@
|
|||||||
---
|
---
|
||||||
# Register a service's logical network with Caddy.
|
# Include from a service playbook with network_name set. The play must define
|
||||||
#
|
# the "Restart Caddy" handler.
|
||||||
# Caddy is the *base* unit and knows nothing about its upstreams. Each service
|
|
||||||
# adds itself by writing two files into the Quadlet directory:
|
|
||||||
#
|
|
||||||
# 1. its own <name>.network unit, and
|
|
||||||
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
|
|
||||||
#
|
|
||||||
# Caddy's own playbook never changes when a service is added or removed.
|
|
||||||
#
|
|
||||||
# Include this from a service playbook:
|
|
||||||
#
|
|
||||||
# - name: Register this service with Caddy
|
|
||||||
# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
|
||||||
# vars:
|
|
||||||
# network_name: "{{ container_name }}"
|
|
||||||
#
|
|
||||||
# The including play must also run the `podman` role (for podman_user and
|
|
||||||
# quadlet_dir) and define a "Restart Caddy" handler:
|
|
||||||
#
|
|
||||||
# - name: Restart Caddy
|
|
||||||
# ansible.builtin.systemd_service:
|
|
||||||
# name: caddy.service
|
|
||||||
# scope: user
|
|
||||||
# state: restarted
|
|
||||||
# daemon_reload: true
|
|
||||||
# listen: Restart Caddy
|
|
||||||
#
|
|
||||||
# It runs as the connection user, which must be {{ podman_user }}.
|
|
||||||
|
|
||||||
- name: "Create the {{ network_name }} network unit"
|
- name: "Create the {{ network_name }} network unit"
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -36,15 +9,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# {{ network_name }} — logical app network.
|
# Managed by Ansible. NetworkName pins the Podman name; isolate blocks
|
||||||
# Managed by Ansible. Do not edit by hand.
|
# traffic between separate networks.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
[Network]
|
[Network]
|
||||||
NetworkName={{ network_name }}_net
|
NetworkName={{ network_name }}_net
|
||||||
Options=isolate=true
|
Options=isolate=true
|
||||||
@@ -57,10 +23,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# Added by the {{ network_name }} playbook. Do not edit by hand.
|
# Added by {{ network_name }}. Deleting it detaches the network from
|
||||||
#
|
# Caddy on the next daemon-reload.
|
||||||
# Quadlet merges this into caddy.container. Deleting this file removes
|
|
||||||
# the network from Caddy on the next daemon-reload.
|
|
||||||
[Container]
|
[Container]
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
notify: Restart Caddy
|
notify: Restart Caddy
|
||||||
+5
-25
@@ -1,46 +1,26 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Set gather_facts: true, because these values come from ansible_facts.
|
||||||
# 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.
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
|
|
||||||
# State and config live in the user's home, not /var/lib. Taken from the
|
|
||||||
# user_dir fact, so this is correct even for users whose home is not
|
|
||||||
# /home/<name>.
|
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# Rootless containers cannot publish ports below 1024 without this sysctl.
|
# Caddy publishes 80/443, which needs the unprivileged port floor lowered.
|
||||||
# Caddy needs 80/443, so patching it down to 80 is the standard trade.
|
|
||||||
podman_unprivileged_port_start: 80
|
podman_unprivileged_port_start: 80
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Caddy
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddy_unit: "{{ caddy_name }}.service"
|
caddy_unit: "{{ caddy_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified, because short names prompt for a registry and the unit has
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
# no TTY.
|
||||||
# the systemd unit starts.
|
|
||||||
caddy_image: docker.io/library/caddy:latest
|
caddy_image: docker.io/library/caddy:latest
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/caddy"
|
host_directory: "{{ podman_data_dir }}/caddy"
|
||||||
caddyfile_path: "{{ host_directory }}/conf/Caddyfile"
|
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"
|
caddy_dropin_dir: "{{ quadlet_dir }}/{{ caddy_name }}.container.d"
|
||||||
|
|
||||||
# Host ports. 443/udp is HTTP/3 (QUIC).
|
# 443/udp carries HTTP/3.
|
||||||
caddy_ports:
|
caddy_ports:
|
||||||
- "80:80"
|
- "80:80"
|
||||||
- "443:443"
|
- "443:443"
|
||||||
|
|||||||
@@ -1,6 +1,3 @@
|
|||||||
#################################################
|
|
||||||
# DO Community Playbooks: Docker
|
|
||||||
#################################################
|
|
||||||
---
|
---
|
||||||
- hosts: all
|
- hosts: all
|
||||||
become: true
|
become: true
|
||||||
@@ -49,15 +46,3 @@
|
|||||||
- /var/lib/gitea:/data
|
- /var/lib/gitea:/data
|
||||||
- /etc/timezone:/etc/timezone:ro
|
- /etc/timezone:/etc/timezone:ro
|
||||||
- /etc/localtime:/etc/localtime:ro
|
- /etc/localtime:/etc/localtime:ro
|
||||||
|
|
||||||
# - name: Sets Nginx conf file for gitea
|
|
||||||
# template:
|
|
||||||
# src: "files/nginx.conf.j2"
|
|
||||||
# dest: "/etc/nginx/sites-available/{{ http_conf }}"
|
|
||||||
|
|
||||||
# - name: Enables new site
|
|
||||||
# file:
|
|
||||||
# src: "/etc/nginx/sites-available/{{ http_conf }}"
|
|
||||||
# dest: "/etc/nginx/sites-enabled/{{ http_conf }}"
|
|
||||||
# state: link
|
|
||||||
# notify: Reload Nginx
|
|
||||||
+2
-42
@@ -1,33 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Gitea as a rootless Podman Quadlet unit on emily, behind the shared Caddy
|
# Requires caddy to have run first. Gitea uses SQLite, not Postgres.
|
||||||
# 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
|
|
||||||
- name: Deploy Gitea as a rootless Podman Quadlet
|
- name: Deploy Gitea as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -35,23 +7,11 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# unit references {{ network_name }}.network, and Quadlet fails *unit
|
|
||||||
# generation* (so `systemctl --user start` reports "unit not found") when a
|
|
||||||
# referenced .network file does not exist yet. The same include writes the
|
|
||||||
# Caddy-side drop-in that attaches Caddy to this network.
|
|
||||||
#
|
|
||||||
# network_name is a play var, so the include can see it directly.
|
|
||||||
- name: Register Gitea's network with Caddy
|
- name: Register Gitea's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- ssh
|
||||||
# The app: the data directory tree the image's own root phase would leave
|
|
||||||
# subuid-owned, the Quadlet unit, start + verify.
|
|
||||||
- gitea
|
- gitea
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
# A reload keeps existing connections. A restart would briefly drop every hosted site.
|
||||||
# there. These handlers run as the connection user, so `systemctl --user` and
|
# The config path is inside the container, not caddyfile_path on the host.
|
||||||
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
|
|
||||||
|
|
||||||
# Notified by this role when the Caddyfile changes. A reload keeps existing
|
|
||||||
# connections; a restart would briefly drop every hosted site.
|
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap config
|
|
||||||
# reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -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"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
|
|||||||
@@ -1,33 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Gitea as a rootless Podman Quadlet unit.
|
# Create the tree as podman_user: anything left to the image stays subuid-owned and misses a plain tar backup.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
- name: Ensure the Gitea data directory exists
|
- name: Ensure the Gitea data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}"
|
path: "{{ host_directory }}"
|
||||||
@@ -48,27 +20,12 @@
|
|||||||
- { path: gitea/conf, mode: '0755' }
|
- { path: gitea/conf, mode: '0755' }
|
||||||
- { path: gitea/log, mode: '0755' }
|
- { path: gitea/log, mode: '0755' }
|
||||||
- { path: git, mode: '0755' }
|
- { path: git, mode: '0755' }
|
||||||
# 0700 because that is what the image's own setup script chmods it to
|
# The image's setup chmods this to 0700; setting it here does not depend on that script.
|
||||||
# (`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.
|
|
||||||
- { path: git/.ssh, mode: '0700' }
|
- { path: git/.ssh, mode: '0700' }
|
||||||
# The in-image sshd's host keys are mounted over /data/ssh from
|
# This is a mount point, so it must exist; letting Podman create it leaves it subuid-owned and fails the owner check.
|
||||||
# {{ 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.
|
|
||||||
- { path: ssh, mode: '0755' }
|
- { path: ssh, mode: '0755' }
|
||||||
|
|
||||||
# The image's setup creates this file if it is missing, as root — and it is the
|
# The image would create this as root and never chown it back, leaving it subuid-owned.
|
||||||
# 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.
|
|
||||||
- name: Write the container-side .ssh/environment file
|
- name: Write the container-side .ssh/environment file
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: "{{ host_directory }}/git/.ssh/environment"
|
dest: "{{ host_directory }}/git/.ssh/environment"
|
||||||
@@ -78,16 +35,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0600'
|
mode: '0600'
|
||||||
|
|
||||||
# The image always starts an sshd of its own inside the container, and that sshd
|
# The in-image sshd runs as a subuid that cannot touch the data directory, and 0777 is safe because the home directory is 0750.
|
||||||
# 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.
|
|
||||||
- name: Ensure the scratch host key directory exists
|
- name: Ensure the scratch host key directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ container_sshd_hostkeys }}"
|
path: "{{ container_sshd_hostkeys }}"
|
||||||
@@ -107,16 +55,7 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart Gitea
|
- Restart Gitea
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so the generated unit is never enabled.
|
||||||
# `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.
|
|
||||||
- name: Start and verify Gitea
|
- name: Start and verify Gitea
|
||||||
block:
|
block:
|
||||||
- name: Start Gitea
|
- name: Start Gitea
|
||||||
@@ -126,9 +65,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# Retried rather than checked once: podman reports `activating` for the
|
# Retried because podman reports `activating` between start and readiness.
|
||||||
# interval between systemd starting the container and its readiness
|
|
||||||
# notification.
|
|
||||||
- name: Verify Gitea is running
|
- name: Verify Gitea is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: gitea_active
|
register: gitea_active
|
||||||
@@ -137,9 +74,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can
|
# The image ships no HEALTHCHECK, so the unit defines one and this runs it synchronously.
|
||||||
# serve. The image ships no HEALTHCHECK, so the unit defines one (HealthCmd=,
|
|
||||||
# see the template) and this runs exactly that command once, synchronously.
|
|
||||||
- name: Wait for Gitea to pass its health check
|
- name: Wait for Gitea to pass its health check
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: gitea_health
|
register: gitea_health
|
||||||
@@ -148,10 +83,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd only reports "control process exited with error code", so read the journal for the real reason.
|
||||||
# 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.
|
|
||||||
- name: Collect Gitea diagnostics
|
- name: Collect Gitea diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -170,13 +102,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Gitea did not start — see the diagnostics above."
|
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
|
# A wrong keep-id still serves, but writes subuid-owned files a tar backup misses, so check the whole tree and let find fail.
|
||||||
# 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.
|
|
||||||
- name: List anything in the data tree not owned by {{ podman_user }}
|
- name: List anything in the data tree not owned by {{ podman_user }}
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
find {{ host_directory }} ! -user {{ podman_user }}
|
find {{ host_directory }} ! -user {{ podman_user }}
|
||||||
@@ -198,9 +124,7 @@
|
|||||||
path: "{{ host_directory }}/gitea/gitea.db"
|
path: "{{ host_directory }}/gitea/gitea.db"
|
||||||
register: gitea_db
|
register: gitea_db
|
||||||
|
|
||||||
# Not a failure: a fresh instance legitimately has no database yet. But on a
|
# Not a failure on a fresh instance, but a migration whose copy never ran will serve an empty installer.
|
||||||
# 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.
|
|
||||||
- name: Report a data directory with no database in it
|
- name: Report a data directory with no database in it
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
@@ -1,44 +1,11 @@
|
|||||||
---
|
---
|
||||||
# The host half of Gitea's git-over-SSH support.
|
# Git-over-SSH uses the host's sshd on port 22, and the shim reaches the container through a one-command sudoers rule.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
- name: Ensure the git group exists
|
- name: Ensure the git group exists
|
||||||
ansible.builtin.group:
|
ansible.builtin.group:
|
||||||
name: "{{ git_user }}"
|
name: "{{ git_user }}"
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
# The shell matters even though nobody ever gets one: sshd runs a forced command
|
# sshd runs the forced command through the login user's shell, so it must not be nologin; the uid is left to the system.
|
||||||
# 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 }}).
|
|
||||||
- name: Add the git login user
|
- name: Add the git login user
|
||||||
ansible.builtin.user:
|
ansible.builtin.user:
|
||||||
name: "{{ git_user }}"
|
name: "{{ git_user }}"
|
||||||
@@ -49,11 +16,6 @@
|
|||||||
create_home: true
|
create_home: true
|
||||||
become: 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
|
- name: Install the git-over-SSH passthrough shim
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
@@ -82,9 +44,7 @@
|
|||||||
mode: '0755'
|
mode: '0755'
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
# valid with `visudo -cf %s`: a syntax error in /etc/sudoers.d does not fail one
|
# A syntax error in /etc/sudoers.d breaks every sudo on the host, so validate before installing.
|
||||||
# rule, it fails *every* sudo invocation on the host, which on a server reached
|
|
||||||
# by ssh is a nasty way to find out.
|
|
||||||
- name: Let the git user reach the container as {{ podman_user }}
|
- name: Let the git user reach the container as {{ podman_user }}
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: sudoers-gitea.j2
|
src: sudoers-gitea.j2
|
||||||
@@ -107,10 +67,7 @@
|
|||||||
register: gitea_sshd_dropin
|
register: gitea_sshd_dropin
|
||||||
become: true
|
become: true
|
||||||
|
|
||||||
# A drop-in that does not parse is not a failed play, it is a host nobody can
|
# A drop-in that does not parse locks everyone out on the next reboot, so validate it and remove the file if it fails.
|
||||||
# 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.
|
|
||||||
- name: Validate the resulting sshd configuration
|
- name: Validate the resulting sshd configuration
|
||||||
ansible.builtin.command: /usr/sbin/sshd -t
|
ansible.builtin.command: /usr/sbin/sshd -t
|
||||||
changed_when: false
|
changed_when: false
|
||||||
@@ -130,8 +87,7 @@
|
|||||||
sshd is still running — and still restarting — on its previous
|
sshd is still running — and still restarting — on its previous
|
||||||
configuration. Run `/usr/sbin/sshd -t` on the host to see why.
|
configuration. Run `/usr/sbin/sshd -t` on the host to see why.
|
||||||
|
|
||||||
# `reload`, not `restart`: a reload re-execs sshd without dropping the connection
|
# A reload re-execs sshd without dropping the connection this play runs over.
|
||||||
# this play is running over.
|
|
||||||
- name: Reload sshd
|
- name: Reload sshd
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ sshd_service }}"
|
name: "{{ sshd_service }}"
|
||||||
|
|||||||
+15
-69
@@ -1,114 +1,60 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
|
||||||
# Rootless Podman / Quadlet
|
# podman_user comes from the connection user, so the play needs gather_facts.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Gitea
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
host: git.tanshu.com
|
host: git.tanshu.com
|
||||||
|
|
||||||
container_name: gitea
|
container_name: gitea
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
|
||||||
# the systemd unit starts.
|
|
||||||
image: docker.io/gitea/gitea:latest
|
image: docker.io/gitea/gitea:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is gitea.network.
|
||||||
# Podman network name to <network_name>_net (so: gitea.network, gitea_net).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
# Port inside the container that Caddy proxies to — app.ini's HTTP_PORT. Not
|
# The port Caddy proxies to inside the container, where app.ini's HTTP_PORT listens. Nothing is published on the host.
|
||||||
# published on the host: Caddy reaches it by container name.
|
|
||||||
container_port: 3000
|
container_port: 3000
|
||||||
|
|
||||||
# The uid/gid of the `git` account the image creates for itself
|
# The uid/gid of the image's `git` account, and therefore the uid Gitea runs as.
|
||||||
# (git:x:1003:1003, verified in the image), 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.
|
||||||
# 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.
|
|
||||||
gitea_uid: 1003
|
gitea_uid: 1003
|
||||||
gitea_gid: 1003
|
gitea_gid: 1003
|
||||||
|
|
||||||
# Where the image keeps its data *inside* the container, and therefore the
|
# The image's data directory inside the container, and the GITEA_CUSTOM it already sets.
|
||||||
# GITEA_CUSTOM the image's own environment already sets.
|
|
||||||
gitea_custom: /data/gitea
|
gitea_custom: /data/gitea
|
||||||
|
|
||||||
# Gitea's own unauthenticated health endpoint. The image ships no HEALTHCHECK,
|
# The image ships no HEALTHCHECK, so the unit defines one from this endpoint and the play gates on it.
|
||||||
# so the unit defines one from this and the play gates on it.
|
|
||||||
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/healthz"
|
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
|
# Only log timestamps are affected: Gitea stores each user's own timezone.
|
||||||
# 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.
|
|
||||||
timezone: Asia/Kolkata
|
timezone: Asia/Kolkata
|
||||||
|
|
||||||
# A scratch directory for the sshd *inside* the image, which this deployment
|
# The in-image sshd needs host keys somewhere, but it runs as a subuid, so keep them outside the data tree.
|
||||||
# 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.
|
|
||||||
container_sshd_hostkeys: "{{ podman_data_dir }}/{{ container_name }}-sshd"
|
container_sshd_hostkeys: "{{ podman_data_dir }}/{{ container_name }}-sshd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Clients keep using git@git.tanshu.com:user/repo.git on port 22, served by the host's sshd.
|
||||||
# The git-over-SSH entry point on the host
|
# `git_home` only has to exist, and its uid is left to the system.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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.
|
|
||||||
git_user: git
|
git_user: git
|
||||||
git_home: /home/git
|
git_home: /home/git
|
||||||
|
|
||||||
# The command Gitea writes into every authorized_keys line it maintains. This is
|
# Renaming this path locks users out because every authorized_keys line Gitea writes already contains it.
|
||||||
# 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.
|
|
||||||
shim_path: /usr/local/bin/gitea
|
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
|
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
|
authorized_keys_command: /usr/local/bin/gitea-authorized-keys
|
||||||
|
|
||||||
sshd_dropin_path: /etc/ssh/sshd_config.d/60-gitea.conf
|
sshd_dropin_path: /etc/ssh/sshd_config.d/60-gitea.conf
|
||||||
sshd_service: ssh
|
sshd_service: ssh
|
||||||
sudoers_path: /etc/sudoers.d/gitea-ssh-passthrough
|
sudoers_path: /etc/sudoers.d/gitea-ssh-passthrough
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ query_range:
|
|||||||
enabled: true
|
enabled: true
|
||||||
max_size_mb: 100
|
max_size_mb: 100
|
||||||
|
|
||||||
# limits_config:
|
|
||||||
# metric_aggregation_enabled: truez
|
|
||||||
|
|
||||||
schema_config:
|
schema_config:
|
||||||
configs:
|
configs:
|
||||||
- from: 2020-10-24
|
- from: 2020-10-24
|
||||||
@@ -40,25 +37,9 @@ schema_config:
|
|||||||
|
|
||||||
pattern_ingester:
|
pattern_ingester:
|
||||||
enabled: true
|
enabled: true
|
||||||
# metric_aggregation:
|
|
||||||
# enabled: true
|
|
||||||
# loki_address: localhost:3100
|
|
||||||
|
|
||||||
ruler:
|
ruler:
|
||||||
alertmanager_url: http://localhost:9093
|
alertmanager_url: http://localhost:9093
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
encoding: protobuf
|
encoding: protobuf
|
||||||
|
|
||||||
# By default, Loki will send anonymous, but uniquely-identifiable usage and configuration
|
|
||||||
# analytics to Grafana Labs. These statistics are sent to https://stats.grafana.org/
|
|
||||||
#
|
|
||||||
# Statistics help us better understand how Loki is used, and they show us performance
|
|
||||||
# levels for most users. This helps us prioritize features and documentation.
|
|
||||||
# For more information on what's sent, look at
|
|
||||||
# https://github.com/grafana/loki/blob/main/pkg/analytics/stats.go
|
|
||||||
# Refer to the buildReport method to see what goes into a report.
|
|
||||||
#
|
|
||||||
# If you would like to disable reporting, uncomment the following lines:
|
|
||||||
#analytics:
|
|
||||||
# reporting_enabled: false
|
|
||||||
@@ -1,9 +1,6 @@
|
|||||||
---
|
---
|
||||||
# The image is configured to run by default as user loki with UID 10001 and GID 10001.
|
# The image runs as UID and GID 10001, so the data directory must be owned by 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.
|
|
||||||
|
|
||||||
# Tasks for Loki role
|
|
||||||
- name: Pull Loki image
|
- name: Pull Loki image
|
||||||
docker_image:
|
docker_image:
|
||||||
name: "{{ loki_image }}"
|
name: "{{ loki_image }}"
|
||||||
@@ -18,14 +15,6 @@
|
|||||||
owner: "{{ loki_user }}"
|
owner: "{{ loki_user }}"
|
||||||
mode: 0755
|
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
|
- name: Copy the Loki conf file
|
||||||
template:
|
template:
|
||||||
src: "files/loki-config.yaml"
|
src: "files/loki-config.yaml"
|
||||||
|
|||||||
@@ -21,26 +21,3 @@ promtail_image: grafana/promtail:latest
|
|||||||
promtail_directory: "{{ host_directory }}/promtail"
|
promtail_directory: "{{ host_directory }}/promtail"
|
||||||
promtail_user: "{{ loki_user }}"
|
promtail_user: "{{ loki_user }}"
|
||||||
promtail_port: 9080
|
promtail_port: 9080
|
||||||
|
|
||||||
# mosquitto_container: mosquitto
|
|
||||||
# mosquitto_image: eclipse-mosquitto:latest
|
|
||||||
# mosquitto_directory: "{{ host_directory }}/mosquitto"
|
|
||||||
# mosquitto_user: 'root'
|
|
||||||
|
|
||||||
# nodered_container: nodered
|
|
||||||
# nodered_image: nodered/node-red:latest
|
|
||||||
# nodered_directory: "{{ host_directory }}/nodered"
|
|
||||||
# nodered_user: "{{ user }}"
|
|
||||||
|
|
||||||
# influx_container: influxtestplay
|
|
||||||
# influx_image: influxdb:latest
|
|
||||||
# influx_directory: "{{ host_directory }}/influxtestplaydir"
|
|
||||||
# influx_user: "{{ user }}"
|
|
||||||
|
|
||||||
# grafana_container: grafana
|
|
||||||
# grafana_image: grafana/grafana-enterprise:latest
|
|
||||||
# grafana_directory: "{{ host_directory }}/grafana"
|
|
||||||
# grafana_user: "{{ user }}"
|
|
||||||
|
|
||||||
# http_host: "iot.tanshu.com"
|
|
||||||
# http_conf: "iot.tanshu.com.conf"
|
|
||||||
+2
-37
@@ -1,23 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Nextcloud as a rootless Podman Quadlet unit, behind the shared Caddy, with its
|
# Requires caddy, postgres, and valkey to have run first.
|
||||||
# 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
|
|
||||||
- name: Deploy Nextcloud as a rootless Podman Quadlet
|
- name: Deploy Nextcloud as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -25,29 +7,12 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# 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.
|
|
||||||
- name: Register Nextcloud's network with Caddy
|
- name: Register Nextcloud's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- 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
|
- valkey
|
||||||
# The app: the webroot, the 0600 environment file, the Quadlet unit, the
|
|
||||||
# background-job timer, start + verify.
|
|
||||||
- nextcloud
|
- nextcloud
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
# A reload keeps existing connections. A restart would briefly drop every hosted site.
|
||||||
# there. These handlers run as the connection user, which is what makes
|
# The config path is inside the container, not caddyfile_path on the host.
|
||||||
# `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.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -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"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
|
|||||||
@@ -1,17 +1,11 @@
|
|||||||
---
|
---
|
||||||
# Handlers run as the connection user, which is what makes `systemctl --user`
|
|
||||||
# work here.
|
|
||||||
|
|
||||||
- name: Reload systemd user units
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
# A changed environment file or unit file changes the generated unit, so
|
# Config, data, and custom_apps are on the bind mount, and the database is in Postgres.
|
||||||
# Nextcloud is recreated. Nothing is lost by that: config.php, data/ and
|
# The entrypoint reruns its version check, including occ upgrade, on the way back up.
|
||||||
# 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.
|
|
||||||
- name: Restart Nextcloud
|
- name: Restart Nextcloud
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
|
|||||||
@@ -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
|
- name: Ensure the webroot exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}"
|
path: "{{ host_directory }}"
|
||||||
@@ -17,9 +7,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# Read by the entrypoint, not by Ansible, so it has to exist before the unit
|
# The entrypoint reads this before the unit starts, and POSTGRES_PASSWORD means it stays 0600.
|
||||||
# starts. Mode 0600: POSTGRES_PASSWORD is in it, and the unit file is
|
|
||||||
# world-readable.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: nextcloud.env.j2
|
src: nextcloud.env.j2
|
||||||
@@ -40,8 +28,7 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart Nextcloud
|
- Restart Nextcloud
|
||||||
|
|
||||||
# Quadlet applies [Install] WantedBy= when it generates the unit, so a generated
|
# Quadlet applies [Install] WantedBy= when it generates the unit, so it is never enabled.
|
||||||
# unit is never `systemctl enable`d.
|
|
||||||
- name: Start Nextcloud and wait for it to serve
|
- name: Start Nextcloud and wait for it to serve
|
||||||
block:
|
block:
|
||||||
- name: Start Nextcloud
|
- name: Start Nextcloud
|
||||||
@@ -51,13 +38,8 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# `systemctl start` returns while the unit is still `activating`, and a
|
# `systemctl start` returns while the unit is still activating, and a crash loop looks the same.
|
||||||
# crash-looping container is `activating` most of the time as well, so
|
# status.php answers after the entrypoint and Apache are ready, and it reports maintenance mode.
|
||||||
# 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.
|
|
||||||
- name: Wait for Nextcloud to serve status.php
|
- name: Wait for Nextcloud to serve status.php
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} php -r
|
podman exec {{ container_name }} php -r
|
||||||
@@ -70,8 +52,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else.
|
# systemd only reports "control process exited with error code", so read the entrypoint output.
|
||||||
# The reason is in the entrypoint's own output.
|
|
||||||
- name: Collect diagnostics
|
- name: Collect diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -94,11 +75,8 @@
|
|||||||
this playbook has produced before. If the unit is in the `failed` state
|
this playbook has produced before. If the unit is in the `failed` state
|
||||||
from an earlier attempt: systemctl --user reset-failed {{ unit_name }}
|
from an earlier attempt: systemctl --user reset-failed {{ unit_name }}
|
||||||
|
|
||||||
# Nextcloud wants `cron.php` every five minutes. This has to be *this user's*
|
# This must be the connection user's crontab. Root cannot podman exec into another user's rootless containers.
|
||||||
# crontab, not root's: root cannot `podman exec` into another user's rootless
|
# Lingering keeps /run/user/<uid> present, which is where podman keeps the state exec needs.
|
||||||
# containers. It works without any environment plumbing because the caddy
|
|
||||||
# playbook enables lingering, so /run/user/<uid> exists whether or not anyone is
|
|
||||||
# logged in — which is where podman keeps the state `exec` needs.
|
|
||||||
- name: Schedule Nextcloud's background jobs
|
- name: Schedule Nextcloud's background jobs
|
||||||
ansible.builtin.cron:
|
ansible.builtin.cron:
|
||||||
name: Nextcloud background jobs
|
name: Nextcloud background jobs
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
|
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
|
||||||
# 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".
|
|
||||||
- name: Restart Postgres
|
- name: Restart Postgres
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ postgres_unit }}"
|
name: "{{ postgres_unit }}"
|
||||||
|
|||||||
@@ -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
|
- name: Attach Nextcloud to Postgres
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
|
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
|
# 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.
|
||||||
# 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.
|
|
||||||
- name: Apply the network change before starting Nextcloud
|
- name: Apply the network change before starting Nextcloud
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
# Postgres ships one default database and only this app knows the name of its own,
|
# On a fresh cluster the installer would fail without this database, and the retry covers the restart the flush did.
|
||||||
# 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.
|
|
||||||
- name: Check whether the {{ pg_database }} database exists
|
- name: Check whether the {{ pg_database }} database exists
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec -e PGPASSWORD={{ pg_password }} {{ postgres_container }}
|
podman exec -e PGPASSWORD={{ pg_password }} {{ postgres_container }}
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
---
|
---
|
||||||
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
|
# A new drop-in changes the generated unit, so Valkey is recreated to join the network.
|
||||||
# there. This handler runs as the connection user, which is what makes
|
# Data survives the recreate, but Nextcloud's cache and locks are unavailable for the seconds it is down.
|
||||||
# `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".
|
|
||||||
- name: Restart Valkey
|
- name: Restart Valkey
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ valkey_unit }}"
|
name: "{{ valkey_unit }}"
|
||||||
|
|||||||
@@ -1,28 +1,8 @@
|
|||||||
---
|
---
|
||||||
# Attach this app's network to the shared Valkey unit.
|
# The app connects to `redis`, the alias the attach role adds, not the container name.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
- name: Attach Nextcloud to Valkey
|
- name: Attach Nextcloud to Valkey
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
|
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
|
# 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.
|
||||||
# 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.
|
|
||||||
- name: Apply the Valkey network change before starting Nextcloud
|
- name: Apply the Valkey network change before starting Nextcloud
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
@@ -1,41 +1,26 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Duplicated from caddy/vars/default.yml, so keep them in sync.
|
||||||
# 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.
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# What Caddy serves, and the trusted domain for an unattended install.
|
||||||
# Nextcloud
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The site's hostname: what Caddy serves, and the trusted domain for an unattended
|
|
||||||
# install.
|
|
||||||
host: cloud.hopsngrains.com
|
host: cloud.hopsngrains.com
|
||||||
|
|
||||||
container_name: nextcloud
|
container_name: nextcloud
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified: rootless Podman resolves short names against
|
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
|
||||||
# unqualified-search-registries, and a unit has no TTY to answer the prompt.
|
|
||||||
image: docker.io/library/nextcloud:latest
|
image: docker.io/library/nextcloud:latest
|
||||||
|
|
||||||
# caddy/roles/register pins the Podman network name to <network_name>_net, so the
|
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is nextcloud.network.
|
||||||
# file is nextcloud.network and the network is nextcloud_net.
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
# The image's VOLUME and Apache document root, and — unlike the other services
|
# The image's VOLUME and Apache docroot, and unlike other services it holds the code as well as the state.
|
||||||
# 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".
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
webroot: /var/www/html
|
webroot: /var/www/html
|
||||||
|
|
||||||
# www-data. The entrypoint rsyncs the code in as this uid and Apache runs its
|
# www-data. The entrypoint rsyncs the code in as this uid, and Apache workers run as it.
|
||||||
# workers as it, so this is the one uid to map onto the connection user.
|
|
||||||
app_uid: 33
|
app_uid: 33
|
||||||
app_gid: 33
|
app_gid: 33
|
||||||
|
|
||||||
@@ -44,48 +29,28 @@ container_port: 80
|
|||||||
|
|
||||||
env_file: "{{ host_directory }}/nextcloud.env"
|
env_file: "{{ host_directory }}/nextcloud.env"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared services (each its own playbook)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Postgres, and the alias its attach role adds.
|
|
||||||
postgres_container: postgres
|
postgres_container: postgres
|
||||||
postgres_unit: "{{ postgres_container }}.service"
|
postgres_unit: "{{ postgres_container }}.service"
|
||||||
db_host: db
|
db_host: db
|
||||||
|
|
||||||
# The database this play creates if it is missing, and the credentials in
|
# The database this play creates, and the credentials the cluster was initialised with.
|
||||||
# postgres/vars/default.yml that the cluster was initialised with.
|
|
||||||
pg_user: postgres
|
pg_user: postgres
|
||||||
pg_password: '123456'
|
pg_password: '123456'
|
||||||
pg_database: nextcloud
|
pg_database: nextcloud
|
||||||
|
|
||||||
# Valkey, which the attach role puts this app's network on with the alias
|
# Valkey's attach role adds the `redis` alias, which config.php uses for cache and locking.
|
||||||
# `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_container: valkey
|
valkey_container: valkey
|
||||||
valkey_unit: "{{ valkey_container }}.service"
|
valkey_unit: "{{ valkey_container }}.service"
|
||||||
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Empty keeps the setup wizard. Set both to install unattended, with trusted_domains from `host`.
|
||||||
# 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.
|
|
||||||
nextcloud_admin_user: ""
|
nextcloud_admin_user: ""
|
||||||
nextcloud_admin_password: ""
|
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"
|
cron_minute: "*/5"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Caddy site block
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
snippet_block: |
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
reverse_proxy {{ container_name }}:{{ container_port }}
|
reverse_proxy {{ container_name }}:{{ container_port }}
|
||||||
|
|||||||
+1
-23
@@ -1,25 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Deploys PostgreSQL as a rootless Podman Quadlet unit.
|
# Run this before the app playbooks. Each service attaches itself.
|
||||||
#
|
|
||||||
# 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
|
|
||||||
- name: Deploy PostgreSQL as a rootless Podman Quadlet
|
- name: Deploy PostgreSQL as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -27,6 +7,4 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
# The base postgres unit. It does NOT know which app networks it serves.
|
|
||||||
# Each service attaches itself via postgres/roles/attach.
|
|
||||||
- postgres
|
- postgres
|
||||||
@@ -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"
|
- name: "Check that the {{ network_name }} network unit exists"
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "{{ quadlet_dir }}/{{ network_name }}.network"
|
path: "{{ quadlet_dir }}/{{ network_name }}.network"
|
||||||
register: postgres_attach_network
|
register: postgres_attach_network
|
||||||
|
|
||||||
# A drop-in naming a .network file that does not exist makes Quadlet fail to
|
# A drop-in naming a missing network breaks unit generation with an unrelated
|
||||||
# generate the whole postgres unit, with a message that does not mention the
|
# error, so fail here instead.
|
||||||
# drop-in at all. Fail here instead, where the cause is obvious.
|
|
||||||
- name: "Fail if {{ network_name }} has no network unit"
|
- name: "Fail if {{ network_name }} has no network unit"
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that: postgres_attach_network.stat.exists
|
that: postgres_attach_network.stat.exists
|
||||||
@@ -64,20 +31,14 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# Added by the {{ network_name }} playbook. Do not edit by hand.
|
# Added by {{ network_name }}. Deleting it detaches the network from
|
||||||
#
|
# {{ postgres_container | default('postgres') }} on the next daemon-reload.
|
||||||
# Deleting this file removes the network from {{ postgres_container | default('postgres') }} on
|
|
||||||
# the next daemon-reload; nothing else changes.
|
|
||||||
[Container]
|
[Container]
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
notify: Restart Postgres
|
notify: Restart Postgres
|
||||||
|
|
||||||
# NetworkAlias= applies to every network the container joins, which is exactly
|
# NetworkAlias applies to every network, so one shared file holds the fixed
|
||||||
# right for a hub: every app in this repo reaches postgres as `db` (nextcloud,
|
# alias. Two services writing their own would pass --network-alias twice.
|
||||||
# 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.
|
|
||||||
- name: "Give {{ postgres_container | default('postgres') }} the shared 'db' alias"
|
- name: "Give {{ postgres_container | default('postgres') }} the shared 'db' alias"
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: >-
|
dest: >-
|
||||||
|
|||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
|
# A new drop-in needs a recreate, because Quadlet has no live network connect.
|
||||||
- name: Restart Postgres
|
- name: Restart Postgres
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ postgres_unit }}"
|
name: "{{ postgres_unit }}"
|
||||||
@@ -14,18 +13,3 @@
|
|||||||
state: restarted
|
state: restarted
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
listen: Restart Postgres
|
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`.
|
|
||||||
@@ -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
|
- name: Ensure the Postgres data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -13,14 +8,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# PGDATA and its version directory are created here rather than left to the
|
# The entrypoint runs as a subuid under keep-id, so create PGDATA here to keep
|
||||||
# container's own `mkdir -p`. The entrypoint runs as container-root, which under
|
# it owned by podman_user rather than 165536.
|
||||||
# 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".
|
|
||||||
- name: Ensure the versioned data directory exists
|
- name: Ensure the versioned data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ pgdata_host_dir | dirname }}"
|
path: "{{ pgdata_host_dir | dirname }}"
|
||||||
@@ -45,8 +34,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# The unit file is 0644 in ~/.config, so the password does not belong in it.
|
# The unit file is world-readable, so the password goes in a 0600 env file.
|
||||||
# This file is 0600 and is read by the container through EnvironmentFile=.
|
|
||||||
- name: Write the Postgres environment file
|
- name: Write the Postgres environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: postgres.env.j2
|
src: postgres.env.j2
|
||||||
@@ -67,10 +55,8 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart Postgres
|
- Restart Postgres
|
||||||
|
|
||||||
# The wait is part of the block on purpose. `Restart=always` means systemd
|
# `Restart=always` reports the unit active while the container crash-loops, so
|
||||||
# reports the unit active even while the container crash-loops — a wrong
|
# wait for a real connection instead of trusting is-active.
|
||||||
# keep-id shows up as an entrypoint permission error over and over — so
|
|
||||||
# is-active alone would call a broken database healthy.
|
|
||||||
- name: Start Postgres and wait for it to accept connections
|
- name: Start Postgres and wait for it to accept connections
|
||||||
block:
|
block:
|
||||||
- name: Start Postgres
|
- name: Start Postgres
|
||||||
@@ -80,8 +66,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# pg_isready is the server's own answer, and it fails while initdb is still
|
# pg_isready fails with exit 2 while initdb runs, so the retry loop holds.
|
||||||
# running on a fresh data directory (it exits 2, so the retry loop holds).
|
|
||||||
- name: Wait for Postgres to accept connections
|
- name: Wait for Postgres to accept connections
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ postgres_container }}
|
podman exec {{ postgres_container }}
|
||||||
@@ -92,9 +77,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd only reports a generic exit code, so collect the real reason.
|
||||||
# else, which is useless for the usual failures (a bad uid mapping, an
|
|
||||||
# unwritable PGDATA, a missing image). Pull the real reason out.
|
|
||||||
- name: Collect Postgres diagnostics
|
- name: Collect Postgres diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -112,8 +95,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Postgres did not come up — see the diagnostics above."
|
msg: "Postgres did not come up — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
|
||||||
# `systemctl enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify the Postgres unit is active
|
- name: Verify the Postgres unit is active
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ postgres_unit }}"
|
ansible.builtin.command: "systemctl --user is-active {{ postgres_unit }}"
|
||||||
register: postgres_active
|
register: postgres_active
|
||||||
@@ -143,9 +125,7 @@
|
|||||||
{{ postgres_dropins.files | map(attribute='path') | map('basename') | join(', ')
|
{{ postgres_dropins.files | map(attribute='path') | map('basename') | join(', ')
|
||||||
| default('(nothing yet)', true) }}.
|
| default('(nothing yet)', true) }}.
|
||||||
|
|
||||||
# Say this out loud rather than let it be discovered by a failing app: with no
|
# With no drop-in there is no Network=, so it sits on the default Podman network.
|
||||||
# drop-in, Postgres has no Network= at all, so it sits on the default Podman
|
|
||||||
# network and no app network can resolve it.
|
|
||||||
- name: Note that no service has attached yet
|
- name: Note that no service has attached yet
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
+16
-60
@@ -1,94 +1,50 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# A play cannot include caddy/vars/default.yml, which also sets host_directory,
|
||||||
# Rootless Podman / Quadlet
|
# so keep these in sync with it. Set gather_facts: true for the facts below.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
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_container: postgres
|
||||||
postgres_unit: "{{ postgres_container }}.service"
|
postgres_unit: "{{ postgres_container }}.service"
|
||||||
|
|
||||||
# The major version is the one knob, because the image tag and the PGDATA
|
# 18 is deliberate: monoco runs 17.5 and a PGDATA copy cannot cross a major
|
||||||
# directory have to agree.
|
# version, so the data moves with a logical dump. The alpine tag still floats
|
||||||
#
|
# within 18.x for security patches.
|
||||||
# 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.
|
|
||||||
postgres_major: '18'
|
postgres_major: '18'
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified, because short names prompt for a registry and the unit has
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
# no TTY.
|
||||||
# the systemd unit starts.
|
|
||||||
postgres_image: "docker.io/library/postgres:{{ postgres_major }}-alpine"
|
postgres_image: "docker.io/library/postgres:{{ postgres_major }}-alpine"
|
||||||
|
|
||||||
# The uid the server actually writes as — not the image's configured user. The
|
# The image's postgres account is uid and gid 70. keep-id maps it onto
|
||||||
# Alpine image creates the "postgres" account at uid/gid 70 ("70 is the standard
|
# podman_user, so PGDATA and backups stay owned by the host user.
|
||||||
# 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.
|
|
||||||
postgres_uid: 70
|
postgres_uid: 70
|
||||||
postgres_gid: 70
|
postgres_gid: 70
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ postgres_container }}"
|
host_directory: "{{ podman_data_dir }}/{{ postgres_container }}"
|
||||||
|
|
||||||
# Postgres 18 moved both the image's VOLUME and its default PGDATA:
|
# Postgres 18 moved PGDATA under <major>/docker, and its entrypoint refuses a
|
||||||
#
|
# cluster in the old layout. The versioned path also keeps pg_upgrade --link on
|
||||||
# 17 Volume /var/lib/postgresql/data PGDATA /var/lib/postgresql/data/pgdata
|
# one filesystem.
|
||||||
# 18+ Volume /var/lib/postgresql PGDATA /var/lib/postgresql/<major>/docker
|
|
||||||
#
|
|
||||||
# So the mount is now the parent directory and the version lives in the path.
|
|
||||||
# That is what the 18 entrypoint expects: it refuses to start — "there appears
|
|
||||||
# to be PostgreSQL data in ..." — if it finds a cluster in the old layout, and
|
|
||||||
# the versioned subdirectory is what makes a later `pg_upgrade --link` work
|
|
||||||
# without mount-boundary trouble.
|
|
||||||
postgres_mount: /var/lib/postgresql
|
postgres_mount: /var/lib/postgresql
|
||||||
|
|
||||||
# Everything under the mount that identifies this cluster, kept as one value so
|
# One value keeps the container and host paths from drifting apart.
|
||||||
# the container path and the host path cannot drift apart.
|
|
||||||
postgres_versioned_subdir: "{{ postgres_major }}/docker"
|
postgres_versioned_subdir: "{{ postgres_major }}/docker"
|
||||||
|
|
||||||
pgdata: "{{ postgres_mount }}/{{ postgres_versioned_subdir }}"
|
pgdata: "{{ postgres_mount }}/{{ postgres_versioned_subdir }}"
|
||||||
pgdata_host_dir: "{{ host_directory }}/{{ 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`
|
# The unit file is world-readable, so secrets go in a 0600 EnvironmentFile=.
|
||||||
# prints the environment anyway. They go in a 0600 file the unit reads with
|
|
||||||
# EnvironmentFile=.
|
|
||||||
postgres_env_file: "{{ host_directory }}/postgres.env"
|
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"
|
postgres_dropin_dir: "{{ quadlet_dir }}/{{ postgres_container }}.container.d"
|
||||||
|
|
||||||
# Credentials. This is the placeholder the app playbooks already embed in their
|
# Placeholder password. Replace with a real secret.
|
||||||
# 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.
|
|
||||||
pg_user: postgres
|
pg_user: postgres
|
||||||
pg_password: '123456'
|
pg_password: '123456'
|
||||||
pg_database: postgres
|
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_start: 120
|
||||||
postgres_timeout_stop: 60
|
postgres_timeout_stop: 60
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Tasks for the common role.
|
# Runs in the second play, after the user role has installed the keys this play logs in with.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
- name: Update the sshd config file to disable password logins
|
- name: Update the sshd config file to disable password logins
|
||||||
ansible.builtin.replace:
|
ansible.builtin.replace:
|
||||||
|
|||||||
@@ -1,18 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Tasks for the user role.
|
# The zsh role owns the login shell, so this role must not set one.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
|
|
||||||
- name: Create the desired user
|
- name: Create the desired user
|
||||||
ansible.builtin.user:
|
ansible.builtin.user:
|
||||||
@@ -37,14 +24,9 @@
|
|||||||
group: "{{ user }}"
|
group: "{{ user }}"
|
||||||
mode: "0644"
|
mode: "0644"
|
||||||
|
|
||||||
# The second play logs in as {{ user }} with the private half of this key on
|
# The second play logs in with this key, so bootstrap installs it here rather
|
||||||
# the control node, so this entry is what makes that login possible. It is
|
# than relying on a named key below. It uses lineinfile because authorized_key
|
||||||
# installed here, during the bootstrap play, rather than being left to depend
|
# redirects to ansible.posix 2.1.0, which imports a deprecated helper and warns on every run.
|
||||||
# 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.
|
|
||||||
- name: Authorize the control node's key for the new user
|
- name: Authorize the control node's key for the new user
|
||||||
ansible.builtin.lineinfile:
|
ansible.builtin.lineinfile:
|
||||||
dest: "/home/{{ user }}/.ssh/authorized_keys"
|
dest: "/home/{{ user }}/.ssh/authorized_keys"
|
||||||
|
|||||||
+10
-12
@@ -3,9 +3,8 @@ docker_container: rustdesk
|
|||||||
image_name: "rustdesk/rustdesk-server-s6"
|
image_name: "rustdesk/rustdesk-server-s6"
|
||||||
image_version: "latest"
|
image_version: "latest"
|
||||||
|
|
||||||
# Public name clients use, and the name Caddy serves. hbbs hands this out as the
|
# Clients use this name and Caddy serves it. hbbs hands it out as the relay
|
||||||
# relay address, and a WebSocket client rewrites a hostname to
|
# address, so it must be a hostname and not an IP.
|
||||||
# wss://<host>/ws/relay (an IP would get neither TLS nor the path).
|
|
||||||
host: rust.tanshu.com
|
host: rust.tanshu.com
|
||||||
rustdesk_relay_server: "{{ host }}:21117"
|
rustdesk_relay_server: "{{ host }}:21117"
|
||||||
|
|
||||||
@@ -15,10 +14,9 @@ data_location: "/var/lib/{{ host_directory }}/data"
|
|||||||
docker_network: "{{ docker_container }}_net"
|
docker_network: "{{ docker_container }}_net"
|
||||||
caddy_container: caddy
|
caddy_container: caddy
|
||||||
|
|
||||||
# hbbs/hbbr also speak their own TCP/UDP protocol on 21115-21117, which is not
|
# hbbs and hbbr speak their own protocol on 21115-21117, which Caddy cannot
|
||||||
# HTTP and so cannot go through Caddy. Leave this off to keep the host's only
|
# carry. Leave this off to keep only 80/443 open. Every session is then relayed
|
||||||
# open ports at 80/443: clients then have to enable WebSocket, and every session
|
# and clients must use WebSocket. Turn it on for clients that do not.
|
||||||
# is relayed. Turn it on for clients that do not use WebSocket.
|
|
||||||
rustdesk_publish_raw_ports: false
|
rustdesk_publish_raw_ports: false
|
||||||
rustdesk_raw_ports:
|
rustdesk_raw_ports:
|
||||||
- "21115:21115"
|
- "21115:21115"
|
||||||
@@ -28,11 +26,11 @@ rustdesk_raw_ports:
|
|||||||
|
|
||||||
rustdesk_published_ports: "{{ rustdesk_raw_ports if rustdesk_publish_raw_ports | bool else [] }}"
|
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
|
# hbbs writes its key pair under /data on first start. Clients need the public
|
||||||
# configured with the public key, and ENCRYPTED_ONLY makes hbbr check it too.
|
# half, and ENCRYPTED_ONLY makes hbbr check it too.
|
||||||
rustdesk_encrypted_only: true
|
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.
|
# regardless, so this only matters for clients on the raw ports.
|
||||||
rustdesk_always_use_relay: false
|
rustdesk_always_use_relay: false
|
||||||
|
|
||||||
@@ -41,8 +39,8 @@ rustdesk_env:
|
|||||||
ENCRYPTED_ONLY: "{{ '1' if rustdesk_encrypted_only | bool else '0' }}"
|
ENCRYPTED_ONLY: "{{ '1' if rustdesk_encrypted_only | bool else '0' }}"
|
||||||
ALWAYS_USE_RELAY: "{{ 'Y' if rustdesk_always_use_relay | bool else 'N' }}"
|
ALWAYS_USE_RELAY: "{{ 'Y' if rustdesk_always_use_relay | bool else 'N' }}"
|
||||||
|
|
||||||
# Caddy terminates TLS on 443 and carries the WebSocket transports: 21118 is
|
# Caddy terminates TLS on 443 and carries the WebSockets: 21118 is hbbs's
|
||||||
# hbbs's WebSocket listener, 21119 hbbr's. Neither is published on the host.
|
# listener, 21119 hbbr's. Neither port is published on the host.
|
||||||
caddyfile_path: /var/lib/caddy/conf/Caddyfile
|
caddyfile_path: /var/lib/caddy/conf/Caddyfile
|
||||||
snippet_block: |
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
|
|||||||
@@ -1,39 +1,5 @@
|
|||||||
---
|
---
|
||||||
# sms-wa-bridge as a rootless Podman Quadlet unit on emily, behind the shared
|
# Requires caddy to have run first.
|
||||||
# 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
|
|
||||||
- name: Deploy sms-wa-bridge as a rootless Podman Quadlet
|
- name: Deploy sms-wa-bridge as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -41,19 +7,10 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# unit references {{ network_name }}.network, and Quadlet fails *unit
|
|
||||||
# generation* (so `systemctl --user start` reports "unit not found") when a
|
|
||||||
# referenced .network file does not exist yet. The same include writes the
|
|
||||||
# Caddy-side drop-in that attaches Caddy to this network.
|
|
||||||
#
|
|
||||||
# network_name is a play var, so the include can see it directly.
|
|
||||||
- name: Register the bridge's network with Caddy
|
- name: Register the bridge's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- sms-wa-bridge
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -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
|
# A reload keeps live connections; a restart would drop every hosted site.
|
||||||
# connections; a restart would briefly drop every hosted site.
|
# The path is inside the container, not the host {{ caddyfile_path }}.
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet cannot attach a network live, so Caddy must be recreated.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first when both are notified.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
# Under --check the unit was never written, so skip the restart.
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap config
|
|
||||||
# reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
---
|
---
|
||||||
# Append this site's block to the shared Caddyfile.
|
|
||||||
#
|
|
||||||
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
|
||||||
# change as soon as the file is written; the handler only asks Caddy to re-read
|
|
||||||
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
|
||||||
#
|
|
||||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
|
||||||
# slurp the file and compare first — which also matters because slurp *fails*
|
|
||||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
|
||||||
# not run.
|
|
||||||
|
|
||||||
- name: "Add the {{ host }} site block to the Caddyfile"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
# A changed unit file, a changed environment file, or a rebuilt image — all of
|
# A recreate keeps state on the bind mounts; incoming SMS fail for a few seconds.
|
||||||
# them mean the app is recreated. Everything it owns is on the four bind mounts,
|
# Under --check the unit file was never written, so skip the restart.
|
||||||
# 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.
|
|
||||||
- name: Restart sms-wa-bridge
|
- name: Restart sms-wa-bridge
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
|
|||||||
@@ -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.
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# All four mount sources must exist before the unit starts.
|
||||||
# The data tree, and the two bind-mounted files
|
# Podman creates a missing bind-mount source as a directory, which breaks the app.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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.
|
|
||||||
- name: Ensure the sms-wa-bridge data directory exists
|
- name: Ensure the sms-wa-bridge data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}"
|
path: "{{ host_directory }}"
|
||||||
@@ -34,16 +18,12 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
loop:
|
loop:
|
||||||
# The paired WhatsApp session: the Chromium profile whatsapp-web.js logged
|
# The paired session cannot be recreated without scanning a QR code.
|
||||||
# in as. 336 MB on monoco, and not reproducible without scanning a QR code.
|
|
||||||
- wwebjs_auth
|
- wwebjs_auth
|
||||||
# Empty on monoco and unused by the current whatsapp-web.js, but mounted by
|
# Empty and unused, kept so the tree matches the Docker-era layout.
|
||||||
# the Docker-era container, so the migrated tree keeps its shape.
|
|
||||||
- wwebjs_session
|
- wwebjs_session
|
||||||
|
|
||||||
# The app's own log file, bind-mounted as a *file*. state=touch with preserve is
|
# preserve stops every run from bumping the log's mtime and reporting a change.
|
||||||
# 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.
|
|
||||||
- name: Ensure the log file the app appends to exists
|
- name: Ensure the log file the app appends to exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}/bridge.log"
|
path: "{{ host_directory }}/bridge.log"
|
||||||
@@ -54,13 +34,7 @@
|
|||||||
modification_time: preserve
|
modification_time: preserve
|
||||||
access_time: preserve
|
access_time: preserve
|
||||||
|
|
||||||
# Seeded from the image's own rules.json, and then never touched again: the
|
# The app re-reads rules.json on every request, so never overwrite live edits.
|
||||||
# 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.
|
|
||||||
- name: Seed the routing rules if there are none yet
|
- name: Seed the routing rules if there are none yet
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
src: "{{ playbook_dir }}/files/build/rules.json"
|
src: "{{ playbook_dir }}/files/build/rules.json"
|
||||||
@@ -70,24 +44,8 @@
|
|||||||
mode: '0644'
|
mode: '0644'
|
||||||
force: false
|
force: false
|
||||||
|
|
||||||
# The one failure the migration is most likely to arrive with, checked before
|
# Container root is {{ podman_user }}, so a root-owned file copied from monoco is unwritable.
|
||||||
# anything expensive happens — and before the unit starts, because it cannot
|
# Skip the find under --check because the directory above was never created.
|
||||||
# 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.
|
|
||||||
- name: List anything in the data tree not owned by {{ podman_user }}
|
- name: List anything in the data tree not owned by {{ podman_user }}
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
find {{ host_directory }} ! -user {{ podman_user }}
|
find {{ host_directory }} ! -user {{ podman_user }}
|
||||||
@@ -107,12 +65,8 @@
|
|||||||
`chown -R {{ podman_user }}:{{ podman_user }} {{ host_directory }}` if the
|
`chown -R {{ podman_user }}:{{ podman_user }} {{ host_directory }}` if the
|
||||||
copy was otherwise good. See readme.md, "Migrating the data from monoco".
|
copy was otherwise good. See readme.md, "Migrating the data from monoco".
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Podman reads this through EnvironmentFile=, so it must be written before the unit starts.
|
||||||
# The environment file (AUTH_SECRET)
|
# 0600 keeps the shared secret out of the world-readable unit.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: sms-wa-bridge.env.j2
|
src: sms-wa-bridge.env.j2
|
||||||
@@ -122,18 +76,8 @@
|
|||||||
mode: '0600'
|
mode: '0600'
|
||||||
notify: Restart sms-wa-bridge
|
notify: Restart sms-wa-bridge
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The image exists in no registry, so load it from monoco or build it here.
|
||||||
# The image
|
# Run the probes in check mode so --check reports the real build decision.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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.
|
|
||||||
- name: Check for the locally built image
|
- name: Check for the locally built image
|
||||||
ansible.builtin.command: "podman image exists {{ image }}"
|
ansible.builtin.command: "podman image exists {{ image }}"
|
||||||
register: sms_image_local
|
register: sms_image_local
|
||||||
@@ -141,15 +85,8 @@
|
|||||||
failed_when: false
|
failed_when: false
|
||||||
check_mode: false
|
check_mode: false
|
||||||
|
|
||||||
# `docker save` writes an unqualified RepoTags and podman load qualifies it
|
# podman load qualifies a docker-saved tag against docker.io, not localhost.
|
||||||
# against the default registry, so an image loaded from monoco lands as
|
# Re-tag rather than rebuild: a rebuild can break the paired WhatsApp session.
|
||||||
# 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.
|
|
||||||
- name: Re-tag an image that was loaded from monoco
|
- name: Re-tag an image that was loaded from monoco
|
||||||
when: (sms_image_local.rc | default(1)) != 0
|
when: (sms_image_local.rc | default(1)) != 0
|
||||||
block:
|
block:
|
||||||
@@ -166,10 +103,6 @@
|
|||||||
changed_when: true
|
changed_when: true
|
||||||
notify: Restart sms-wa-bridge
|
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
|
- name: Decide whether the image has to be built
|
||||||
ansible.builtin.set_fact:
|
ansible.builtin.set_fact:
|
||||||
sms_build_image: >-
|
sms_build_image: >-
|
||||||
@@ -177,15 +110,11 @@
|
|||||||
and (sms_image_docker_hub.rc | default(1)) != 0)
|
and (sms_image_docker_hub.rc | default(1)) != 0)
|
||||||
or (rebuild_image | bool) }}
|
or (rebuild_image | bool) }}
|
||||||
|
|
||||||
# Only reached when no image is present under either name, or when the operator
|
# Only runs when no image exists or rebuild_image is set.
|
||||||
# asked for a rebuild. `podman build` needs buildah, which `podman` Recommends on
|
|
||||||
# Ubuntu and caddy/roles/podman therefore installed.
|
|
||||||
- name: Build the image from the vendored Dockerfile
|
- name: Build the image from the vendored Dockerfile
|
||||||
when: sms_build_image | bool
|
when: sms_build_image | bool
|
||||||
block:
|
block:
|
||||||
# The context is copied rather than built from the repository working copy,
|
# Copy the context so the host builds from a fixed path next to its data.
|
||||||
# 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.
|
|
||||||
- name: Ensure the image build context directory exists
|
- name: Ensure the image build context directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ build_dir }}"
|
path: "{{ build_dir }}"
|
||||||
@@ -208,9 +137,6 @@
|
|||||||
changed_when: true
|
changed_when: true
|
||||||
notify: Restart sms-wa-bridge
|
notify: Restart sms-wa-bridge
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The unit
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
- name: Install the sms-wa-bridge Quadlet unit
|
- name: Install the sms-wa-bridge Quadlet unit
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: sms-wa-bridge.container.j2
|
src: sms-wa-bridge.container.j2
|
||||||
@@ -222,11 +148,7 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart sms-wa-bridge
|
- Restart sms-wa-bridge
|
||||||
|
|
||||||
# Everything from here on inspects a *running* container, and none of it is
|
# The runtime checks need a real unit, so skip them under --check.
|
||||||
# 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.
|
|
||||||
- name: Start sms-wa-bridge
|
- name: Start sms-wa-bridge
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
block:
|
block:
|
||||||
@@ -237,9 +159,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd reports only a generic exit error, so collect the real reason.
|
||||||
# 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.
|
|
||||||
- name: Collect sms-wa-bridge diagnostics
|
- name: Collect sms-wa-bridge diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -258,8 +178,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "sms-wa-bridge did not start — see the diagnostics above."
|
msg: "sms-wa-bridge did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify sms-wa-bridge is running
|
- name: Verify sms-wa-bridge is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: sms_active
|
register: sms_active
|
||||||
@@ -267,10 +186,8 @@
|
|||||||
failed_when: sms_active.stdout != 'active'
|
failed_when: sms_active.stdout != 'active'
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app is serving.
|
# is-active still reports active for a crash-looping container, and the image ships no HEALTHCHECK.
|
||||||
# `Restart=always` means even a crash-looping container is reported active, and
|
# The unit defines one instead, and this runs exactly that command once.
|
||||||
# the image ships no HEALTHCHECK, so the unit defines one (see the template) and
|
|
||||||
# this runs exactly that command once, synchronously.
|
|
||||||
- name: Wait for sms-wa-bridge to pass its health check
|
- name: Wait for sms-wa-bridge to pass its health check
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: sms_health
|
register: sms_health
|
||||||
@@ -280,14 +197,8 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
when: not ansible_check_mode
|
when: not ansible_check_mode
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The health check proves HTTP is up, not that WhatsApp is linked.
|
||||||
# What a running container does not tell you
|
# An unlinked bridge accepts every SMS with 200 and delivers nothing.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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.
|
|
||||||
- name: Read the app's own health payload
|
- name: Read the app's own health payload
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} wget -q -O -
|
podman exec {{ container_name }} wget -q -O -
|
||||||
@@ -302,10 +213,7 @@
|
|||||||
sms_health_json: "{{ sms_health_payload.stdout | from_json }}"
|
sms_health_json: "{{ sms_health_payload.stdout | from_json }}"
|
||||||
when: (sms_health_payload.rc | default(1)) == 0
|
when: (sms_health_payload.rc | default(1)) == 0
|
||||||
|
|
||||||
# Reported, not asserted: an unpaired bridge is a legitimate state — a fresh
|
# An unpaired bridge is a valid fresh-install state, so report it without failing.
|
||||||
# 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.
|
|
||||||
- name: Report what the bridge says about itself
|
- name: Report what the bridge says about itself
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
@@ -337,11 +245,7 @@
|
|||||||
- sms_health_json is defined
|
- sms_health_json is defined
|
||||||
- sms_health_json.waReady | default(false)
|
- sms_health_json.waReady | default(false)
|
||||||
|
|
||||||
# The data half of the same question, asked from the host rather than from the
|
# Missing wwebjs_auth/session means the bridge will print a fresh QR code.
|
||||||
# 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.
|
|
||||||
- name: Check whether a linked WhatsApp session was copied in
|
- name: Check whether a linked WhatsApp session was copied in
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "{{ host_directory }}/wwebjs_auth/session"
|
path: "{{ host_directory }}/wwebjs_auth/session"
|
||||||
|
|||||||
@@ -1,112 +1,48 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Derived from the connection user; requires gather_facts.
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# sms-wa-bridge
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
host: bridge.tanshu.dev
|
host: bridge.tanshu.dev
|
||||||
|
|
||||||
container_name: sms-wa-bridge
|
container_name: sms-wa-bridge
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network, and caddy/roles/register pins the
|
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
|
||||||
# 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.
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
# There is no registry: the image was built locally on monoco with
|
# The image exists in no registry; load it from monoco or build it here.
|
||||||
# `docker build -t sms-wa-bridge` and exists nowhere else. Two ways to get it
|
# A rebuild can re-resolve whatsapp-web.js and break the paired session, so prefer podman load.
|
||||||
# onto emily, and the playbook supports both:
|
# Keep it fully qualified so rootless Podman never prompts; there is no TTY.
|
||||||
#
|
|
||||||
# * `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.
|
|
||||||
image: localhost/sms-wa-bridge:latest
|
image: localhost/sms-wa-bridge:latest
|
||||||
|
|
||||||
# What `podman load` actually calls a docker-saved unqualified tag: it qualifies
|
# What podman load calls a docker-saved tag; the play re-tags it to image.
|
||||||
# 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.
|
|
||||||
image_docker_hub: docker.io/library/sms-wa-bridge:latest
|
image_docker_hub: docker.io/library/sms-wa-bridge:latest
|
||||||
|
|
||||||
# `podman build` is skipped when the image is already present. Set this to true
|
# Set true to rebuild from files/build/ after editing server.js.
|
||||||
# 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
|
|
||||||
rebuild_image: false
|
rebuild_image: false
|
||||||
|
|
||||||
# The whole of this service's state, and a faithful analogue of monoco's
|
# Same four entries and layout as monoco's /var/lib/sms-wa-bridge, so the tar lands as-is.
|
||||||
# /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
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
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"
|
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
|
container_port: 8080
|
||||||
|
|
||||||
# The shared secret MacroDroid sends as `X-Auth`, carried over verbatim from the
|
# Kept verbatim so the cutover only changes the URL.
|
||||||
# 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=.
|
|
||||||
auth_secret: "64f205031acc01744b6edc23"
|
auth_secret: "64f205031acc01744b6edc23"
|
||||||
|
|
||||||
# AUTH_SECRET lives here rather than as an Environment= line in the unit: that
|
# Read by podman through EnvironmentFile=, not by Ansible, so write it before the unit starts.
|
||||||
# file is world-readable in ~/.config. Read by podman (--env-file), not by
|
|
||||||
# Ansible, so it has to be written before the unit starts.
|
|
||||||
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
||||||
|
|
||||||
# The image ships no HEALTHCHECK, so the unit defines one and the play gates on
|
# The runtime image has wget, not curl.
|
||||||
# it. /health is the app's own endpoint and `wget` is what the runtime image has
|
# /health answers 200 whenever the server is up, not when WhatsApp is paired.
|
||||||
# — 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.
|
|
||||||
health_cmd: "wget -q -O /dev/null http://127.0.0.1:{{ container_port }}/health"
|
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
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
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: |
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
reverse_proxy {{ container_name }}:{{ container_port }}
|
reverse_proxy {{ container_name }}:{{ container_port }}
|
||||||
|
|||||||
+2
-38
@@ -1,28 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Tandoor Recipes as a rootless Podman Quadlet unit, behind the shared Caddy
|
# Requires caddy and postgres to have run first.
|
||||||
# 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
|
|
||||||
- name: Deploy Tandoor as a rootless Podman Quadlet
|
- name: Deploy Tandoor as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -30,24 +7,11 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# 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.
|
|
||||||
- name: Register Tandoor's network with Caddy
|
- name: Register Tandoor's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- postgres
|
||||||
# The app: data directories, .env, the Quadlet unit, start + verify.
|
|
||||||
- tandoor
|
- tandoor
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
# A reload keeps existing connections. A restart would briefly drop every hosted site.
|
||||||
# there. These handlers run as the connection user, so `systemctl --user` and
|
# The config path is inside the container, not caddyfile_path on the host.
|
||||||
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
|
|
||||||
|
|
||||||
# Notified by this role when the Caddyfile changes. A reload keeps existing
|
|
||||||
# connections; a restart would briefly drop every hosted site.
|
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -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"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
|
# A new drop-in changes the generated unit, so Postgres is recreated and every attached app restarts.
|
||||||
# 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".
|
|
||||||
- name: Restart Postgres
|
- name: Restart Postgres
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ postgres_unit }}"
|
name: "{{ postgres_unit }}"
|
||||||
|
|||||||
@@ -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
|
- name: Attach Tandoor to Postgres
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
|
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
|
# The restart that joins Postgres to tandoor_net is a handler, so flush it before the app starts or its is-active check fails.
|
||||||
# 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.
|
|
||||||
- name: Apply the Postgres network change before starting Tandoor
|
- name: Apply the Postgres network change before starting Tandoor
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
@@ -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
|
- name: Ensure the Tandoor data directories exist
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}/{{ item }}"
|
path: "{{ host_directory }}/{{ item }}"
|
||||||
@@ -13,16 +7,12 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
loop:
|
loop:
|
||||||
# mediafiles is the irreplaceable one; the other two are regenerated by the
|
# mediafiles is irreplaceable. The other two are regenerated and bind-mounted because the image expects them.
|
||||||
# app and are bind-mounted anyway because the image expects them to exist.
|
|
||||||
- mediafiles
|
- mediafiles
|
||||||
- staticfiles
|
- staticfiles
|
||||||
- externalfiles
|
- externalfiles
|
||||||
|
|
||||||
# The app's secrets and database coordinates are consumed by systemd
|
# systemd reads it through EnvironmentFile= before the unit starts, and it holds SECRET_KEY and the Postgres password.
|
||||||
# (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.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: tandoor.env.j2
|
src: tandoor.env.j2
|
||||||
@@ -52,9 +42,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd only reports "control process exited with error code", so pull the real reason.
|
||||||
# else, which is useless for the usual failures (an unattached network, an
|
|
||||||
# unreachable database, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Tandoor diagnostics
|
- name: Collect Tandoor diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -73,16 +61,14 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Tandoor did not start — see the diagnostics above."
|
msg: "Tandoor did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so `systemctl --user enable` is not needed on a generated unit.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify Tandoor is running
|
- name: Verify Tandoor is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: tandoor_active
|
register: tandoor_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: tandoor_active.stdout != 'active'
|
failed_when: tandoor_active.stdout != 'active'
|
||||||
|
|
||||||
# `db` is the alias the environment file actually asks for; the container name
|
# The environment file asks for `db`; the container name is checked too because the network provides it.
|
||||||
# is checked too because it is what the Quadlet network gives us for free.
|
|
||||||
- name: Confirm Tandoor can resolve its database
|
- name: Confirm Tandoor can resolve its database
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} getent hosts {{ item }}
|
podman exec {{ container_name }} getent hosts {{ item }}
|
||||||
@@ -100,15 +86,8 @@
|
|||||||
{{ item.stdout | default('(no answer)') }}
|
{{ item.stdout | default('(no answer)') }}
|
||||||
loop: "{{ tandoor_db_lookup.results }}"
|
loop: "{{ tandoor_db_lookup.results }}"
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can serve.
|
# `is-active` only says the container is running. Tandoor migrates before gunicorn answers, and its nginx returns 502 until then.
|
||||||
# Tandoor's entrypoint runs Django migrations before gunicorn answers, and until
|
# The probe uses python3 because the Django image guarantees it and may not ship curl or wget.
|
||||||
# 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.
|
|
||||||
- name: Wait for Tandoor to serve HTTP
|
- name: Wait for Tandoor to serve HTTP
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }}
|
podman exec {{ container_name }}
|
||||||
|
|||||||
@@ -1,50 +1,29 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
|
||||||
# Rootless Podman / Quadlet
|
# podman_user comes from the connection user, so the play needs gather_facts.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Tandoor
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
host: recipes.tanshu.com
|
host: recipes.tanshu.com
|
||||||
|
|
||||||
container_name: tandoor
|
container_name: tandoor
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified because rootless Podman would otherwise prompt for a registry, and a unit has no TTY.
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
|
||||||
# the systemd unit starts.
|
|
||||||
image: docker.io/vabene1111/recipes:latest
|
image: docker.io/vabene1111/recipes:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is tandoor.network.
|
||||||
# Podman network name to <network_name>_net (so: tandoor.network, tandoor_net).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ 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
|
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_container: postgres
|
||||||
postgres_unit: "{{ postgres_container }}.service"
|
postgres_unit: "{{ postgres_container }}.service"
|
||||||
|
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
|
|||||||
+2
-38
@@ -1,33 +1,5 @@
|
|||||||
---
|
---
|
||||||
# TriliumNext Notes as a rootless Podman Quadlet unit, behind the shared Caddy
|
# Requires caddy to have run first. State is one SQLite file, not Postgres.
|
||||||
# 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
|
|
||||||
- name: Deploy TriliumNext Notes as a rootless Podman Quadlet
|
- name: Deploy TriliumNext Notes as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -35,18 +7,10 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# unit references {{ network_name }}.network, and Quadlet fails *unit
|
|
||||||
# generation* (so `systemctl --user start` reports "unit not found") when a
|
|
||||||
# referenced .network file does not exist yet. The same include writes the
|
|
||||||
# Caddy-side drop-in that attaches Caddy to this network.
|
|
||||||
#
|
|
||||||
# network_name is a play var, so the include can see it directly.
|
|
||||||
- name: Register Trilium's network with Caddy
|
- name: Register Trilium's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
# The app: the data directory, the Quadlet unit, start + verify.
|
|
||||||
- trilium
|
- trilium
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -1,27 +1,14 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
|
# A reload keeps existing connections. A restart would briefly drop every hosted site.
|
||||||
# there. These handlers run as the connection user, so `systemctl --user` and
|
# The config path is inside the container, not caddyfile_path on the host.
|
||||||
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
|
|
||||||
|
|
||||||
# Notified by this role when the Caddyfile changes. A reload keeps existing
|
|
||||||
# connections; a restart would briefly drop every hosted site.
|
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet has no live equivalent of network connect, so Caddy is recreated and every hosted site restarts.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -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"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
|
|||||||
@@ -1,13 +1,5 @@
|
|||||||
---
|
---
|
||||||
# TriliumNext Notes as a rootless Podman Quadlet unit.
|
# The image keeps document.db, config.ini, session_secret.txt and the subdirectories below in this one tree.
|
||||||
#
|
|
||||||
# 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.
|
|
||||||
- name: Ensure the Trilium data directory exists
|
- name: Ensure the Trilium data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ data_dir }}"
|
path: "{{ data_dir }}"
|
||||||
@@ -16,12 +8,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# Trilium creates these itself on first start, at 0700 — which is exactly how
|
# Trilium creates these at 0700 on first start. Creating them here makes a fresh host and a migrated one look the same.
|
||||||
# they are on monoco. Creating them here means a fresh host and a migrated one
|
# log/ holds the daily log the play reads back as evidence, and backup/ holds the app's own zipped backups.
|
||||||
# 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.
|
|
||||||
- name: Ensure the app-managed subdirectories exist
|
- name: Ensure the app-managed subdirectories exist
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ item }}"
|
path: "{{ item }}"
|
||||||
@@ -34,11 +22,7 @@
|
|||||||
- "{{ tmp_dir }}"
|
- "{{ tmp_dir }}"
|
||||||
- "{{ backup_dir }}"
|
- "{{ backup_dir }}"
|
||||||
|
|
||||||
# Checked before the unit starts, because this is the one thing the play cannot
|
# Check before the unit starts, because an empty document it creates hides whether the copy ever landed.
|
||||||
# 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.
|
|
||||||
- name: Check whether a document has been copied in
|
- name: Check whether a document has been copied in
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "{{ data_dir }}/document.db"
|
path: "{{ data_dir }}/document.db"
|
||||||
@@ -64,15 +48,7 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart Trilium
|
- Restart Trilium
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so the generated unit is never enabled.
|
||||||
# `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.
|
|
||||||
- name: Start and verify Trilium
|
- name: Start and verify Trilium
|
||||||
block:
|
block:
|
||||||
- name: Start Trilium
|
- name: Start Trilium
|
||||||
@@ -82,9 +58,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# Retried rather than checked once: the container is coming up, and podman
|
# Retried because podman reports `activating` until the container signals readiness.
|
||||||
# reports `activating` for the interval between systemd starting it and the
|
|
||||||
# container's own readiness notification.
|
|
||||||
- name: Verify Trilium is running
|
- name: Verify Trilium is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: trilium_active
|
register: trilium_active
|
||||||
@@ -93,11 +67,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can
|
# The image ships no health check, so the unit defines one and this runs it synchronously.
|
||||||
# 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.
|
|
||||||
- name: Wait for Trilium to pass its health check
|
- name: Wait for Trilium to pass its health check
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: trilium_health
|
register: trilium_health
|
||||||
@@ -106,10 +76,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd only reports "control process exited with error code", so read the journal for the real reason.
|
||||||
# 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.
|
|
||||||
- name: Collect Trilium diagnostics
|
- name: Collect Trilium diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -128,19 +95,8 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Trilium did not start — see the diagnostics above."
|
msg: "Trilium did not start — see the diagnostics above."
|
||||||
|
|
||||||
# The check that catches a missing or mistyped Volume= line, which is the one
|
# A missing or mistyped Volume= line would run fine and lose document.db on the next recreate, so check the mount.
|
||||||
# failure here that is silent: the app would run perfectly well with document.db
|
# Both Source and Destination must match, since a destination-only check would pass an anonymous volume.
|
||||||
# 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.
|
|
||||||
- name: Read the running container's configuration back
|
- name: Read the running container's configuration back
|
||||||
ansible.builtin.command: "podman inspect {{ container_name }}"
|
ansible.builtin.command: "podman inspect {{ container_name }}"
|
||||||
register: trilium_inspect
|
register: trilium_inspect
|
||||||
@@ -162,12 +118,7 @@
|
|||||||
check the Volume= line in the generated unit
|
check the Volume= line in the generated unit
|
||||||
({{ quadlet_dir }}/{{ container_name }}.container).
|
({{ quadlet_dir }}/{{ container_name }}.container).
|
||||||
|
|
||||||
# Evidence rather than a gate: with keep-id:uid=1000 mapped onto
|
# 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.
|
||||||
# {{ 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.
|
|
||||||
- name: Look for a log written since the unit started
|
- name: Look for a log written since the unit started
|
||||||
ansible.builtin.find:
|
ansible.builtin.find:
|
||||||
paths: "{{ log_dir }}"
|
paths: "{{ log_dir }}"
|
||||||
@@ -190,9 +141,7 @@
|
|||||||
path: "{{ data_dir }}/document.db"
|
path: "{{ data_dir }}/document.db"
|
||||||
register: trilium_document_db_after
|
register: trilium_document_db_after
|
||||||
|
|
||||||
# A migrated document.db is a few megabytes; the empty one the app creates on a
|
# A migrated document.db is megabytes; the empty one from a fresh install is kilobytes.
|
||||||
# fresh install is kilobytes. Worth printing, because it is the difference
|
|
||||||
# between "the notes came across" and "the copy never ran".
|
|
||||||
- name: Report the document on the host
|
- name: Report the document on the host
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
+12
-63
@@ -1,94 +1,43 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Duplicated from caddy/vars/default.yml on purpose, so keep them in sync.
|
||||||
# Rootless Podman / Quadlet
|
# podman_user comes from the connection user, so the play needs gather_facts.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
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
|
host: notes.tanshu.com
|
||||||
|
|
||||||
container_name: trilium
|
container_name: trilium
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# This is the TriliumNext image, not the retired zadam/trilium. Its document.db carries migrations the old image cannot run.
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when
|
# `latest` has floated well beyond monoco's version, so pin a digest or tag if the runtime should not move.
|
||||||
# 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.
|
|
||||||
image: docker.io/triliumnext/notes:latest
|
image: docker.io/triliumnext/notes:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# caddy/roles/register pins the Podman network to <network_name>_net, so the Quadlet file is trilium.network.
|
||||||
# Podman network name to <network_name>_net (so: trilium.network,
|
|
||||||
# trilium_net).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
# The image's data directory, and the whole of this service's state. Unlike
|
# The image's data directory and the whole of this service's state, all in one tree.
|
||||||
# audiobookshelf/ there is one mount target rather than four, because Trilium
|
|
||||||
# keeps document.db, config.ini and its own subdirectories together.
|
|
||||||
data_dir: "{{ podman_data_dir }}/{{ container_name }}"
|
data_dir: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
container_data_dir: /home/node/trilium-data
|
container_data_dir: /home/node/trilium-data
|
||||||
|
|
||||||
# Subdirectories the app creates and owns itself, at mode 0700 — that is exactly
|
# The app creates these at 0700 on monoco. Creating them here makes a fresh host and a migrated one look the same.
|
||||||
# what they are on monoco. Created by the role so a fresh host and a migrated one
|
# log/ is regenerable, tmp/ is scratch, and backup/ holds the app's own zipped backups worth keeping.
|
||||||
# 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.
|
|
||||||
log_dir: "{{ data_dir }}/log"
|
log_dir: "{{ data_dir }}/log"
|
||||||
tmp_dir: "{{ data_dir }}/tmp"
|
tmp_dir: "{{ data_dir }}/tmp"
|
||||||
backup_dir: "{{ data_dir }}/backup"
|
backup_dir: "{{ data_dir }}/backup"
|
||||||
|
|
||||||
# The port the app listens on inside the container, from config.ini's
|
# The port from config.ini. Caddy proxies to it, and nothing is published on the host.
|
||||||
# `port = 8080`, which the Docker-era container used unchanged. Caddy proxies to
|
|
||||||
# it; nothing is published on the host.
|
|
||||||
container_port: 8080
|
container_port: 8080
|
||||||
|
|
||||||
# The unit's own HEALTHCHECK, because the image does not ship one: v0.106.0
|
# The image ships no health check, so the unit defines one from the endpoint the app advertises.
|
||||||
# dropped the docker_healthcheck.cjs that v0.95.0 had, so `podman healthcheck
|
# `curl` is in the image; `wget` is not.
|
||||||
# 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.
|
|
||||||
health_cmd: "curl -fsS -o /dev/null http://127.0.0.1:{{ container_port }}/api/health-check"
|
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
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
# Caddy handles the WebSocket upgrade Trilium's live-updating editor needs, and
|
# Caddy handles the WebSocket upgrade the live editor needs and imposes no request body limit.
|
||||||
# imposes no request-body limit, so there is nothing to carry over from the old
|
|
||||||
# nginx front end.
|
|
||||||
snippet_block: |
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
reverse_proxy {{ container_name }}:{{ container_port }}
|
reverse_proxy {{ container_name }}:{{ container_port }}
|
||||||
|
|||||||
+1
-23
@@ -1,25 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Deploys Valkey as a rootless Podman Quadlet unit.
|
# Run this before the app playbooks. Each service attaches itself.
|
||||||
#
|
|
||||||
# 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
|
|
||||||
- name: Deploy Valkey as a rootless Podman Quadlet
|
- name: Deploy Valkey as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -27,6 +7,4 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
# The base Valkey unit. It does NOT know which app networks it serves.
|
|
||||||
# Each service attaches itself via valkey/roles/attach.
|
|
||||||
- valkey
|
- valkey
|
||||||
@@ -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"
|
- name: "Check that the {{ network_name }} network unit exists"
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: "{{ quadlet_dir }}/{{ network_name }}.network"
|
path: "{{ quadlet_dir }}/{{ network_name }}.network"
|
||||||
register: valkey_attach_network
|
register: valkey_attach_network
|
||||||
|
|
||||||
# A drop-in naming a .network file that does not exist makes Quadlet fail to
|
# A drop-in naming a missing network breaks unit generation with an unrelated
|
||||||
# generate the whole valkey unit, with a message that does not mention the
|
# error, so fail here instead.
|
||||||
# drop-in at all. Fail here instead, where the cause is obvious.
|
|
||||||
- name: "Fail if {{ network_name }} has no network unit"
|
- name: "Fail if {{ network_name }} has no network unit"
|
||||||
ansible.builtin.assert:
|
ansible.builtin.assert:
|
||||||
that: valkey_attach_network.stat.exists
|
that: valkey_attach_network.stat.exists
|
||||||
@@ -64,21 +31,14 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# Added by the {{ network_name }} playbook. Do not edit by hand.
|
# Added by {{ network_name }}. Deleting it detaches the network from
|
||||||
#
|
# {{ valkey_container | default('valkey') }} on the next daemon-reload.
|
||||||
# Deleting this file removes the network from {{ valkey_container | default('valkey') }} on
|
|
||||||
# the next daemon-reload; nothing else changes.
|
|
||||||
[Container]
|
[Container]
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
notify: Restart Valkey
|
notify: Restart Valkey
|
||||||
|
|
||||||
# NetworkAlias= applies to every network the container joins, which is exactly
|
# NetworkAlias applies to every network, so one shared file holds the fixed
|
||||||
# right for a hub: this repo's cache clients reach Valkey as `redis` (nextcloud,
|
# alias. Two services writing their own would pass --network-alias twice.
|
||||||
# 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.
|
|
||||||
- name: "Give {{ valkey_container | default('valkey') }} the shared 'redis' alias"
|
- name: "Give {{ valkey_container | default('valkey') }} the shared 'redis' alias"
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: >-
|
dest: >-
|
||||||
|
|||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
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
|
- name: Restart Valkey
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
@@ -14,28 +14,3 @@
|
|||||||
state: restarted
|
state: restarted
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
listen: Restart Valkey
|
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".
|
|
||||||
@@ -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
|
# The entrypoint runs as a subuid under keep-id, so create the data directory
|
||||||
# runs as container-root, which under keep-id is a subuid on the host, so a
|
# here to keep it owned by podman_user rather than 165536.
|
||||||
# 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.
|
|
||||||
- name: Ensure the Valkey data directory exists
|
- name: Ensure the Valkey data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}"
|
path: "{{ host_directory }}"
|
||||||
@@ -41,11 +29,8 @@
|
|||||||
- Reload systemd user units
|
- Reload systemd user units
|
||||||
- Restart Valkey
|
- Restart Valkey
|
||||||
|
|
||||||
# The wait is part of the block on purpose. `Restart=always` means systemd
|
# `Restart=always` reports the unit active while the container crash-loops, so
|
||||||
# reports the unit active even while the container crash-loops — a wrong keep-id
|
# wait for a real PING instead of trusting is-active.
|
||||||
# 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.
|
|
||||||
- name: Start Valkey and wait for it to answer
|
- name: Start Valkey and wait for it to answer
|
||||||
block:
|
block:
|
||||||
- name: Start Valkey
|
- name: Start Valkey
|
||||||
@@ -55,8 +40,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
|
|
||||||
# valkey-cli is the server's own client and is guaranteed present, the same
|
# valkey-cli ships in the image, so it is always available for the check.
|
||||||
# way pg_isready is in the Postgres image.
|
|
||||||
- name: Wait for Valkey to answer PING
|
- name: Wait for Valkey to answer PING
|
||||||
ansible.builtin.command: "podman exec {{ container_name }} valkey-cli ping"
|
ansible.builtin.command: "podman exec {{ container_name }} valkey-cli ping"
|
||||||
register: valkey_ping
|
register: valkey_ping
|
||||||
@@ -65,9 +49,7 @@
|
|||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd only reports a generic exit code, so collect the real reason.
|
||||||
# which is useless for the usual failures (a bad uid mapping, an unwritable
|
|
||||||
# data directory, a missing image). Pull the real reason out.
|
|
||||||
- name: Collect Valkey diagnostics
|
- name: Collect Valkey diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -85,8 +67,7 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Valkey did not come up — see the diagnostics above."
|
msg: "Valkey did not come up — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy=, so generated units cannot be enabled.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify the Valkey unit is active
|
- name: Verify the Valkey unit is active
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: valkey_active
|
register: valkey_active
|
||||||
@@ -116,9 +97,7 @@
|
|||||||
{{ valkey_dropins.files | map(attribute='path') | map('basename') | join(', ')
|
{{ valkey_dropins.files | map(attribute='path') | map('basename') | join(', ')
|
||||||
| default('(nothing yet)', true) }}.
|
| default('(nothing yet)', true) }}.
|
||||||
|
|
||||||
# Say this out loud rather than let it be discovered by a failing app: with no
|
# With no drop-in there is no Network=, so it sits on the default Podman network.
|
||||||
# drop-in, Valkey has no Network= at all, so it sits on the default Podman
|
|
||||||
# network and no app network can resolve it.
|
|
||||||
- name: Note that no service has attached yet
|
- name: Note that no service has attached yet
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
|
|||||||
+9
-38
@@ -1,58 +1,29 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# A play cannot include caddy/vars/default.yml, which also sets host_directory,
|
||||||
# Rootless Podman / Quadlet
|
# so keep these in sync with it. Set gather_facts: true for the facts below.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Valkey
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
container_name: valkey
|
container_name: valkey
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified, because short names prompt for a registry and the unit has
|
||||||
# unqualified-search-registries, and there is no TTY to answer a prompt when the
|
# no TTY.
|
||||||
# systemd unit starts. The Docker-era playbook said `valkey/valkey:alpine`.
|
|
||||||
image: docker.io/valkey/valkey:alpine
|
image: docker.io/valkey/valkey:alpine
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
# The image's WORKDIR and VOLUME: the RDB snapshot and, if it is ever enabled,
|
# The image volume holds the RDB snapshot. A graceful stop writes a final one
|
||||||
# the AOF live here. The contents are disposable — nothing needs to be copied
|
# here, so keep the mount unless you also set `save ""`.
|
||||||
# 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".
|
|
||||||
data_mount: /data
|
data_mount: /data
|
||||||
|
|
||||||
# The uid and gid of the image's `valkey` account. The uid is 999 — the one the
|
# The image account is uid 999 and gid 1000, not 999: Alpine already has a gid
|
||||||
# anonymous Docker volume was owned by (docs/podman-compat-audit.md §3.2) — but
|
# 999. Mapping both keeps host_directory owned by podman_user.
|
||||||
# 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.
|
|
||||||
app_uid: 999
|
app_uid: 999
|
||||||
app_gid: 1000
|
app_gid: 1000
|
||||||
|
|
||||||
# No PublishPort, on purpose: the Docker-era playbook published 127.0.0.1:6379,
|
# No PublishPort: clients reach Valkey over the app networks, not the host.
|
||||||
# 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.
|
|
||||||
container_port: 6379
|
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"
|
valkey_dropin_dir: "{{ quadlet_dir }}/{{ container_name }}.container.d"
|
||||||
+2
-39
@@ -1,28 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Wiki.js as a rootless Podman Quadlet unit, behind the shared Caddy unit
|
# Requires caddy and postgres to have run first.
|
||||||
# (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
|
|
||||||
- name: Deploy Wiki.js as a rootless Podman Quadlet
|
- name: Deploy Wiki.js as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -30,25 +7,11 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# 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.
|
|
||||||
- name: Register Wiki.js's network with Caddy
|
- name: Register Wiki.js's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- postgres
|
||||||
# The app: content and upload directories, the 0600 environment file, the
|
|
||||||
# Quadlet unit, start + verify.
|
|
||||||
- wikijs
|
- wikijs
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -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
|
# A reload keeps live connections; a restart would drop every hosted site.
|
||||||
# connections; a restart would briefly drop every hosted site.
|
# The path is inside the container, not the host {{ caddyfile_path }}.
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet cannot attach a network live, so Caddy must be recreated.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first when both are notified.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap config
|
|
||||||
# reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
---
|
---
|
||||||
# Append this site's block to the shared Caddyfile.
|
|
||||||
#
|
|
||||||
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
|
||||||
# change as soon as the file is written; the handler only asks Caddy to re-read
|
|
||||||
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
|
||||||
#
|
|
||||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
|
||||||
# slurp the file and compare first — which also matters because slurp *fails*
|
|
||||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
|
||||||
# not run.
|
|
||||||
|
|
||||||
- name: "Add the {{ host }} site block to the Caddyfile"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
---
|
---
|
||||||
# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet
|
# A new drop-in changes the generated unit, so Postgres must be recreated to join the network.
|
||||||
# unit there. This handler runs as the connection user, so `systemctl --user`
|
# That restarts every attached app, not just this one.
|
||||||
# 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".
|
|
||||||
- name: Restart Postgres
|
- name: Restart Postgres
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ postgres_unit }}"
|
name: "{{ postgres_unit }}"
|
||||||
|
|||||||
@@ -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
|
- name: Attach Wiki.js to Postgres
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml"
|
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
|
# The restart is a handler, so it would run after the wikijs role starts the app.
|
||||||
# join hiwikijs_net — and the shared 10-network-aliases.conf is what creates the
|
# Flushing applies the new drop-in before the app starts; later runs are a no-op.
|
||||||
# `db` alias DB_HOST asks for. That restart is a *handler*, which by default runs
|
# Flush also applies Caddy's pending restart, which is right: Caddy must join the network to proxy.
|
||||||
# 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.
|
|
||||||
- name: Apply the Postgres network change before starting Wiki.js
|
- name: Apply the Postgres network change before starting Wiki.js
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
# A changed environment file or unit file changes the generated unit, so Wiki.js
|
# A recreate keeps the wiki in Postgres and the bind mounts, but costs a few seconds of downtime.
|
||||||
# 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.
|
|
||||||
- name: Restart Wiki.js
|
- name: Restart Wiki.js
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
|
|||||||
@@ -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
|
# Both must be bind mounts so nothing the app writes lands in the container layer.
|
||||||
# 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.
|
|
||||||
- name: Ensure the Wiki.js data directories exist
|
- name: Ensure the Wiki.js data directories exist
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ item }}"
|
path: "{{ item }}"
|
||||||
@@ -25,9 +12,7 @@
|
|||||||
- "{{ content_dir }}"
|
- "{{ content_dir }}"
|
||||||
- "{{ uploads_dir }}"
|
- "{{ uploads_dir }}"
|
||||||
|
|
||||||
# The app's database coordinates are consumed by systemd (EnvironmentFile= in the
|
# Written before the unit starts; 0600 keeps the Postgres password from a world-readable unit.
|
||||||
# Quadlet unit), not by Ansible, so this has to be written before the unit starts.
|
|
||||||
# Mode 0600: DB_PASS carries the Postgres password.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: wikijs.env.j2
|
src: wikijs.env.j2
|
||||||
@@ -57,9 +42,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd reports only a generic exit error, so collect the real reason.
|
||||||
# which is useless for the usual failures (an unattached network, an
|
|
||||||
# unreachable database, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Wiki.js diagnostics
|
- name: Collect Wiki.js diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -78,26 +61,15 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Wiki.js did not start — see the diagnostics above."
|
msg: "Wiki.js did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
|
||||||
- name: Verify Wiki.js is running
|
- name: Verify Wiki.js is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: wikijs_active
|
register: wikijs_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: wikijs_active.stdout != 'active'
|
failed_when: wikijs_active.stdout != 'active'
|
||||||
|
|
||||||
# The database is the whole wiki, and it arrives over the network that
|
# An unresolvable database would otherwise show up as two minutes of retries and a bare curl error.
|
||||||
# roles/postgres attached Postgres to. Check the name the environment file
|
# getent exits 2 when the name does not resolve.
|
||||||
# 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.
|
|
||||||
- name: Confirm Wiki.js can resolve its database
|
- name: Confirm Wiki.js can resolve its database
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} getent hosts {{ item }}
|
podman exec {{ container_name }} getent hosts {{ item }}
|
||||||
@@ -123,16 +95,8 @@
|
|||||||
That means hiwikijs_net was never attached to the postgres unit — see
|
That means hiwikijs_net was never attached to the postgres unit — see
|
||||||
roles/postgres and postgres/readme.md, "How services attach themselves".
|
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.
|
# The image declares no HEALTHCHECK, so probe HTTP with its own curl.
|
||||||
# Wiki.js runs its own schema migrations against Postgres before it binds port
|
# -f turns the 4xx/5xx served while the database is unreachable into a failed retry.
|
||||||
# {{ 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.
|
|
||||||
- name: Wait for Wiki.js to serve HTTP
|
- name: Wait for Wiki.js to serve HTTP
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }}
|
podman exec {{ container_name }}
|
||||||
|
|||||||
+12
-63
@@ -1,102 +1,51 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Derived from the connection user; requires gather_facts.
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The service keeps its Docker-era name hiwikijs everywhere; only the directory is wikijs.
|
||||||
# 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.
|
|
||||||
host: wiki.hinchco.in
|
host: wiki.hinchco.in
|
||||||
|
|
||||||
container_name: hiwikijs
|
container_name: hiwikijs
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: registry + org + repo, so rootless Podman never
|
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
|
||||||
# consults unqualified-search-registries. There is no TTY to answer a short-name
|
|
||||||
# prompt when the systemd unit starts.
|
|
||||||
image: ghcr.io/requarks/wiki:latest
|
image: ghcr.io/requarks/wiki:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
|
||||||
# Podman network name to <network_name>_net (so: hiwikijs.network,
|
|
||||||
# hiwikijs_net — the name the Docker-era container used).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ 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
|
# The image runs as uid 1000, and keep-id maps it onto podman_user.
|
||||||
# 1000 there (verified: `docker exec hiwikijs id` → uid=1000(node) gid=1000).
|
# Unlike bitwarden and tandoor, Wiki.js runs unprivileged.
|
||||||
# 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.
|
|
||||||
wikijs_uid: 1000
|
wikijs_uid: 1000
|
||||||
wikijs_gid: 1000
|
wikijs_gid: 1000
|
||||||
|
|
||||||
# The image declares VOLUME /wiki/data/content. On Docker that silently became an
|
# The image declares this as a volume; bind it to a real path so backups can find it.
|
||||||
# *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.
|
|
||||||
content_dir: "{{ host_directory }}/content"
|
content_dir: "{{ host_directory }}/content"
|
||||||
|
|
||||||
# Not a declared volume, but it is where Wiki.js's default "Local File System"
|
# Not a declared volume, but a local storage target would write here.
|
||||||
# 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.
|
|
||||||
uploads_dir: "{{ host_directory }}/uploads"
|
uploads_dir: "{{ host_directory }}/uploads"
|
||||||
|
|
||||||
# DB_PASS carries the Postgres password and the Quadlet unit is world-readable in
|
# The unit is world-readable, so the password goes in this 0600 file.
|
||||||
# ~/.config, so it goes in this 0600 file instead and is read by systemd through
|
|
||||||
# EnvironmentFile=.
|
|
||||||
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
||||||
|
|
||||||
# From the image's own config.yml (`port: 3000`, `bindIP: 0.0.0.0`). Caddy
|
# The image's config.yml sets port 3000.
|
||||||
# proxies to it over the app network; nothing is published host-side.
|
|
||||||
container_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_container: postgres
|
||||||
postgres_unit: "{{ postgres_container }}.service"
|
postgres_unit: "{{ postgres_container }}.service"
|
||||||
|
|
||||||
# The credentials the Docker-era playbook embedded in its .env file, kept as-is
|
# Kept as-is so the restored database and the app's connection string agree.
|
||||||
# so the already-restored `hiwikijs` database and the app's own connection string
|
|
||||||
# agree. Postgres is authoritative for this service; nothing here is new.
|
|
||||||
pg_user: postgres
|
pg_user: postgres
|
||||||
pg_password: '123456'
|
pg_password: '123456'
|
||||||
pg_database: hiwikijs
|
pg_database: hiwikijs
|
||||||
|
|
||||||
# `db`, not `postgres`: that is literally what the Docker-era container ran with
|
# The image expects the db alias; postgres/roles/attach writes the NetworkAlias=db drop-in.
|
||||||
# (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.
|
|
||||||
db_host: db
|
db_host: db
|
||||||
|
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
|
|||||||
+2
-39
@@ -1,28 +1,5 @@
|
|||||||
---
|
---
|
||||||
# Yamtrack as a rootless Podman Quadlet unit, behind the shared Caddy unit
|
# Requires caddy and valkey to have run first. The database is SQLite.
|
||||||
# (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
|
|
||||||
- name: Deploy Yamtrack as a rootless Podman Quadlet
|
- name: Deploy Yamtrack as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -30,25 +7,11 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet needs the network unit before it generates the app unit.
|
||||||
# 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.
|
|
||||||
- name: Register Yamtrack's network with Caddy
|
- name: Register Yamtrack's network with Caddy
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
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
|
- valkey
|
||||||
# The app: data directory, the 0600 environment file, the Quadlet unit,
|
|
||||||
# start + verify.
|
|
||||||
- yamtrack
|
- yamtrack
|
||||||
# Appends this site's block to the shared Caddyfile and reloads Caddy.
|
|
||||||
- caddy
|
- caddy
|
||||||
@@ -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
|
# A reload keeps live connections; a restart would drop every hosted site.
|
||||||
# connections; a restart would briefly drop every hosted site.
|
# The path is inside the container, not the host {{ caddyfile_path }}.
|
||||||
#
|
|
||||||
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
|
|
||||||
# not {{ caddyfile_path }}, which is the host side of it.
|
|
||||||
- name: Reload Caddy configuration
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by caddy/roles/register when this service's network unit, or the
|
# Quadlet cannot attach a network live, so Caddy must be recreated.
|
||||||
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
|
# Defined after the reload so the cheap reload runs first when both are notified.
|
||||||
# of `docker network connect`, so the container has to be recreated — a brief
|
|
||||||
# restart of every hosted site, not just this one. See caddy/readme.md,
|
|
||||||
# "Trade-off worth knowing".
|
|
||||||
#
|
|
||||||
# Defined after the reload above so that when both are notified the cheap
|
|
||||||
# config reload happens first.
|
|
||||||
- name: Restart Caddy
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -1,14 +1,4 @@
|
|||||||
---
|
---
|
||||||
# Append this site's block to the shared Caddyfile.
|
|
||||||
#
|
|
||||||
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
|
||||||
# change as soon as the file is written; the handler only asks Caddy to re-read
|
|
||||||
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
|
||||||
#
|
|
||||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
|
||||||
# slurp the file and compare first — which also matters because slurp *fails*
|
|
||||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
|
||||||
# not run. (The Docker-era version of this role did exactly that.)
|
|
||||||
|
|
||||||
- name: "Add the {{ host }} site block to the Caddyfile"
|
- name: "Add the {{ host }} site block to the Caddyfile"
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
|
|||||||
@@ -1,16 +1,6 @@
|
|||||||
---
|
---
|
||||||
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
|
# A new drop-in changes the generated unit, so Valkey must be recreated to join the network.
|
||||||
# there. This handler runs as the connection user, so `systemctl --user` works
|
# That restarts every attached client, not just Yamtrack.
|
||||||
# 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".
|
|
||||||
- name: Restart Valkey
|
- name: Restart Valkey
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ valkey_unit }}"
|
name: "{{ valkey_unit }}"
|
||||||
|
|||||||
@@ -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
|
- name: Attach Yamtrack to Valkey
|
||||||
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
|
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
|
# The restart is a handler, so it would run after the yamtrack role starts the app.
|
||||||
# yamtrack_net — and that restart is a *handler*, which by default runs at the
|
# Flushing applies the new drop-in before the app starts; later runs are a no-op.
|
||||||
# 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.
|
|
||||||
- name: Apply the Valkey network change before starting Yamtrack
|
- name: Apply the Valkey network change before starting Yamtrack
|
||||||
ansible.builtin.meta: flush_handlers
|
ansible.builtin.meta: flush_handlers
|
||||||
@@ -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
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: Reload systemd user units
|
listen: Reload systemd user units
|
||||||
|
|
||||||
# A changed environment file or unit file changes the generated unit, so
|
# A recreate keeps state in the bind mount but costs a few seconds of downtime.
|
||||||
# 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.
|
|
||||||
- name: Restart Yamtrack
|
- name: Restart Yamtrack
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ unit_name }}"
|
name: "{{ unit_name }}"
|
||||||
|
|||||||
@@ -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
|
# /yamtrack/db holds db.sqlite3 and its WAL sidecars; everything else is image content.
|
||||||
# sidecars, and nothing else worth keeping — staticfiles/ is built into the
|
|
||||||
# image and the code is read-only.
|
|
||||||
- name: Ensure the Yamtrack data directory exists
|
- name: Ensure the Yamtrack data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ data_dir }}"
|
path: "{{ data_dir }}"
|
||||||
@@ -16,9 +9,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# The app's secrets and cache coordinates are consumed by systemd
|
# Written before the unit starts; 0600 keeps SECRET out of the world-readable unit.
|
||||||
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
|
|
||||||
# written before the unit starts. Mode 0600: it holds SECRET.
|
|
||||||
- name: Write the environment file
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: yamtrack.env.j2
|
src: yamtrack.env.j2
|
||||||
@@ -48,9 +39,7 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing
|
# systemd reports only a generic exit error, so collect the real reason.
|
||||||
# else, which is useless for the usual failures (an unattached network, an
|
|
||||||
# unreachable cache, a bad volume). Pull the real reason out.
|
|
||||||
- name: Collect Yamtrack diagnostics
|
- name: Collect Yamtrack diagnostics
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
@@ -69,22 +58,15 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: "Yamtrack did not start — see the diagnostics above."
|
msg: "Yamtrack did not start — see the diagnostics above."
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours WantedBy=, so systemctl enable is neither needed nor possible on a generated unit.
|
||||||
# `systemctl --user 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.
|
||||||
#
|
|
||||||
# `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.
|
|
||||||
- name: Verify Yamtrack is running
|
- name: Verify Yamtrack is running
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: yamtrack_active
|
register: yamtrack_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: yamtrack_active.stdout != 'active'
|
failed_when: yamtrack_active.stdout != 'active'
|
||||||
|
|
||||||
# `valkey` is the host the environment file actually asks for; it resolves from
|
# The app expects the valkey host; attach joined the shared unit to this app's network.
|
||||||
# ContainerName= on the shared unit, which valkey/roles/attach joined to this
|
|
||||||
# app's network.
|
|
||||||
- name: Confirm Yamtrack can resolve its cache
|
- name: Confirm Yamtrack can resolve its cache
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ container_name }} getent hosts {{ valkey_container }}
|
podman exec {{ container_name }} getent hosts {{ valkey_container }}
|
||||||
@@ -98,19 +80,9 @@
|
|||||||
{{ valkey_container }} resolves as
|
{{ valkey_container }} resolves as
|
||||||
{{ yamtrack_cache_lookup.stdout | default('(no answer)') }}
|
{{ yamtrack_cache_lookup.stdout | default('(no answer)') }}
|
||||||
|
|
||||||
# `is-active` says the container is running; the image's own HEALTHCHECK is what
|
# The image's own HEALTHCHECK covers the cache, database, Redis and Celery, so a pass means ready.
|
||||||
# says the app works. It wgets http://127.0.0.1:8000/health/, and that view
|
# Running the image's check means the play depends on no path or tool of its own.
|
||||||
# checks all four things this deployment can get wrong — the cache, the database,
|
# The start period plus migrations is why this allows three minutes.
|
||||||
# Redis and Celery — so a green result here is the app being genuinely ready.
|
|
||||||
#
|
|
||||||
# Running the image's check rather than one written here means depending on
|
|
||||||
# nothing the play has to know: no curl or wget wrapper of our own, and no
|
|
||||||
# hardcoded path that a later image tag could move. `podman exec <c> wget -qO-
|
|
||||||
# http://127.0.0.1:{{ container_port }}/health/` prints the per-check breakdown
|
|
||||||
# when this fails; see readme.md, "Verification".
|
|
||||||
#
|
|
||||||
# The start period (30s) plus the entrypoint's migrations are why this allows
|
|
||||||
# three minutes rather than one.
|
|
||||||
- name: Wait for Yamtrack to pass its own health check
|
- name: Wait for Yamtrack to pass its own health check
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: yamtrack_health
|
register: yamtrack_health
|
||||||
|
|||||||
+12
-71
@@ -1,109 +1,50 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Derived from the connection user; requires gather_facts.
|
||||||
# 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).
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Yamtrack
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
host: track.tanshu.com
|
host: track.tanshu.com
|
||||||
|
|
||||||
container_name: yamtrack
|
container_name: yamtrack
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# Fully qualified on purpose: rootless Podman resolves short names against
|
# Fully qualified so rootless Podman never prompts for a short name; there is no TTY.
|
||||||
# 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".
|
|
||||||
image: ghcr.io/fuzzygrim/yamtrack:latest
|
image: ghcr.io/fuzzygrim/yamtrack:latest
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network. caddy/roles/register pins the
|
# The Quadlet file is <network_name>.network; register pins <network_name>_net.
|
||||||
# Podman network name to <network_name>_net (so: yamtrack.network, yamtrack_net,
|
|
||||||
# which is the name the Docker-era deployment used).
|
|
||||||
network_name: "{{ container_name }}"
|
network_name: "{{ container_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
# The image's bind mount: /yamtrack/db holds db.sqlite3 and its WAL sidecars.
|
# /yamtrack/db holds db.sqlite3 and its WAL sidecars; nothing else on /yamtrack is state.
|
||||||
# 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.
|
|
||||||
data_dir: "{{ host_directory }}/data"
|
data_dir: "{{ host_directory }}/data"
|
||||||
|
|
||||||
# The port inside the container that Caddy proxies to: the image's own nginx
|
# The image's nginx listens on 8000 and forwards to gunicorn.
|
||||||
# listens there and forwards to gunicorn. Reproduces the Docker-era Caddyfile
|
|
||||||
# entry (`reverse_proxy yamtrack:8000`) exactly.
|
|
||||||
container_port: 8000
|
container_port: 8000
|
||||||
|
|
||||||
# SECRET, TZ and REDIS_URL live here rather than as Environment= lines in the
|
# The unit is world-readable, so secrets go in this 0600 file.
|
||||||
# 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.
|
|
||||||
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
env_file: "{{ host_directory }}/{{ container_name }}.env"
|
||||||
|
|
||||||
# The uid this unit maps onto podman_user, and the PUID/PGID the image's
|
# It is 0 because the entrypoint rewrites /etc/group and /etc/passwd as container root.
|
||||||
# entrypoint is given. It is 0 — container root — and that is deliberate:
|
# 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.
|
||||||
# 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".
|
|
||||||
app_uid: 0
|
app_uid: 0
|
||||||
app_gid: 0
|
app_gid: 0
|
||||||
|
|
||||||
yamtrack_tz: "Asia/Kolkata"
|
yamtrack_tz: "Asia/Kolkata"
|
||||||
|
|
||||||
# The Django SECRET_KEY, as the image calls it (`SECRET`). Carried over verbatim
|
# Carried over so existing sessions and password-reset tokens stay valid.
|
||||||
# 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.
|
|
||||||
yamtrack_secret: "c14c5970eb728fbf8b2325fd3004069d"
|
yamtrack_secret: "c14c5970eb728fbf8b2325fd3004069d"
|
||||||
|
|
||||||
# What the old container passed as URLS, which Yamtrack turns into its CSRF
|
# ALLOWED_HOSTS is deliberately unset; its default is *.
|
||||||
# trusted origins. ALLOWED_HOSTS is deliberately not set: its default is `*`,
|
|
||||||
# which is what the Docker-era container relied on.
|
|
||||||
urls: "https://{{ host }}"
|
urls: "https://{{ host }}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# The container is called valkey, so the Redis URL is unchanged.
|
||||||
# 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.
|
|
||||||
valkey_container: valkey
|
valkey_container: valkey
|
||||||
valkey_unit: "{{ valkey_container }}.service"
|
valkey_unit: "{{ valkey_container }}.service"
|
||||||
redis_url: "redis://{{ valkey_container }}:6379"
|
redis_url: "redis://{{ valkey_container }}:6379"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Caddy
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml.
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user