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.
211 lines
7.5 KiB
YAML
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
|