Files
menu/ansible/roles/menu-app/tasks/main.yaml
T
tanshu 537a4f1e46 Match the deploy system to hops and greatbear
menu-admin was the odd one out: it built from the working directory with a date
tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era
container name. The other two apps build from `git archive HEAD` and tag with the
commit, so what runs on the host is exactly what git knows about; build only for
the architecture emily actually runs; and use one slug for the container, its
network and its data directory.

The build is now their three lines: `podman build --platform linux/arm64` from
`git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push`
rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty
tracked tree or a HEAD that is not origin/main — which is what makes the commit
tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same
`-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays,
but behind `--checks`, so a plain deploy behaves as theirs does.

The registry credential moves into vars/default.yml and the role logs in, pulls
and logs out around it, as their roles do. The image is only pulled at deploy
time, so the unit keeps podman's default `Pull=missing` and a reboot never needs
the registry. This is the same plaintext credential the other two repos carry.

One real bug goes with it: the healthcheck gate ran before the handler that
restarts a changed unit, so a run that only changed the unit checked the stale
container. `meta: flush_handlers` before the gate is the fix the sibling repos
already made.

The container, network and data directory are all menu-<site> now. The dotted
Docker-era name and the NetworkAlias it needed are gone, so `podman logs
menu-<site>` and `systemctl --user status menu-<site>` are the same two commands
across all three apps. The Dockerfile keeps its name for the same reason: podman
reads it as readily as a Containerfile, and the siblings use that name too.
2026-10-16 11:10:59 +05:30

211 lines
7.5 KiB
YAML

---
# The menu app as a rootless Podman Quadlet unit.
#
# Everything here runs as {{ podman_user }} — the connection user. No become, no
# XDG_RUNTIME_DIR: the SSH connection gives a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# The registry is private, so the pull below has to be authenticated. Podman
# keeps the credential in the user's own auth file; it is removed again at the
# end of this role, so nothing but the image is left behind. Skipped when no
# credentials are configured.
- name: Log in to the private registry
ansible.builtin.command:
cmd: 'podman login --username {{ registry_username }} --password-stdin {{ registry }}'
stdin: '{{ registry_password }}'
no_log: true
changed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0
# Pulled here rather than left to the unit: Quadlet only pulls when the image is
# missing, and at that point the service has no registry credential to offer.
- name: Pull the menu app image
ansible.builtin.command: 'podman pull {{ image }}'
changed_when: false
# The data directory is created here rather than left to the image. Under
# keep-id the image's own root phase maps to a subuid, so anything it tries to
# create under the mount fails with EPERM; the directory has to exist already,
# owned by {{ podman_user }}.
- name: Ensure the app root exists
ansible.builtin.file:
path: '{{ host_directory }}'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0750'
# MENU_ROOT is the app root, and the app keeps its content one level down in
# data/ (originals/, drafts/, published/, site.json, sections.json).
- name: Ensure the data directory exists
ansible.builtin.file:
path: '{{ host_directory }}/data'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0750'
- name: Look for the untracked site.env
ansible.builtin.stat:
path: '{{ host_directory }}/site.env'
register: menu_site_env
- name: Warn when the admin has no password yet
ansible.builtin.debug:
msg: >-
{{ host_directory }}/site.env is missing, so the admin is disabled: the
public menu still serves. Run scripts/provision-site-env.sh
{{ host_directory }} on this host, then re-run this playbook.
when: not menu_site_env.stat.exists
- name: Keep site.env readable only by the app user
ansible.builtin.file:
path: '{{ host_directory }}/site.env'
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0600'
when: menu_site_env.stat.exists
- name: Install the menu app Quadlet unit
ansible.builtin.template:
src: menu-app.container.j2
dest: '{{ quadlet_dir }}/{{ container_name }}.container'
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0644'
notify:
- Reload systemd user units
- Restart the menu app
- name: Start the menu app
block:
- name: Start the menu app
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 (a missing image, an unattached
# network, an unwritable data directory). Pull the real reason out.
- name: Collect diagnostics
ansible.builtin.command: '{{ item }}'
loop:
- 'systemctl --user status {{ unit_name }} --no-pager'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 60 {{ container_name }}'
register: menu_diagnostics
changed_when: false
failed_when: false
- name: Show diagnostics
ansible.builtin.debug:
msg: '{{ menu_diagnostics.results | map(attribute="stdout") | list }}'
- name: Fail
ansible.builtin.fail:
msg: 'The menu app did not start — see the diagnostics above.'
# The unit template notifies "Restart the menu app", but handlers otherwise run
# at the very end of the play — after the healthcheck gate below. Flush them
# here so the gate tests the container that has the current HealthCmd; without
# this, a run that only changes the unit would check the stale container and
# fail. On a run where nothing changed, no handler is pending and this is a
# no-op.
- name: Apply pending unit changes before verifying
ansible.builtin.meta: flush_handlers
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
- name: Verify the menu app unit is active
ansible.builtin.command: 'systemctl --user is-active {{ unit_name }}'
register: menu_active
changed_when: false
failed_when: menu_active.stdout != 'active'
# `is-active` says the container is running; it does not say the app can serve.
# Restart=always means even a crash-looping container is reported active — which
# is what a fatal integrity problem in the data directory produces. The unit
# declares the health check, so run that once, synchronously.
- name: Wait for the container healthcheck to pass
ansible.builtin.command: 'podman healthcheck run {{ container_name }}'
register: menu_health
until: menu_health.rc == 0
retries: 36
delay: 5
changed_when: false
failed_when: false
- name: Report a container that never answered
when: menu_health.rc != 0
block:
- name: Collect diagnostics
ansible.builtin.command: '{{ item }}'
loop:
- 'systemctl --user status {{ unit_name }} --no-pager'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 60 {{ container_name }}'
register: menu_health_diagnostics
changed_when: false
failed_when: false
- name: Show diagnostics
ansible.builtin.debug:
msg: '{{ menu_health_diagnostics.results | map(attribute="stdout") | list }}'
- name: Fail
ansible.builtin.fail:
msg: >-
{{ container_name }} did not pass its health check within three
minutes. Its logs are above, and: podman logs {{ container_name }}
- name: Check the data directory for integrity problems
# Reads the same directory the container writes, as the same user, so a
# permissions mistake shows up here rather than at 8pm service.
ansible.builtin.command:
argv:
- podman
- exec
- --env
- MENU_ROOT=/data
- --env
- 'MENU_SITE_CONFIG=/app/config/{{ site }}.site.json'
- '{{ container_name }}'
- node
- src/cli/verify.ts
register: menu_verify
changed_when: false
failed_when: false
- name: Report integrity problems
ansible.builtin.debug:
msg: '{{ menu_verify.stdout_lines | default([]) }}'
when: menu_verify.rc != 0
- name: Show the networks the container is attached to
ansible.builtin.command: >-
podman inspect {{ container_name }}
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
register: menu_networks_now
changed_when: false
failed_when: false
- name: Report the network
ansible.builtin.debug:
msg: >-
{{ container_name }} is on
{{ menu_networks_now.stdout | default('(container not running)') }}
# Removes the credential from the user's auth file. The image is already local
# and the unit only pulls when it is missing, so this does not break restarts.
- name: Log out of the private registry
ansible.builtin.command: 'podman logout {{ registry }}'
changed_when: false
failed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0