--- # 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