--- # 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 gave us a real PAM session, so systemd's # user manager is reachable through the standard environment. # The pull is explicit, rather than left to the Quadlet unit, for two reasons. # The image lives in a private registry, so a failure here is where a missing # `podman login` should show up — not two tasks later as "unit not found". And # the unit keeps podman's default `Pull=missing`, so it never reaches for the # registry on a restart or after a reboot: that is what lets a box come back up # when the registry is down. Pulling here is what keeps a redeployed tag current. - name: Pull the app image ansible.builtin.command: 'podman pull {{ image_name }}:{{ image_tag }}' register: menu_pull changed_when: false failed_when: false - name: Fail when the image cannot be pulled ansible.builtin.fail: msg: >- podman could not pull {{ image_name }}:{{ image_tag }}. {{ menu_pull.stderr | default('') | trim }} The registry is private: run `podman login registry.tanshu.com` once as {{ podman_user }} on this host. when: menu_pull.rc != 0 # 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: '{{ app_root }}' 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: '{{ app_root }}/data' state: directory owner: '{{ podman_user }}' group: '{{ podman_user }}' mode: '0750' - name: Look for the untracked site.env ansible.builtin.stat: path: '{{ app_root }}/site.env' register: menu_site_env - name: Warn when the admin has no password yet ansible.builtin.debug: msg: >- {{ app_root }}/site.env is missing, so the admin is disabled: the public menu still serves. Run scripts/provision-site-env.sh {{ app_root }} on this host, then re-run this playbook. when: not menu_site_env.stat.exists - name: Install the menu app Quadlet unit ansible.builtin.template: src: menu-app.container.j2 dest: '{{ quadlet_dir }}/{{ network_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 network unit, an # unwritable /data, a bad uid mapping). 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 40' - 'podman logs --tail 40 {{ 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.' # 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 is running 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 exactly 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 app to pass its health check ansible.builtin.command: 'podman healthcheck run {{ container_name }}' register: menu_health until: menu_health.rc == 0 retries: 24 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 40' - 'podman logs --tail 40 {{ 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 two minutes. Its logs are above, and: podman logs {{ container_name }} - name: Keep site.env readable only by the app user ansible.builtin.file: path: '{{ app_root }}/site.env' owner: '{{ podman_user }}' group: '{{ podman_user }}' mode: '0600' when: menu_site_env.stat.exists - 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