Add the Yamtrack playbook

This commit is contained in:
tanshu committed 2026-10-18 21:35:53 +05:30
1 parent 26a0654fea
commit af2e7394b8
11 files changed
+1023

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
---
# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit
# there. These handlers run as the connection user, so `systemctl --user` and
# `podman exec` work without any XDG_RUNTIME_DIR juggling.
# Notified by this role when the Caddyfile changes. A reload keeps existing
# connections; a restart would briefly drop every hosted site.
#
# Note the config path: /etc/caddy/Caddyfile is the mount inside the container,
# not {{ caddyfile_path }}, which is the host side of it.
- name: Reload Caddy configuration
ansible.builtin.command: >-
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
changed_when: true
listen: "Reload Caddy"
# Notified by caddy/roles/register when this service's network unit, or the
# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent
# of `docker network connect`, so the container has to be recreated — a brief
# restart of every hosted site, not just this one. See caddy/readme.md,
# "Trade-off worth knowing".
#
# Defined after the reload above so that when both are notified the cheap
# config reload happens first.
- name: Restart Caddy
ansible.builtin.systemd_service:
name: "{{ caddy_name }}.service"
scope: user
state: restarted
daemon_reload: true
listen: Restart Caddy
+22
View File
@@ -0,0 +1,22 @@
---
# Append this site's block to the shared Caddyfile.
#
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
# change as soon as the file is written; the handler only asks Caddy to re-read
# it. That is a reload, not a restart, so the other hosted sites are untouched.
#
# blockinfile is idempotent on its own via the marker, so there is no need to
# slurp the file and compare first — which also matters because slurp *fails*
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
# not run. (The Docker-era version of this role did exactly that.)
- name: "Add the {{ host }} site block to the Caddyfile"
ansible.builtin.blockinfile:
path: "{{ caddyfile_path }}"
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
block: "{{ snippet_block }}"
create: yes
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: Reload Caddy
+20
View File
@@ -0,0 +1,20 @@
---
# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit
# there. This handler runs as the connection user, so `systemctl --user` works
# without setting XDG_RUNTIME_DIR.
#
# Required by valkey/roles/attach, which notifies it whenever this app's drop-in
# changes: a new drop-in changes the generated unit, so Valkey must be recreated
# to join the network — Quadlet has no live equivalent of
# `podman network connect`.
#
# The cost is a cold cache for every attached app, not lost data — but note that
# attached means every cache client, not just Yamtrack. See valkey/readme.md,
# "The trade-off".
- name: Restart Valkey
ansible.builtin.systemd_service:
name: "{{ valkey_unit }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Valkey
+31
View File
@@ -0,0 +1,31 @@
---
# 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.
- name: Attach Yamtrack to Valkey
ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml"
# On a first attach the drop-in is new, so Valkey has to be recreated to join
# yamtrack_net — and that restart is a *handler*, which by default runs at the
# end of the play, after the yamtrack role has already started the app. The app
# would then come up before `valkey` resolves, fail its own checks, and fail the
# play on a machine where nothing is actually wrong.
#
# Flushing applies the new drop-in before the app starts. On later runs the
# drop-in is unchanged, nothing is pending, and this is a no-op.
- name: Apply the Valkey network change before starting Yamtrack
ansible.builtin.meta: flush_handlers
@@ -0,0 +1,20 @@
---
# All handlers run as the connection user ({{ podman_user }}), so systemctl
# --user works without setting XDG_RUNTIME_DIR.
- name: Reload systemd user units
ansible.builtin.command: systemctl --user daemon-reload
changed_when: true
listen: Reload systemd user units
# A changed environment file or unit file changes the generated unit, so
# Yamtrack is recreated. The database is a bind mount and the rest of /yamtrack
# is image content, so a recreate loses nothing — but it is a few seconds of
# downtime for the one site.
- name: Restart Yamtrack
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: restarted
daemon_reload: true
listen: Restart Yamtrack
+121
View File
@@ -0,0 +1,121 @@
---
# Yamtrack as a rootless Podman Quadlet unit.
#
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# /yamtrack/db is the image's state directory: db.sqlite3 plus its -wal/-shm
# sidecars, and nothing else worth keeping — staticfiles/ is built into the
# image and the code is read-only.
- name: Ensure the Yamtrack data directory exists
ansible.builtin.file:
path: "{{ data_dir }}"
state: directory
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0755'
# The app's secrets and cache coordinates are consumed by systemd
# (EnvironmentFile= in the Quadlet unit), not by Ansible, so this has to be
# written before the unit starts. Mode 0600: it holds SECRET.
- name: Write the environment file
ansible.builtin.template:
src: yamtrack.env.j2
dest: "{{ env_file }}"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0600'
notify: Restart Yamtrack
- name: Install the Yamtrack Quadlet unit
ansible.builtin.template:
src: yamtrack.container.j2
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify:
- Reload systemd user units
- Restart Yamtrack
- name: Start Yamtrack
block:
- name: Start Yamtrack
ansible.builtin.systemd_service:
name: "{{ unit_name }}"
scope: user
state: started
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing
# else, which is useless for the usual failures (an unattached network, an
# unreachable cache, a bad volume). Pull the real reason out.
- name: Collect Yamtrack diagnostics
ansible.builtin.command: "{{ item }}"
loop:
- "systemctl --user status {{ unit_name }} --no-pager"
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
- "podman logs --tail 40 {{ container_name }}"
register: yamtrack_diag
changed_when: false
failed_when: false
- name: Show Yamtrack diagnostics
ansible.builtin.debug:
msg: "{{ yamtrack_diag.results | map(attribute='stdout') | list }}"
- name: Fail
ansible.builtin.fail:
msg: "Yamtrack did not start — see the diagnostics above."
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
#
# `is-active` says the container is running; it does not say the app can serve.
# The entrypoint runs migrations before supervisord starts nginx, gunicorn,
# celery and beat, and `Restart=always` means even a crash-looping container is
# reported active — so the checks below are the ones that matter.
- name: Verify Yamtrack is running
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
register: yamtrack_active
changed_when: false
failed_when: yamtrack_active.stdout != 'active'
# `valkey` is the host the environment file actually asks for; it resolves from
# ContainerName= on the shared unit, which valkey/roles/attach joined to this
# app's network.
- name: Confirm Yamtrack can resolve its cache
ansible.builtin.command: >-
podman exec {{ container_name }} getent hosts {{ valkey_container }}
register: yamtrack_cache_lookup
changed_when: false
failed_when: false
- name: Report cache resolution
ansible.builtin.debug:
msg: >-
{{ valkey_container }} resolves as
{{ yamtrack_cache_lookup.stdout | default('(no answer)') }}
# `is-active` says the container is running; the image's own HEALTHCHECK is what
# says the app works. It wgets http://127.0.0.1:8000/health/, and that view
# checks all four things this deployment can get wrong — the cache, the database,
# Redis and Celery — so a green result here is the app being genuinely ready.
#
# Running the image's check rather than one written here means depending on
# nothing the play has to know: no curl or wget wrapper of our own, and no
# hardcoded path that a later image tag could move. `podman exec <c> wget -qO-
# http://127.0.0.1:{{ container_port }}/health/` prints the per-check breakdown
# when this fails; see readme.md, "Verification".
#
# The start period (30s) plus the entrypoint's migrations are why this allows
# three minutes rather than one.
- name: Wait for Yamtrack to pass its own health check
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
register: yamtrack_health
until: yamtrack_health.rc == 0
retries: 36
delay: 5
changed_when: false
failed_when: yamtrack_health.rc != 0
@@ -0,0 +1,56 @@
# Yamtrack — rootless Podman Quadlet unit.
# Managed by Ansible (yamtrack/roles/yamtrack). Do not edit by hand.
#
# This unit joins {{ network_name }}.network, which is created by
# caddy/roles/register (the same include that attaches Caddy to it). The shared
# valkey container is joined to that network by valkey/roles/attach, which is
# what makes the host in REDIS_URL resolve.
[Unit]
Description=Yamtrack
Documentation=https://github.com/FuzzyGrim/Yamtrack
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ container_name }}
Image={{ image }}
# keep-id maps *container root* onto {{ podman_user }} on the host, so
# {{ data_dir }} stays owned by {{ podman_user }} and never by a subuid, and a
# plain tar backup keeps working without `podman unshare` or `sudo`.
#
# uid 0 is not incidental. `--userns=keep-id` also decides which user the
# container runs as — the *mapped* one — and the image's entrypoint needs real
# root for its first steps: `groupmod` and `usermod` rewrite /etc/group and
# /etc/passwd, which are owned by container-root in the image. With
# `keep-id:uid=1000` the entrypoint runs as `abc`, cannot lock /etc/group, and
# exits 10 before supervisord is ever reached. The entrypoint's PUID/PGID are
# pinned to the same 0 in the environment file, so `abc` resolves to container
# root and everything the app writes lands on {{ podman_user }}.
#
# See readme.md, "Who runs as whom".
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
# SECRET, TZ, URLS and REDIS_URL live here rather than as Environment= lines
# above: this unit is world-readable in ~/.config, and SECRET signs sessions and
# password-reset tokens. Read by systemd, not by Ansible, so the file has to
# exist before this unit starts.
EnvironmentFile={{ env_file }}
Network={{ network_name }}.network
# The image's state directory: db.sqlite3 and its -wal/-shm sidecars. The rest
# of /yamtrack is image content (staticfiles/ is built in, the code is
# read-only), so this is the whole of what has to survive.
Volume={{ data_dir }}:/yamtrack/db
[Service]
Restart=always
# The entrypoint runs `manage.py migrate --noinput` before supervisord starts
# nginx, gunicorn, celery and celery beat, so allow for a slow first start on a
# large database.
TimeoutStartSec=300
[Install]
WantedBy=default.target
@@ -0,0 +1,37 @@
# Environment for {{ container_name }}.container.
# Managed by Ansible (yamtrack/roles/yamtrack). Do not edit by hand.
#
# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable,
# and SECRET signs sessions and password-reset tokens. Note that `podman inspect`
# still prints the resulting environment to anyone who can run podman as this
# user.
#
# The first four are the variables the Docker-era container was given, so this
# deployment behaves identically. ALLOWED_HOSTS is deliberately absent: its
# default is `*`, which is what the old container relied on. PUID/PGID are new —
# the Docker-era container ran as real root, and under rootless keep-id the
# entrypoint needs to be told which uid is the mapped one. See readme.md, "Who
# runs as whom".
# The Django SECRET_KEY. Carried over verbatim so existing sessions stay valid.
SECRET={{ yamtrack_secret }}
# TIME_ZONE for the app and for celery beat's schedules.
TZ={{ yamtrack_tz }}
# CSRF trusted origins. The old container set `URLS=https://{{ host }}`.
URLS={{ urls }}
# The shared Valkey unit, attached to this app's network by
# yamtrack/roles/valkey. Yamtrack requires Redis: it is the cache backend and
# the Celery broker, with no fallback.
REDIS_URL={{ redis_url }}
# The entrypoint runs `groupmod`/`usermod` to make the image's `abc` account
# match these, then chowns the data directory to it. Its own defaults are 1000,
# which under this unit's keep-id:uid=0 is a *subuid* on the host — the database
# would come out owned by e.g. 165536, invisible to a plain tar backup. Pinning
# both to 0 makes `abc` resolve to container root, which keep-id maps onto
# podman_user.
PUID={{ app_uid }}
PGID={{ app_gid }}