The real first run of this play did not fail on a transition. The journal shows
the entrypoint dying on every attempt:
nextcloud[302063]: => Configuring PHP session handler...
nextcloud[302063]: /entrypoint.sh: 121: cannot create
/usr/local/etc/php/conf.d/redis-session.ini: Permission denied
With REDIS_HOST set, the entrypoint generates PHP's session-handler config on
every start, and the target directory belongs to the image's root: the php base
image does `mkdir -p "$PHP_INI_DIR/conf.d"` with no chown. The uid this container
runs as has no write bit on it, the entrypoint exits 2 before Apache is reached,
Restart=always retries that, and systemd's start limit turns it into a `failed`
unit. Nothing in that shape says "permissions": the symptom is a unit state and a
message that only exists in journalctl.
The container now mounts a host-owned file over that exact path. A bind-mounted
file replaces the directory entry, so the directory's ownership stops mattering
and the write lands on podman_user. The role templates the five lines the
entrypoint would have written, so a refused write is no longer fatal either --
PHP still gets Redis sessions instead of a crash loop.
This also corrects a reading of the previous run that this play got wrong and
documented wrongly: `activating` is not evidence of a healthy start. A
crash-looping container spends its time `activating` between restarts, so the
earlier "reports a healthy start as a failure" comment and commit message were
describing a start that was in fact failing. The wait is still right -- it stops
promptly at a terminal state and hands over to a rescue that has the logs -- but
its comment now says what it actually buys.
Apache's own runtime directories need none of this, and the php base image is why:
it chowns /var/run/apache2, /var/lock/apache2 and /var/log/apache2 to www-data and
chmods them 1777, so only the session file was ever going to bite. The readme
gains a section on it, the diagnostic commands the play now collects, and a note
that the uid mapping is not to be derived from a `docker exec id -u` of 0.
376 lines
16 KiB
YAML
376 lines
16 KiB
YAML
---
|
|
# Nextcloud 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.
|
|
|
|
- name: Ensure the Nextcloud webroot directory exists
|
|
ansible.builtin.file:
|
|
path: "{{ host_directory }}"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
# A migration target should already hold config/config.php, because the webroot is
|
|
# copied from monoco before this play runs (readme.md, "Migrating the data from
|
|
# monoco"). Two situations leave it missing, and both are worth saying out loud
|
|
# before the container starts rather than discovering afterwards:
|
|
#
|
|
# - the copy never landed, so the image initialises a fresh instance and the
|
|
# site comes up empty;
|
|
# - this play was run *before* the copy, so the image's own rsynced code is now
|
|
# in the webroot. Copying monoco's tree on top of that merges two versions of
|
|
# the code — the drift the rest of this role warns about — and the play
|
|
# reports success either way.
|
|
#
|
|
# It warns rather than fails because installing a new instance through the web UI
|
|
# is a legitimate use, and that instance has no config.php until the installer
|
|
# finishes.
|
|
- name: Check whether the webroot already holds an instance
|
|
ansible.builtin.stat:
|
|
path: "{{ host_directory }}/config/config.php"
|
|
register: nextcloud_config
|
|
|
|
- name: Warn if the webroot has no config.php
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ host_directory }}/config/config.php does not exist, so this webroot is
|
|
not a restored Nextcloud instance. Migrating? Stop the unit, delete
|
|
{{ host_directory }}, copy /var/lib/nextcloud from monoco first, and run
|
|
this play again — see readme.md, "If you ran the playbook before copying".
|
|
Installing a new instance through the web UI? Ignore this.
|
|
when: not nextcloud_config.stat.exists
|
|
|
|
# The entrypoint takes an flock on this path using a root-owned shell redirect
|
|
# (`) 9> /var/www/html/nextcloud-init-sync.lock`, in the nextcloud/docker
|
|
# entrypoint). Creating it here means it belongs to {{ podman_user }} and stays
|
|
# that way: the redirect *truncates* the file, it does not recreate it, so its
|
|
# owner never changes.
|
|
#
|
|
# Left to the container, the file would be created by container-root — a subuid
|
|
# under keep-id — and would be the one thing in the webroot not owned by
|
|
# {{ podman_user }}.
|
|
#
|
|
# `force: false` leaves an existing file completely alone, which is what makes
|
|
# this idempotent and what stops it failing on a file a previous run stranded as
|
|
# a subuid (a host user cannot chown a file it does not own).
|
|
- name: Pre-create the entrypoint's init lock file
|
|
ansible.builtin.copy:
|
|
dest: "{{ host_directory }}/nextcloud-init-sync.lock"
|
|
content: ""
|
|
force: false
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
|
|
# The app's database 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: POSTGRES_PASSWORD is in it.
|
|
- name: Write the environment file
|
|
ansible.builtin.template:
|
|
src: nextcloud.env.j2
|
|
dest: "{{ env_file }}"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0600'
|
|
notify: Restart Nextcloud
|
|
|
|
# The second path the entrypoint writes, and the one that made this play fail for
|
|
# real: with REDIS_HOST set, the entrypoint generates PHP's session-handler config
|
|
# at {{ php_session_ini_mount }} on every start, and that path's directory in the
|
|
# php base image is
|
|
#
|
|
# RUN mkdir -p "$PHP_INI_DIR/conf.d"
|
|
#
|
|
# — created by root, never chowned. The uid this container runs as therefore has
|
|
# no write bit on the directory, the entrypoint dies with
|
|
#
|
|
# /entrypoint.sh: 121: cannot create
|
|
# /usr/local/etc/php/conf.d/redis-session.ini: Permission denied
|
|
#
|
|
# and `Restart=always` turns that into a crash loop that systemd eventually marks
|
|
# `failed`. The Quadlet unit mounts this file over that exact path, which is what
|
|
# gives the write somewhere it is allowed: the mount replaces the directory entry,
|
|
# so the directory's ownership stops mattering.
|
|
#
|
|
# The file is templated rather than left empty so that a start in which the
|
|
# entrypoint's write is refused still has working Redis sessions.
|
|
- name: Ensure the PHP session config directory exists
|
|
ansible.builtin.file:
|
|
path: "{{ php_conf_dir }}"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: Write the PHP session handler config
|
|
ansible.builtin.template:
|
|
src: redis-session.ini.j2
|
|
dest: "{{ php_session_ini }}"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify: Restart Nextcloud
|
|
|
|
- name: Install the Nextcloud Quadlet unit
|
|
ansible.builtin.template:
|
|
src: nextcloud.container.j2
|
|
dest: "{{ quadlet_dir }}/{{ container_name }}.container"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
notify:
|
|
- Reload systemd user units
|
|
- Restart Nextcloud
|
|
|
|
- name: Start Nextcloud and wait for the unit to become active
|
|
block:
|
|
- name: Start Nextcloud
|
|
ansible.builtin.systemd_service:
|
|
name: "{{ unit_name }}"
|
|
scope: user
|
|
state: started
|
|
daemon_reload: true
|
|
|
|
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
|
# `systemctl --user enable` is neither needed nor possible on a generated
|
|
# unit.
|
|
#
|
|
# This waits rather than checking once, because `systemctl start` returns
|
|
# while the unit is still `activating` and `is-active` exits 3 for that state.
|
|
#
|
|
# `activating` is NOT evidence that a start is healthy, which this play got
|
|
# wrong once and should not again: a container that crash-loops spends its
|
|
# time `activating` between `Restart=always` attempts, and the first run here
|
|
# was read as a healthy start mid-transition when the entrypoint was in fact
|
|
# dying on every attempt (it could not write
|
|
# /usr/local/etc/php/conf.d/redis-session.ini — see the task that mounts that
|
|
# file). What this wait actually buys is a prompt, bounded stop: it retries
|
|
# only while the state can still change on its own, and a terminal `failed`
|
|
# or `inactive` falls straight through to the rescue below, which has the logs.
|
|
#
|
|
# Keeping it inside the block matters: an is-active check outside it fails with
|
|
# a bare state and no diagnostics, which is the dead end the second run hit
|
|
# before this was moved in.
|
|
- name: Wait for the Nextcloud unit to become active
|
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
|
register: nextcloud_active
|
|
until: >-
|
|
nextcloud_active.stdout == 'active'
|
|
or nextcloud_active.stdout not in ['activating', 'reloading']
|
|
retries: 60
|
|
delay: 5
|
|
changed_when: false
|
|
failed_when: nextcloud_active.stdout != 'active'
|
|
rescue:
|
|
# systemd reports "control process exited with error code" and nothing else,
|
|
# which is useless for the usual failures (an unattached network, an
|
|
# unreachable database, an unwritable webroot, a container name left behind
|
|
# by an interrupted run). Pull the real reason out.
|
|
#
|
|
# `cat` is in the list because the generated unit settles what systemd thinks
|
|
# it is waiting for, and `podman ps -a` is in it because the failure most
|
|
# likely to bite after an aborted run is a container that already exists
|
|
# under this name: `podman run` then refuses to start, `Restart=always`
|
|
# retries it into systemd's start limit, and the unit ends up `failed` with
|
|
# nothing in `podman logs` to explain it.
|
|
- name: Collect Nextcloud diagnostics
|
|
ansible.builtin.command: "{{ item }}"
|
|
loop:
|
|
- "systemctl --user status {{ unit_name }} --no-pager -l"
|
|
- "systemctl --user cat {{ unit_name }}"
|
|
- "journalctl --user -u {{ unit_name }} --no-pager -n 60"
|
|
- "podman ps -a --filter name={{ container_name }}"
|
|
- "podman logs --tail 60 {{ container_name }}"
|
|
register: nextcloud_diag
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Show Nextcloud diagnostics
|
|
ansible.builtin.debug:
|
|
msg: "{{ nextcloud_diag.results | map(attribute='stdout') | list }}"
|
|
|
|
- name: Fail
|
|
ansible.builtin.fail:
|
|
msg: >-
|
|
Nextcloud did not come up — see the diagnostics above. If `podman ps
|
|
-a` shows a container that already exists under this name, it was left
|
|
behind by an interrupted run; clear it and start again with:
|
|
systemctl --user reset-failed {{ unit_name }} ;
|
|
systemctl --user stop {{ unit_name }} ; podman rm -f {{ container_name }}
|
|
A unit that is `activating` for the whole retry budget is a different
|
|
problem: look at the generated unit in the diagnostics, and at
|
|
journalctl for the container's own output.
|
|
|
|
# `is-active` says the container is running; it does not say the app can serve.
|
|
# The entrypoint rsyncs the image's code into the webroot and, when the copied
|
|
# version.php is older than the image, runs `occ upgrade` against
|
|
# {{ pg_database }} before Apache answers — and `Restart=always` means systemd
|
|
# reports an active unit even while that crash-loops.
|
|
#
|
|
# occ is the app's own answer and is always present. It has to run as www-data
|
|
# (uid {{ app_uid }}): occ refuses to run as a user other than the one that owns
|
|
# config.php, which under keep-id is what the mount is owned by.
|
|
#
|
|
# The retry budget of the first wait matches the unit's TimeoutStartSec, because
|
|
# a first run after an image bump really can spend minutes in the database
|
|
# upgrade.
|
|
#
|
|
# The status JSON is matched after stripping spaces rather than with a regex, so
|
|
# both the compact form and a pretty-printed one match, and there is no `\s` to
|
|
# escape (an invalid escape in a Jinja string literal is only a warning today and
|
|
# may not stay one).
|
|
- name: Wait for Nextcloud to become ready
|
|
block:
|
|
- name: Wait for Nextcloud to report itself installed
|
|
ansible.builtin.command: >-
|
|
podman exec -u www-data {{ container_name }}
|
|
php occ status --output=json
|
|
register: nextcloud_status
|
|
until: >-
|
|
nextcloud_status.rc == 0
|
|
and ((nextcloud_status.stdout | replace(' ', '')) is search('"installed":true'))
|
|
retries: 120
|
|
delay: 5
|
|
changed_when: false
|
|
|
|
# Maintenance mode is ON for the whole of `occ upgrade`, which the entrypoint
|
|
# runs on the first start after an image bump — so waiting is not optional
|
|
# here: asserting immediately would fail a perfectly healthy deploy halfway
|
|
# through the upgrade.
|
|
#
|
|
# It can also be left ON in the config.php copied from monoco, because
|
|
# docs/backup-plan.md §3.4 tells an operator to set it before a consistent
|
|
# backup. That case never resolves, which is why the rescue below names it
|
|
# instead of leaving a bare timeout: `status.php` is exempt from maintenance
|
|
# mode, so the app looks up while every real request gets a 503.
|
|
- name: Wait for maintenance mode to be off
|
|
ansible.builtin.command: >-
|
|
podman exec -u www-data {{ container_name }}
|
|
php occ status --output=json
|
|
register: nextcloud_ready
|
|
until: >-
|
|
nextcloud_ready.rc == 0
|
|
and ((nextcloud_ready.stdout | replace(' ', '')) is search('"installed":true'))
|
|
and not ((nextcloud_ready.stdout | replace(' ', '')) is search('"maintenance":true'))
|
|
retries: 120
|
|
delay: 5
|
|
changed_when: false
|
|
rescue:
|
|
- name: Collect Nextcloud readiness diagnostics
|
|
ansible.builtin.command: "{{ item }}"
|
|
loop:
|
|
- "podman exec -u www-data {{ container_name }} php occ status --output=json"
|
|
- "journalctl --user -u {{ unit_name }} --no-pager -n 40"
|
|
- "podman logs --tail 40 {{ container_name }}"
|
|
register: nextcloud_ready_diag
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Fail with the reason
|
|
ansible.builtin.fail:
|
|
msg: >-
|
|
Nextcloud never became ready. If the status above shows
|
|
"maintenance": true, the instance is in maintenance mode — either left
|
|
on in the copied config.php or still mid-upgrade — and because
|
|
status.php is exempt from maintenance mode the site looks healthy while
|
|
every real request gets a 503. Clear it with:
|
|
podman exec -u www-data {{ container_name }} php occ maintenance:mode --off
|
|
Otherwise, see the diagnostics above.
|
|
|
|
- name: Report Nextcloud status
|
|
ansible.builtin.debug:
|
|
msg: "{{ nextcloud_ready.stdout }}"
|
|
|
|
# The check above runs the PHP CLI, which does not prove Apache is serving. This
|
|
# goes through the image's own front end, over loopback, and asserts the JSON
|
|
# status.php returns says the instance is installed.
|
|
- name: Wait for Apache to serve status.php
|
|
ansible.builtin.command: >-
|
|
podman exec {{ container_name }} php -r
|
|
'$b=@file_get_contents("http://127.0.0.1/status.php"); if ($b === false) exit(1);
|
|
$j=json_decode($b, true); exit(!empty($j["installed"]) ? 0 : 1);'
|
|
register: nextcloud_http
|
|
until: nextcloud_http.rc == 0
|
|
retries: 24
|
|
delay: 5
|
|
changed_when: false
|
|
|
|
# The database and the cache arrive over the networks that roles/postgres and
|
|
# roles/valkey attached them to. Check the names config.php actually asks for,
|
|
# plus the container names, because a wrong alias is the failure that looks like
|
|
# a broken app rather than a broken network.
|
|
- name: Confirm Nextcloud can resolve the database and the cache
|
|
ansible.builtin.command: >-
|
|
podman exec {{ container_name }} getent hosts {{ item }}
|
|
loop:
|
|
- "{{ db_host }}"
|
|
- "{{ postgres_container }}"
|
|
- "{{ redis_host }}"
|
|
- "{{ valkey_container }}"
|
|
register: nextcloud_lookup
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Report database and cache resolution
|
|
ansible.builtin.debug:
|
|
msg: >-
|
|
{{ item.item }} resolves as {{ item.stdout | default('(no answer)') }}
|
|
loop: "{{ nextcloud_lookup.results }}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Background jobs
|
|
# ---------------------------------------------------------------------------
|
|
# The Docker-era playbook added a *root* crontab entry running `php cron.php`
|
|
# every five minutes. Root cannot `podman exec` into another user's rootless
|
|
# containers (docs/podman-migration.md §8(b)), so it becomes a systemd user timer
|
|
# next to the Quadlet units — which is also what makes it survive a reboot and
|
|
# report its failures in the journal.
|
|
|
|
- name: Ensure the systemd user unit directory exists
|
|
ansible.builtin.file:
|
|
path: "{{ user_unit_dir }}"
|
|
state: directory
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0755'
|
|
|
|
- name: Install the Nextcloud background-job service
|
|
ansible.builtin.template:
|
|
src: nextcloud-cron.service.j2
|
|
dest: "{{ user_unit_dir }}/{{ cron_unit }}.service"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
|
|
- name: Install the Nextcloud background-job timer
|
|
ansible.builtin.template:
|
|
src: nextcloud-cron.timer.j2
|
|
dest: "{{ user_unit_dir }}/{{ cron_unit }}.timer"
|
|
owner: "{{ podman_user }}"
|
|
group: "{{ podman_user }}"
|
|
mode: '0644'
|
|
|
|
# Unlike the Quadlet units, a plain .timer is not generated, so it does need
|
|
# enabling — and `enabled: true` is what brings it back after a reboot, the same
|
|
# way `[Install] WantedBy=default.target` does for the container units.
|
|
- name: Enable and start the Nextcloud background-job timer
|
|
ansible.builtin.systemd_service:
|
|
name: "{{ cron_unit }}.timer"
|
|
scope: user
|
|
enabled: true
|
|
state: started
|
|
daemon_reload: true
|
|
|
|
- name: Report the background-job timer
|
|
ansible.builtin.command: "systemctl --user list-timers {{ cron_unit }}.timer --no-pager"
|
|
register: nextcloud_timer
|
|
changed_when: false
|
|
failed_when: false
|
|
|
|
- name: Show the background-job timer
|
|
ansible.builtin.debug:
|
|
msg: "{{ nextcloud_timer.stdout_lines | default(['(no output)']) }}"
|