Files
playbooks/nextcloud/roles/nextcloud/tasks/main.yaml
T
tanshu 1038037e45 Mount PHP's session config, the one path the entrypoint cannot write
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.
2026-10-16 11:45:37 +05:30

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)']) }}"