diff --git a/nextcloud/playbook.yml b/nextcloud/playbook.yml index 9b28852..e105f50 100755 --- a/nextcloud/playbook.yml +++ b/nextcloud/playbook.yml @@ -1,13 +1,58 @@ -################################################# -# DO Community Playbooks: Nextcloud -################################################# --- -- hosts: monoco - become: true +# Nextcloud as a rootless Podman Quadlet unit, behind the shared Caddy unit +# (caddy/playbook.yml), with its database in the shared Postgres unit +# (postgres/playbook.yml) and its cache in the shared Valkey unit +# (valkey/playbook.yml). +# +# Connects as the container user and runs everything as that user: the Quadlet +# units belong to it, so the connection's PAM session is what makes +# `systemctl --user` work without any environment juggling. +# +# gather_facts is required: podman_user and the home directory come from the +# user_id / user_dir facts. +# +# The rootless-Podman prerequisites (installing podman, enabling lingering, the +# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied +# here. They are the shared task file at caddy/roles/podman/tasks/main.yaml, and +# a host that has already run caddy/playbook.yml has them: +# +# - name: Rootless Podman prerequisites +# ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +# +# The variables this play needs are duplicated at the top of vars/default.yml, so +# it stands on its own. +# +# Run with: ansible-playbook -u tanshu nextcloud/playbook.yml +- name: Deploy Nextcloud as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - vars/default.yml + pre_tasks: + # This must happen before the app unit is started, not after: the generated + # unit references {{ network_name }}.network, and Quadlet fails *unit + # generation* (so `systemctl --user start` reports "unit not found") when a + # referenced .network file does not exist yet. The same include writes the + # Caddy-side drop-in that attaches Caddy to this network, and both attaches + # below need the network unit to exist as well. + # + # network_name is a play var, so the include can see it directly. + - name: Register Nextcloud's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + roles: - - network - - nextcloud - - caddy + # Attaches this app's network to the shared Postgres unit, which is what + # makes config.php's `db` host resolve, and owns the "Restart Postgres" + # handler that postgres/roles/attach notifies. Runs first so the database is + # reachable before the app starts. + - postgres + # The same at the cache end: attaches this app's network to the shared Valkey + # unit, which is what makes config.php's `redis` host resolve, and owns the + # "Restart Valkey" handler that valkey/roles/attach notifies. + - valkey + # The app: the webroot, the 0600 environment file, the Quadlet unit, the + # background-job timer, start + verify. + - nextcloud + # Appends this site's block to the shared Caddyfile and reloads Caddy. + - caddy diff --git a/nextcloud/readme.md b/nextcloud/readme.md index d3a2039..6896651 100644 --- a/nextcloud/readme.md +++ b/nextcloud/readme.md @@ -1,2 +1,603 @@ -The web/nginx.conf is from -https://github.com/nextcloud/docker/blob/master/.examples/docker-compose/with-nginx-proxy/postgres/fpm/web/nginx.conf \ No newline at end of file +# Nextcloud (rootless Podman + Quadlet) + +Runs Nextcloud on `emily` as a **rootless Podman** container managed by a +**systemd user unit** (Quadlet), behind the shared [rootless Caddy](../caddy/readme.md), +with its database in the shared [rootless Postgres](../postgres/readme.md) and its +cache in the shared [rootless Valkey](../valkey/readme.md). Replaces the +`docker_container` deployment on `monoco`. + +The container name, network, unit, Caddyfile upstream and database are all +`nextcloud`, as they have been since the Docker-era playbook. The **host is +unchanged**: `cloud.hopsngrains.com`. + +Nextcloud is the one service here whose webroot is *also* its code directory. +The Docker-era playbook bind-mounted `/var/lib/nextcloud` at `/var/www/html`, and +this one keeps that shape: the image's `VOLUME`, `config/config.php`, `data/`, +`custom_apps/` and every PHP file live under one mount. That single fact drives +both the [migration](#migrating-the-data-from-monoco) and the ownership story +below — see [What is state, and what is not](#what-is-state-and-what-is-not). + +## Ordering + +The three shared services are already migrated and running on `emily`, and this +playbook is the last step: + +1. [caddy/playbook.yml](../caddy/playbook.yml) — the reverse proxy, which owns + the Caddyfile this site appends to, supplies the `caddy.service` handlers, and + is the one play that applies the rootless-Podman prerequisites. +2. [postgres/playbook.yml](../postgres/playbook.yml) — deployed, and its cluster + brought across from monoco. **The `nextcloud` database is already restored** + (320 MB, the largest of the 12 databases in + [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md)). +3. [valkey/playbook.yml](../valkey/playbook.yml) — **new**, and this is the first + service that needs it. It has to exist before this playbook runs: the attach + include only writes drop-ins next to `valkey.container`, so a `Restart Valkey` + handler against an undeployed unit fails. +4. This playbook — **run it after the file copy below**, not before. The app is + what writes to `~/podman-data/nextcloud`, and its first start is when the + entrypoint runs its version check against whatever is in the webroot. + +`config.php` connects to the hosts `db` and `redis`. Until this playbook has +attached `nextcloud_net` to Postgres and to Valkey, neither name resolves on this +network, so running this playbook is what makes the app reachable — there is no +separate step. + +## Layout + +``` +nextcloud/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── postgres/ # attaches this app's network to Postgres (+ its handler) + ├── valkey/ # attaches this app's network to Valkey (+ its handler) + ├── nextcloud/ # webroot, the 0600 env file, the Quadlet unit, the cron + │ # timer, start + verify + └── caddy/ # appends this site's block to the shared Caddyfile +``` + +Quadlet units and state: + +| On the host | What | +|---|---| +| `~/.config/containers/systemd/nextcloud.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/nextcloud.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-nextcloud.conf` | attaches Caddy to that network | +| `~/.config/containers/systemd/postgres.container.d/50-nextcloud.conf` | attaches Postgres to it | +| `~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf` | the shared `db` alias | +| `~/.config/containers/systemd/valkey.container.d/50-nextcloud.conf` | attaches Valkey to it | +| `~/.config/containers/systemd/valkey.container.d/10-network-aliases.conf` | the shared `redis` alias | +| `~/.config/systemd/user/nextcloud-cron.{service,timer}` | the background-job timer (not Quadlet) | +| `~/podman-data/nextcloud/` | the whole `/var/www/html`: code, `config/config.php`, `data/`, `custom_apps/` | +| `~/podman-data/nextcloud/nextcloud.env` | `POSTGRES_*` + `REDIS_HOST`, mode `0600`, read by systemd | + +## What is state, and what is not + +Per [docs/backup-plan.md §2.1](../docs/backup-plan.md), the instance is 1.2 GB +and only a fraction of it is irreplaceable: + +| Path | Keep? | Why | +|---|---|---| +| `data/` | **required** | user files, `appdata_*`, versions, thumbnails, the log | +| `config/` | **required** | `config.php` — without it the instance is not restorable, and neither is the encryption material | +| `custom_apps/` | **required** | apps installed from the app store | +| `themes/` | if themed | only exists when a theme was installed | +| `version.php`, `core/`, `lib/`, `apps/`, `3rdparty/`, `dist/` | regenerable | shipped by the image, rsynced into the webroot on start | + +**The migration copies all of it anyway**, and that is deliberate. Because the +mount holds the code, the entrypoint decides what to do by reading +`/var/www/html/version.php` **from the mount**: copy the whole tree and the first +start is close to a no-op; copy only the state and `version.php` is missing, so +every start looks like a brand-new instance — no `occ upgrade`, no version +tracking — and Nextcloud would then have to be talked through a manual upgrade. + +## How it registers with Caddy + +Caddy is the base unit and knows nothing about its upstreams. This playbook calls +the shared include in +[caddy/roles/register](../caddy/roles/register/tasks/main.yaml), which writes the +network unit **and** the drop-in that pulls it into Caddy: + +```yaml +- name: Register Nextcloud's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" +``` + +It runs in `pre_tasks`, **before** the app unit is started, because +`nextcloud.container` references `nextcloud.network`: Quadlet fails *unit +generation* — so `systemctl --user start` reports "unit not found" — when a +referenced `.network` file does not exist yet. Both attaches below depend on it +for the same reason. + +`network_name` is `nextcloud`, so the *file* is `nextcloud.network` and the +*Podman network* is `nextcloud_net` (`NetworkName=` in the network unit pins it, +keeping the Docker-era name). + +The Docker-era `roles/network` is **deleted rather than ported**. It existed to +attach an already-created container to a network — `docker_network`'s +`connected:`/`appends:`, with the `redis` alias for the old `valkey` container — +which `podman_network` has no equivalent for +([docs/podman-compat-audit.md §3.3](../docs/podman-compat-audit.md)). Nothing has +to be connected after the fact now: the app's own unit names its `Network=`, and +Caddy, Postgres and Valkey learn about the network from drop-ins written in the +same run. + +The site block is appended to the shared Caddyfile by +[roles/caddy](roles/caddy/tasks/main.yaml) with `blockinfile`, exactly as the +other services do. **Only `caddyfile_path` changed** — from +`/var/lib/caddy/conf/Caddyfile` to `~/podman-data/caddy/conf/Caddyfile` — and the +block itself is the Docker-era one, HSTS header included: + +``` +cloud.hopsngrains.com { + reverse_proxy nextcloud:80 + + header { + Strict-Transport-Security "max-age=15552000; includeSubDomains; preload" + } +} +``` + +The `marker` in the role is unchanged from the Docker era, so an existing block +on a reused Caddyfile is replaced in place rather than duplicated. There is no +request-body limit to carry over: Caddy streams request bodies and imposes none +by default, so the `client_max_body_size 525M` the dead nginx front end carried +has no equivalent and needs none. + +## How it attaches to Postgres + +Postgres knows nothing about its clients, and this playbook uses the shared +include in [postgres/roles/attach](../postgres/roles/attach/tasks/main.yaml) +rather than writing the drop-ins itself. That writes two files, both mergeable +into `postgres.container` by Quadlet: + +| File | Content | +|---|---| +| `postgres.container.d/50-nextcloud.conf` | `Network=nextcloud.network` | +| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` | + +`config.php`'s `dbhost` is `db`, which is what the Docker-era container's +`POSTGRES_HOST=db` wrote; the `postgres` container name resolves too, and the play +checks both. + +[roles/postgres](roles/postgres/tasks/main.yaml) wraps that include for two +reasons: + +- it defines the `Restart Postgres` handler the include notifies; and +- it **flushes handlers** immediately afterwards. On a first attach the drop-in + is new, so Postgres has to be *recreated* to join `nextcloud_net` — and that + restart is a handler, which by default runs at the end of the play, after the + `nextcloud` role has already started the app. The app would then come up before + `db` resolves, fail its own readiness check, and fail the play on a machine + where nothing is wrong. The flush also fires the `Restart Caddy` queued by the + register pre_task, which is what attaches Caddy to the network. On later runs + nothing is pending and it is a no-op. + +Note the cost stated in [postgres/readme.md](../postgres/readme.md): a new +drop-in recreates Postgres, which is a few seconds of downtime for **every** +attached app, not just this one. + +## How it attaches to Valkey + +The same idea at the cache end, via +[valkey/roles/attach](../valkey/roles/attach/tasks/main.yaml): + +| File | Content | +|---|---| +| `valkey.container.d/50-nextcloud.conf` | `Network=nextcloud.network` | +| `valkey.container.d/10-network-aliases.conf` | `NetworkAlias=redis` | + +`redis` is not a name this playbook chose. The image writes `config.php`'s Redis +host from `REDIS_HOST`, the Docker-era `env:` block set it to `redis`, and the +same variable rewrites PHP's session-handler config on **every** start. Renaming +it would mean editing `config.php` on a live instance for nothing. + +It is also **not optional**. `config.php` carries `memcache.locking` pointed at +Redis, so an unresolvable `redis` does not merely make things slower — uploads +fail, and they fail at write time rather than at startup. That is why +`roles/valkey` flushes handlers before the app starts (same reason as Postgres), +and why `REDIS_HOST` is in the environment file at all. + +The cost of the attach is a recreate of Valkey, and it is **not** the same story +as Postgres's. No keys are lost — RDB persistence is on by default, the stop is +graceful, and the new container loads the snapshot back off the bind mount — but +for the seconds it is down, Nextcloud's PHP sessions and its `memcache.locking` +are unreachable, so logins and uploads stall rather than fail cleanly. See +[valkey/readme.md](../valkey/readme.md), "The trade-off". + +## Background jobs + +Nextcloud expects `cron.php` to run every five minutes, and the Docker-era +playbook scheduled it in **root's crontab**: + +``` +*/5 * * * * docker exec -u www-data nextcloud php cron.php +``` + +That cannot survive the move: root cannot `podman exec` into another user's +rootless containers, and there is no container user to own a crontab +([docs/podman-migration.md §8(b)](../docs/podman-migration.md)). The role +installs a **systemd user** pair next to the Quadlet units instead: + +| Unit | What it does | +|---|---| +| `~/.config/systemd/user/nextcloud-cron.service` | `Type=oneshot`, `podman exec -u www-data nextcloud php /var/www/html/cron.php` | +| `~/.config/systemd/user/nextcloud-cron.timer` | `OnCalendar=*:0/5`, `Persistent=true` | + +Unlike the Quadlet units these are plain units, so they really are `enable`d — +that is what brings the timer back after a reboot. No `sudo`, no crontab, and +`journalctl --user -u nextcloud-cron` shows every run and every failure, which the +old crontab never did. + +The background-job *mode* is left alone: `config.php` came from monoco with +whatever it had, and this playbook does not switch it to `cron`. Both the timer +and AJAX polling run jobs; job locking is what keeps them from colliding. If you +want AJAX disabled, that is `occ background:cron`, a deliberate change rather +than a migration step. + +## Settings + +| Variable | Value / purpose | +|---|---| +| `podman_user` | **Derived** from the connection (`ansible_facts['user_id']`), not set by hand | +| `podman_data_dir` | `~/podman-data` | +| `quadlet_dir` | `~/.config/containers/systemd` | +| `host` | `cloud.hopsngrains.com` — the site block Caddy serves | +| `container_name` | `nextcloud`, and the Quadlet unit / container name | +| `image` | `docker.io/library/nextcloud:latest` — fully qualified on purpose | +| `network_name` | `nextcloud` → `nextcloud.network` / `nextcloud_net` | +| `host_directory` / `webroot` | `~/podman-data/nextcloud` → `/var/www/html` | +| `app_uid` / `app_gid` | `33` — `www-data`, the uid the server writes as | +| `container_port` | `80`, the port inside the container that Caddy proxies to | +| `env_file` | `~/podman-data/nextcloud/nextcloud.env`, mode `0600` | +| `postgres_container` / `postgres_unit` | The shared database unit this app attaches to | +| `pg_user` / `pg_password` / `pg_database` | Credentials, and the database name — `nextcloud` | +| `db_host` | `db` — the alias `config.php` connects to | +| `valkey_container` / `valkey_unit` | The shared cache unit this app attaches to | +| `redis_host` | `redis` — the alias `config.php` and PHP sessions connect to | +| `cron_unit` / `cron_schedule` / `user_unit_dir` | The background-job timer | +| `caddy_name` / `caddyfile_path` | The shared Caddy unit and its Caddyfile | + +The three `podman_*` / `quadlet_dir` vars are **duplicated** from +[caddy/vars/default.yml](../caddy/vars/default.yml), as in every service +playbook. That is a deliberate trade: a play cannot include the Caddy vars file +wholesale, because it also sets `host_directory` (to the *Caddy* data directory) +and every service defines that for itself. Keep them in sync. Caddy's fourth, +`podman_unprivileged_port_start`, belongs to the podman tasks this play does not +run — Nextcloud publishes nothing host-side. + +Like Caddy, `podman_user` is derived from the connection and the play carries no +`become`. Because the podman tasks are not run here, there is **no guard against +connecting as `root`**; the prerequisites, including that check, live in +`caddy/playbook.yml`. + +`UserNS=keep-id:uid=33,gid=33` — `33` is the uid the server writes as. The +entrypoint starts as container-root, but it rsyncs the code in with +`--chown www-data:www-data` and Apache then drops to `www-data`, so everything it +writes lands on `podman_user` and no subuid-owned files appear to break backups +([docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). `33` is +also what the data is owned by on monoco, which is what makes the copy below need +no `chown` pass. + +One file is created by container-root and never chowned: the entrypoint takes an +`flock` on `/var/www/html/nextcloud-init-sync.lock` using a root-owned shell +redirect. The role **pre-creates it** as `podman_user`, because the redirect +truncates the file rather than recreating it — so its owner never changes. Left +to the container it would be the one file in the webroot owned by a `165536+` +subuid and not removable by the host user. + +`POSTGRES_PASSWORD` is the fleet's shared `123456`, which matches +`pg_password` in [postgres/vars/default.yml](../postgres/vars/default.yml) and +what `config.php` already carries — a migrated app connects unchanged. It lives +in `nextcloud.env` at mode `0600` rather than in the world-readable unit file. +Both are committed in plaintext as before; flagged as a follow-up, not changed +here. + +## Running + +```command +ansible-playbook -u tanshu nextcloud/playbook.yml +``` + +The play targets `emily` directly, so the inventory must contain it — the one in +`prepare-server/` does, and its `ansible.cfg` names it. + +This play applies **only** this app's units and data. The rootless-Podman +prerequisites — installing podman, `loginctl enable-linger`, the low-port sysctl +and the Quadlet directory — are the shared task file at +[caddy/roles/podman/tasks/main.yaml](../caddy/roles/podman/tasks/main.yaml), and +are **not** run here. A host that has already run `caddy/playbook.yml` has them. + +`systemctl --user enable nextcloud` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. The +cron **timer** is a plain unit and is enabled explicitly, as described above. + +## Migrating the data from monoco + +The database and the files have to correspond, so this is a freeze, not a +best-effort copy. Run it from the machine that holds the `monoco` and `emily` SSH +aliases, as in [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). + +Two things are already done: the `nextcloud` database is restored on `emily`, and +the Caddyfile and certificates live there too. What is left is +`/var/lib/nextcloud`. + +### 0. Look first + +```bash +# What is there, how big, and whether it is readable without sudo. Nextcloud's +# data/ is normally 0770 www-data:www-data, so this says "no" — the sudo form +# below is the expected one, not the exception. +ssh monoco 'du -sh /var/lib/nextcloud; ls -ln /var/lib/nextcloud' +ssh monoco 'find /var/lib/nextcloud -type f ! -readable | head' # empty = no sudo needed + +# The version on monoco versus the image emily will run. The entrypoint compares +# the two on first start: a NEWER image rsyncs its code in and runs `occ upgrade` +# (one major version at a time, so a large gap needs stepping stones), while an +# OLDER image refuses to start at all — "downgrading is not supported". +ssh monoco 'docker exec nextcloud php -r "require \"/var/www/html/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"' +ssh emily 'podman run --rm --entrypoint php docker.io/library/nextcloud:latest \ + -r "require \"/usr/src/nextcloud/version.php\"; echo implode(\".\", \$OC_Version), PHP_EOL;"' +``` + +### 1. Freeze monoco + +```bash +# Stop the app first: a stopped container cannot write to data/ while it is being +# copied, and no maintenance-mode dance is needed. Leave it stopped — its database +# is on emily, and two live copies would diverge. +ssh monoco 'docker stop nextcloud' +``` + +### 2. Make the database correspond (only if it moved since the cluster move) + +The cluster was dumped on **2026-10-15**. If monoco's Nextcloud has served writes +since then, the files you are about to copy are newer than the database on +`emily`, and restoring the files alone would leave the instance inconsistent. +Re-dump the one database and reload it — it is 320 MB, so this is cheap. + +If the app is already running on `emily` from an earlier attempt, stop it first: +`DROP DATABASE` needs the connections gone, and a running Nextcloud holds several. + +```bash +ssh emily 'systemctl --user stop nextcloud.service' # only if it is already deployed + +```bash +# Dump on monoco (Postgres is a separate container and is still running). +ssh monoco 'mkdir -p ~/pg-migrate && cd ~/pg-migrate + docker exec -e PGPASSWORD=123456 postgres pg_dump -U postgres -Fc nextcloud \ + | zstd -T0 -6 -o nextcloud-$(date +%Y%m%d-%H%M).dump.zst + ls -lh nextcloud-*.dump.zst' + +# Transfer, then check the checksum on both sides before trusting it. +ssh monoco 'cat ~/pg-migrate/nextcloud-*.dump.zst' \ + | ssh emily 'cat > ~/pg-migrate/nextcloud.dump.zst' +a=$(ssh monoco 'sha256sum ~/pg-migrate/nextcloud-*.dump.zst | cut -d" " -f1') +b=$(ssh emily 'sha256sum ~/pg-migrate/nextcloud.dump.zst | cut -d" " -f1') +[ -n "$a" ] && [ "$a" = "$b" ] && echo "transfer OK: $a" \ + || { echo "MISMATCH — do not restore: $a vs $b"; false; } + +# Reload it on emily. This DROPS the existing database — it is the one the +# cluster move restored, and the frozen dump replaces it. +ssh emily 'cd ~/pg-migrate + podman exec -e PGPASSWORD=123456 postgres \ + psql -U postgres -d postgres -v ON_ERROR_STOP=1 \ + -c "DROP DATABASE IF EXISTS nextcloud" \ + -c "CREATE DATABASE nextcloud OWNER postgres" + zstd -dc nextcloud.dump.zst | podman exec -i -e PGPASSWORD=123456 postgres \ + pg_restore -U postgres -d nextcloud --no-owner --exit-on-error + podman exec -e PGPASSWORD=123456 postgres \ + psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' +``` + +If monoco's container simply sat idle after the cluster move, skip this and go +straight to the copy — but the file counts at the end of step 3 are what tell you +whether the two halves agree. + +### 3. Copy the files + +```bash +# 1. Create the destination: the mount target of the image's /var/www/html. +ssh emily 'mkdir -p ~/podman-data/nextcloud' + +# 2. Copy. tar streams it across the workstation; rsync cannot copy +# remote-to-remote in one command. +# +# The WHOLE tree is copied — code as well as state — because /var/www/html is +# both. See "What is state, and what is not" above. +# +# `set -o pipefail` matters here: without it the pipeline reports only the +# *receiving* tar's status, so a read error or a dropped stream on the source +# side exits 0 and leaves a silently short copy. +# +# `--no-same-owner` is explicit and important: the source is 33:33 (and partly +# root), and under keep-id:uid=33 the *container's* 33 is tanshu on the host. +# Extracting as tanshu is therefore already the correct ownership, and +# restoring the source's numeric owners would be wrong. +set -o pipefail +ssh monoco 'sudo tar -C /var/lib/nextcloud -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/nextcloud -xf -' + +# Without sudo, when step 0 proved everything is readable: +# ssh monoco 'tar -C /var/lib/nextcloud -cf - .' \ +# | ssh emily 'tar --no-same-owner -C ~/podman-data/nextcloud -xf -' + +# 3. Verify by content, over the whole tree. Read as root on the source side +# because data/ is 0770. +ssh monoco 'cd /var/lib/nextcloud && sudo bash -c "find . -type f -print0 | sort -z | xargs -0 -r sha256sum" | sha256sum' +ssh emily 'cd ~/podman-data/nextcloud && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +# identical digests = a byte-faithful copy + +# 4. Nothing may end up owned by root or by a subuid. +ssh emily 'find ~/podman-data/nextcloud ! -user tanshu' # empty = correct + +# 5. A cheap sanity check that the two halves agree — compare with the count from +# step 2, and with monoco's own tree. +ssh emily 'find ~/podman-data/nextcloud/data -type f | wc -l' +ssh monoco 'sudo find /var/lib/nextcloud/data -type f | wc -l' +``` + +Ownership needs no `chown` pass: `keep-id:uid=33` maps the container's `www-data` +onto the connecting user, so files extracted as `tanshu` are already what the +container expects to own — and what plain `tar`, `rsync` and `rm -rf` keep working +with. + +If the link is slow or drops, do it in two hops instead: resumable, and it shows +progress. + +```bash +rsync -aP --rsync-path='sudo rsync' monoco:/var/lib/nextcloud/ ~/nextcloud-copy/ +rsync -aP ~/nextcloud-copy/ emily:~/podman-data/nextcloud/ +``` + +### 4. Deploy + +```bash +cd prepare-server && ansible-playbook -u tanshu ../nextcloud/playbook.yml +``` + +Run it **after** the copy: the first start is when the entrypoint compares the +copied `version.php` with the image and, if the image is newer, runs +`occ upgrade`. Starting before the copy would initialise an empty instance and +then be overwritten by it. + +Rolling back means rolling back all three halves — `ssh monoco 'docker start nextcloud'` +is only meaningful while monoco's own Postgres 17 cluster is intact, and it would +then be writing to a database emily's copy has already moved past. + +## Verification + +```bash +systemctl --user status nextcloud +journalctl --user -u nextcloud -f +podman logs nextcloud + +# What the playbook checks for you: +podman exec -u www-data nextcloud php occ status # installed, version, maintenance +podman exec nextcloud getent hosts db # config.php's database host +podman exec nextcloud getent hosts redis # config.php's cache host +podman exec nextcloud getent hosts postgres valkey # the container names + +# The background job, and that it is actually scheduled: +systemctl --user list-timers nextcloud-cron.timer +systemctl --user start nextcloud-cron.service # run one by hand +journalctl --user -u nextcloud-cron -n 20 + +# The data really is the copied tree: +ssh emily 'ls -ln ~/podman-data/nextcloud | head' +ssh emily 'test -f ~/podman-data/nextcloud/config/config.php && echo "config.php present"' + +# The database really is the migrated one — run the same query on both hosts. +ssh emily 'podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' +ssh monoco 'docker exec -e PGPASSWORD=123456 postgres psql -U postgres -d nextcloud -tAc "select count(*) from oc_filecache"' + +# The site, end to end: +curl -sSI https://cloud.hopsngrains.com +``` + +Then open it **in a browser** and do two things a `curl` cannot: log in, and +**upload a file**. Uploading is the check that matters here — it exercises the +uid-33 mapping (a wrong `keep-id` shows up as a permissions error), the Redis +file locking in `config.php`, and PHP sessions, which the image configures from +`REDIS_HOST`. + +After the upload, confirm on the host that the new file is owned by `tanshu`: + +```bash +ssh emily 'find ~/podman-data/nextcloud/data -newermt "-10 minutes" -type f ! -user tanshu' +# empty = the write path lands on the right uid +``` + +Then reboot the host and confirm both the container and the timer return +unattended — without `loginctl enable-linger tanshu` neither will, and the failure +is silent. + +Finally, if `config.php` predates the proxy (it should not — monoco already served +this site through Caddy, but check), Nextcloud needs the proxy trusted before it +will believe `X-Forwarded-For`: + +```bash +podman exec -u www-data nextcloud php occ config:system:get trusted_proxies +podman exec -u www-data nextcloud php occ config:system:get overwrite.cli.url +``` + +An empty `trusted_proxies` means every client looks like the proxy; see +[caddy/readme.md](../caddy/readme.md), "Client IPs", which also explains why the +real address may not survive the rootless port forward at all. + +## Notes and gotchas + +- **Maintenance mode travels with `config.php`.** The backup plan recommends + `occ maintenance:mode --on` before a consistent dump + ([docs/backup-plan.md §3.4](../docs/backup-plan.md)); if monoco was left that + way, the copied configuration carries `maintenance => true` and the site returns + `503` for everything except `status.php`. The play **waits for maintenance mode + to clear** — it is on for the whole of a first-start `occ upgrade`, so it cannot + simply assert — and if it never clears, fails with the one-line command to turn + it off. That is deliberate: "the app is up but every page 503s" points anywhere + but at maintenance mode. +- **Version drift is the one irreversible step.** The copied `version.php` is what + the entrypoint compares against the image. A newer image upgrades the instance + on first start (and can only step one major version at a time); an older image + makes the container exit with "downgrading is not supported". Check both before + copying (step 0) and take a database dump before the first start. +- **`latest` floats.** The Docker-era playbook used the unqualified + `nextcloud:latest`; this one fully qualifies the same tag, because rootless + Podman has no TTY to answer a short-name prompt when the unit starts. Neither + pins a digest, so an image bump is a normal `podman pull` away — and it is + exactly the event that triggers an in-place upgrade, because the code lives in + the mount. +- **The code lives in the data directory.** That is the image's design, not this + playbook's, and it is why the whole tree is copied and why an image bump edits + `~/podman-data/nextcloud`. It is also why the backup plan lists `apps/`, `core/` + and `lib/` as regenerable: they are, but only from an image that is the same + version or newer. +- **`occ` must run as `www-data`.** It refuses to run as any other user, and under + `keep-id:uid=33` that is the same user the host sees as `tanshu`. Every + `podman exec` in this file therefore carries `-u www-data`; the plain form works + for `getent`, which does not care. +- **The init lock file.** `~/podman-data/nextcloud/nextcloud-init-sync.lock` is + pre-created by the role so it stays owned by `tanshu`. If a run ever reports it + as a subuid, the pre-create was skipped on a first start — delete it and re-run + the playbook. +- **Client IPs.** Nextcloud uses the client address for brute-force protection and + for the `X-Forwarded-For` it logs. Whether the original address survives the + rootless port forward depends on the forwarder. See + [caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it. +- **Registering restarts all of Caddy** (a brief restart of every hosted site), + **attaching restarts Postgres** (a few seconds without a database for every + attached app) and **restarts Valkey** (seconds without sessions or locks, though + the keys themselves survive the graceful stop). See the trade-offs in + [caddy/readme.md](../caddy/readme.md), [postgres/readme.md](../postgres/readme.md) + and [valkey/readme.md](../valkey/readme.md). +- **If the Caddyfile does not exist yet**, this playbook creates it containing only + the site block. That is a valid Caddyfile; the Caddy playbook's seeded + global-options block is then not added, because its seed only fires on a missing + or zero-length file. Deploy Caddy first on a fresh host. +- **DNS.** `cloud.hopsngrains.com` must resolve to `emily` before Caddy can obtain + a certificate for it; the old certificates live in `~/podman-data/caddy/data` + and are reused if the hostname is unchanged. +- **The backup script still assumes Docker.** `~/bin/nextcloud-backup.sh` uses + `--link postgres:db`, which Podman does not have, and its `sudo tar` / + `chown $(logname)` gymnastics are unnecessary now that the data is owned by you. + It needs the `podman exec` form from + [postgres/readme.md](../postgres/readme.md), "Backups"; the database dump should + also be taken with `occ maintenance:mode --on` around it, as + [docs/backup-plan.md §3.4](../docs/backup-plan.md) says. +- **`~/bin/empty-trash.sh` targets a container named `cloud`**, which never + existed on this host under that name — it is `nextcloud` — and it runs as root, + which cannot `podman exec` into these containers at all. +- **Do not restart the container on monoco.** It would write to monoco's old + cluster, which is being kept intact purely as a rollback. + +## Not done here + +- Rotating the secrets that are committed in plaintext — `pg_password` is the + fleet's shared `123456`, and it lives in `nextcloud.env` at mode `0600` rather + than in the world-readable unit file. +- Switching the background-job mode to `cron` (`occ background:cron`), which the + Docker-era deployment also never did. +- Replacing the broken `~/bin/nextcloud-backup.sh` with a systemd user timer, and + fixing `empty-trash.sh`. +- Turning on ongoing monitoring — the `status.php` probe in the role is where a + timer or an external check would hook in. diff --git a/nextcloud/roles/caddy/handlers/main.yaml b/nextcloud/roles/caddy/handlers/main.yaml index 294bf14..5fe87df 100644 --- a/nextcloud/roles/caddy/handlers/main.yaml +++ b/nextcloud/roles/caddy/handlers/main.yaml @@ -1,5 +1,31 @@ --- -# Handlers for Caddy role +# Caddy is its own playbook (caddy/playbook.yml) and a rootless Quadlet unit +# there. These handlers run as the connection user, so `systemctl --user` and +# `podman exec` work without any XDG_RUNTIME_DIR juggling. + +# Notified by this role when the Caddyfile changes. A reload keeps existing +# connections; a restart would briefly drop every hosted site. +# +# Note the config path: /etc/caddy/Caddyfile is the mount inside the container, +# not {{ caddyfile_path }}, which is the host side of it. - name: Reload Caddy configuration - ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" - listen: "Reload Caddy" \ No newline at end of file + ansible.builtin.command: >- + podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile + changed_when: true + listen: "Reload Caddy" + +# Notified by caddy/roles/register when this service's network unit, or the +# drop-in that attaches Caddy to it, is written. Quadlet has no live equivalent +# of `docker network connect`, so the container has to be recreated — a brief +# restart of every hosted site, not just this one. See caddy/readme.md, +# "Trade-off worth knowing". +# +# Defined after the reload above so that when both are notified the cheap +# config reload happens first. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_name }}.service" + scope: user + state: restarted + daemon_reload: true + listen: Restart Caddy diff --git a/nextcloud/roles/caddy/tasks/main.yaml b/nextcloud/roles/caddy/tasks/main.yaml index 9616151..f439f48 100644 --- a/nextcloud/roles/caddy/tasks/main.yaml +++ b/nextcloud/roles/caddy/tasks/main.yaml @@ -1,22 +1,25 @@ --- -- name: Read current Caddyfile - ansible.builtin.slurp: - path: "{{ caddyfile_path }}" - register: caddyfile_raw - -- name: Decode Caddyfile content - set_fact: - caddyfile_content: "{{ caddyfile_raw['content'] | b64decode }}" - -- name: Check if snippet already exists - set_fact: - snippet_present: "{{ snippet_block in caddyfile_content }}" - -- name: Add snippet if missing +# Append this site's block to the shared Caddyfile. +# +# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the +# change as soon as the file is written; the handler only asks Caddy to re-read +# it. That is a reload, not a restart, so the other hosted sites are untouched. +# +# blockinfile is idempotent on its own via the marker, so there is no need to +# slurp the file and compare first — which also matters because slurp *fails* +# when the Caddyfile does not exist yet, on a host where the Caddy playbook has +# not run. +# +# The marker is unchanged from the Docker-era role, so an existing +# cloud.hopsngrains.com block written on monoco is replaced in place rather than +# duplicated. +- name: "Add the {{ host }} site block to the Caddyfile" ansible.builtin.blockinfile: path: "{{ caddyfile_path }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" block: "{{ snippet_block }}" create: yes - when: not snippet_present - notify: "Reload Caddy" \ No newline at end of file + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/nextcloud/roles/network/tasks/main.yaml b/nextcloud/roles/network/tasks/main.yaml deleted file mode 100644 index b57bb21..0000000 --- a/nextcloud/roles/network/tasks/main.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -- name: "Ensure {{ container_name }} Docker network exists" - docker_network: - name: "{{ docker_network }}" - state: present - connected: - - "{{ caddy_container }}" - - "{{ postgres_container }}" - - name: "{{ redis_container }}" - alias: redis - appends: yes diff --git a/nextcloud/roles/nextcloud/handlers/main.yaml b/nextcloud/roles/nextcloud/handlers/main.yaml new file mode 100644 index 0000000..91063e6 --- /dev/null +++ b/nextcloud/roles/nextcloud/handlers/main.yaml @@ -0,0 +1,21 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so systemctl +# --user works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +# A changed environment file or unit file changes the generated unit, so +# Nextcloud is recreated. Nothing is lost by that: config.php, data/ and +# custom_apps/ are on the bind mount and the database is in Postgres. It is still +# a few seconds of downtime for the site, and the entrypoint re-runs its version +# check — including `occ upgrade` if the image has moved on — on the way back up. +- name: Restart Nextcloud + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Nextcloud diff --git a/nextcloud/roles/nextcloud/tasks/main.yaml b/nextcloud/roles/nextcloud/tasks/main.yaml index 36e5bb4..64add15 100644 --- a/nextcloud/roles/nextcloud/tasks/main.yaml +++ b/nextcloud/roles/nextcloud/tasks/main.yaml @@ -1,36 +1,267 @@ --- -# Tasks for Nextcloud role -- name: Pull Nextcloud image - docker_image: - name: "{{ container_image }}" - source: pull - force_source: yes +# 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 Nextcloud Directory exists - file: - path: "/var/lib/{{ host_directory }}" - state: directory - mode: 0755 +- name: Ensure the Nextcloud webroot directory exists + ansible.builtin.file: + path: "{{ host_directory }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' -- name: Create Nextcloud container - docker_container: - name: "{{ container_name }}" - image: "{{ container_image }}" +# 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 + +- 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 + block: + - name: Start Nextcloud + 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 (an unattached network, an + # unreachable database, an unwritable webroot). Pull the real reason out. + - name: Collect Nextcloud 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: 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 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 Nextcloud is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: nextcloud_active + changed_when: false + failed_when: nextcloud_active.stdout != 'active' + +# `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 - restart_policy: "unless-stopped" - env: - POSTGRES_DB: "{{ db_name }}" - POSTGRES_USER: "{{ db_user }}" - POSTGRES_PASSWORD: "{{ db_pass }}" - POSTGRES_HOST: "{{ db_host }}" - REDIS_HOST: "redis" - networks: - - name: "{{ docker_network }}" - volumes: - - "/var/lib/{{ host_directory }}:/var/www/html" + daemon_reload: true -- name: Add cron job - cron: - name: Nextcloud cron - minute: "*/5" - job: "docker exec -u www-data {{ container_name }} php cron.php" +- 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)']) }}" diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 new file mode 100644 index 0000000..9a059aa --- /dev/null +++ b/nextcloud/roles/nextcloud/templates/nextcloud-cron.service.j2 @@ -0,0 +1,29 @@ +# Nextcloud background jobs — systemd user service. +# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. +# +# This replaces the Docker-era root crontab entry: +# +# */5 * * * * docker exec -u www-data nextcloud php cron.php +# +# Root cannot `podman exec` into another user's rootless containers, and a +# container user's cron is not a thing. A user service started by the timer next +# to it is the idiomatic replacement, and it is what comes back after a reboot +# with the rest of the stack (docs/podman-migration.md §8(b), §10). +# +# No XDG_RUNTIME_DIR juggling is needed: systemd's *user* manager sets it for +# every unit it starts, which is also why the whole playbook runs as the +# connection user rather than through become. + +[Unit] +Description=Nextcloud background jobs (cron.php) +After={{ unit_name }} + +[Service] +Type=oneshot +# `-u www-data` matters: cron.php must run as the user that owns config.php, and +# under keep-id that is uid {{ app_uid }}. +ExecStart=/usr/bin/podman exec -u www-data {{ container_name }} php {{ webroot }}/cron.php +# A long-running job must not pile up behind itself; the next timer tick would +# start a second copy. Generous, because a first run after an upgrade can have a +# lot of queued work. +TimeoutStartSec=900 diff --git a/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 b/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 new file mode 100644 index 0000000..1b24e30 --- /dev/null +++ b/nextcloud/roles/nextcloud/templates/nextcloud-cron.timer.j2 @@ -0,0 +1,19 @@ +# Nextcloud background jobs — systemd user timer. +# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. +# +# Fires nextcloud-cron.service every five minutes, which is what Nextcloud's own +# installation guide asks for and what the Docker-era root crontab did. + +[Unit] +Description=Run Nextcloud background jobs every five minutes + +[Timer] +OnCalendar={{ cron_schedule }} +# Catch up once if a slot was missed while the host was down, so a reboot does +# not delay the first run by five minutes. It is not a queue: one catch-up run, +# never one per missed slot. +Persistent=true +Unit={{ cron_unit }}.service + +[Install] +WantedBy=timers.target diff --git a/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 b/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 new file mode 100644 index 0000000..075ce09 --- /dev/null +++ b/nextcloud/roles/nextcloud/templates/nextcloud.container.j2 @@ -0,0 +1,56 @@ +# Nextcloud — rootless Podman Quadlet unit. +# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. +# +# This unit joins {{ network_name }}.network, which is created by +# caddy/roles/register (the same include that attaches Caddy to it). The +# postgres container is joined to that network by postgres/roles/attach and the +# valkey container by valkey/roles/attach, which is what makes config.php's `db` +# and `redis` hosts resolve. + +[Unit] +Description=Nextcloud +Documentation=https://github.com/nextcloud/docker +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# Map the container's www-data account (uid {{ app_uid }}) onto {{ podman_user }} +# on the host. The entrypoint starts as container-root — a subuid under keep-id — +# but it rsyncs the code in with `--chown www-data:www-data` and Apache drops to +# www-data, so everything it writes lands on {{ podman_user }} and no subuid-owned +# files appear to break backups. +# +# 33 is also the uid the data on monoco is owned by, so the copied tree needs no +# chown pass: extracting it as {{ podman_user }} is already the right ownership. +UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }} + +# POSTGRES_* and REDIS_HOST live here rather than as Environment= lines above: +# this unit is world-readable in ~/.config, and POSTGRES_PASSWORD is in it. Read +# by systemd, not by Ansible, so the file has to exist before this unit starts. +# +# These are not an install step any more — config.php is copied from monoco, not +# generated. REDIS_HOST is still load-bearing on every start, because the +# entrypoint rewrites PHP's session-handler config from it. +EnvironmentFile={{ env_file }} + +Network={{ network_name }}.network + +# The image's VOLUME and Apache document root. This is the whole installation: +# the code (version.php, core/, lib/, apps/), config/config.php, data/ and +# custom_apps/. The Docker-era playbook bind-mounted exactly this, and the +# entrypoint's version check reads version.php from here — which is why the +# migration copies the tree rather than only the state. See readme.md. +Volume={{ host_directory }}:{{ webroot }} + +[Service] +Restart=always +# The entrypoint rsyncs the image's code into the webroot and, if the copied +# version.php is older than the image, runs `occ upgrade` before Apache answers. +# On a 320 MB database that is minutes, not seconds. +TimeoutStartSec=600 + +[Install] +WantedBy=default.target diff --git a/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 b/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 new file mode 100644 index 0000000..6e14953 --- /dev/null +++ b/nextcloud/roles/nextcloud/templates/nextcloud.env.j2 @@ -0,0 +1,19 @@ +# Environment for {{ container_name }}.container. +# Managed by Ansible (nextcloud/roles/nextcloud). Do not edit by hand. +# +# Mode 0600 on purpose: the Quadlet unit that reads this file is world-readable, +# and POSTGRES_PASSWORD is in it. Note that `podman inspect` still prints the +# resulting environment to anyone who can run podman as this user. +# +# config.php is copied from monoco, not generated, so the POSTGRES_* values are +# not an install step any more — they only have to agree with what config.php +# already carries, which is exactly what the Docker-era container passed. +# +# REDIS_HOST is different: the entrypoint reads it on *every* start and rewrites +# PHP's session handler from it (/usr/local/etc/php/conf.d/redis-session.ini), so +# a wrong value here shows up as failed logins rather than as a missing cache. +POSTGRES_DB={{ pg_database }} +POSTGRES_USER={{ pg_user }} +POSTGRES_PASSWORD={{ pg_password }} +POSTGRES_HOST={{ db_host }} +REDIS_HOST={{ redis_host }} diff --git a/nextcloud/roles/postgres/handlers/main.yaml b/nextcloud/roles/postgres/handlers/main.yaml new file mode 100644 index 0000000..31a5d44 --- /dev/null +++ b/nextcloud/roles/postgres/handlers/main.yaml @@ -0,0 +1,17 @@ +--- +# Postgres is its own playbook (postgres/playbook.yml) and a rootless Quadlet +# unit there. This handler runs as the connection user, so `systemctl --user` +# works without setting XDG_RUNTIME_DIR. +# +# Required by postgres/roles/attach, which notifies it whenever this app's +# drop-in changes: a new drop-in changes the generated unit, so Postgres must be +# recreated to join the network — Quadlet has no live equivalent of +# `podman network connect`. That is a few seconds of downtime for every attached +# app, not just this one. See postgres/readme.md, "The trade-off". +- name: Restart Postgres + ansible.builtin.systemd_service: + name: "{{ postgres_unit }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Postgres diff --git a/nextcloud/roles/postgres/tasks/main.yaml b/nextcloud/roles/postgres/tasks/main.yaml new file mode 100644 index 0000000..b756e27 --- /dev/null +++ b/nextcloud/roles/postgres/tasks/main.yaml @@ -0,0 +1,28 @@ +--- +# Attach this app's network to the shared Postgres unit. +# +# Postgres knows nothing about its clients, exactly like Caddy: a service joins +# by dropping 50-nextcloud.conf and the shared 10-network-aliases.conf next to +# postgres.container. The canonical way to do that is the shared include below — +# it writes the same bytes every service writes, asserts that nextcloud.network +# already exists, and notifies the "Restart Postgres" handler defined in this +# role. +# +# This runs in the roles phase, which is after the register include in +# playbook.yml's pre_tasks has created nextcloud.network. + +- name: Attach Nextcloud to Postgres + ansible.builtin.include_tasks: "{{ playbook_dir }}/../postgres/roles/attach/tasks/main.yaml" + +# On a first attach both drop-ins are new, so Postgres has to be recreated to +# join nextcloud_net — and that restart is a *handler*, which by default runs at +# the end of the play, after the nextcloud role has already started the app. The +# app would then come up before `db` resolves, fail its own readiness check, and +# fail the play on a machine where nothing is actually wrong. +# +# Flushing applies the new drop-in before the app starts. It also flushes the +# "Restart Caddy" that the register pre_task queued, which is what attaches Caddy +# to nextcloud_net — also wanted before the app is expected to serve. On later +# runs nothing is pending and this is a no-op. +- name: Apply the Postgres network change before starting Nextcloud + ansible.builtin.meta: flush_handlers diff --git a/nextcloud/roles/valkey/handlers/main.yaml b/nextcloud/roles/valkey/handlers/main.yaml new file mode 100644 index 0000000..71e55fe --- /dev/null +++ b/nextcloud/roles/valkey/handlers/main.yaml @@ -0,0 +1,22 @@ +--- +# Valkey is its own playbook (valkey/playbook.yml) and a rootless Quadlet unit +# there. This handler runs as the connection user, so `systemctl --user` works +# without setting XDG_RUNTIME_DIR. +# +# Required by valkey/roles/attach, which notifies it whenever this app's drop-in +# changes: a new drop-in changes the generated unit, so Valkey must be recreated +# to join the network — Quadlet has no live equivalent of +# `podman network connect`. +# +# Data survives the recreate: RDB persistence is on by default, the stop is +# graceful, and the new container loads the snapshot back off the bind mount. What +# does not survive is availability — for the seconds the container is down, +# Nextcloud's PHP sessions and `memcache.locking` are unreachable. See +# valkey/readme.md, "The trade-off". +- name: Restart Valkey + ansible.builtin.systemd_service: + name: "{{ valkey_unit }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Valkey diff --git a/nextcloud/roles/valkey/tasks/main.yaml b/nextcloud/roles/valkey/tasks/main.yaml new file mode 100644 index 0000000..f8d833c --- /dev/null +++ b/nextcloud/roles/valkey/tasks/main.yaml @@ -0,0 +1,29 @@ +--- +# Attach this app's network to the shared Valkey unit. +# +# Valkey knows nothing about its clients, exactly like Caddy and Postgres: a +# service joins by dropping 50-nextcloud.conf and the shared +# 10-network-aliases.conf next to valkey.container. The canonical way to do that +# is the shared include below — it writes the same bytes every service writes, +# asserts that nextcloud.network already exists, and notifies the "Restart +# Valkey" handler defined in this role. +# +# `redis` is the alias config.php connects to, not the container name: the image +# writes the host from REDIS_HOST, which the Docker-era `env:` block set to +# `redis`, and the same variable configures PHP's session handler on every start. +# Renaming it here would mean editing config.php on a live instance for nothing. + +- name: Attach Nextcloud to Valkey + ansible.builtin.include_tasks: "{{ playbook_dir }}/../valkey/roles/attach/tasks/main.yaml" + +# On a first attach the drop-in is new, so Valkey has to be recreated to join +# nextcloud_net — and that restart is a *handler*, which by default runs at the +# end of the play, after the nextcloud role has already started the app. The app +# would then come up before `redis` resolves; with `memcache.locking` on Redis it +# would fail on the first write rather than at startup, which is a worse failure +# to debug. +# +# Flushing applies the new drop-in before the app starts. On later runs the +# drop-in is unchanged, nothing is pending, and this is a no-op. +- name: Apply the Valkey network change before starting Nextcloud + ansible.builtin.meta: flush_handlers diff --git a/nextcloud/vars/default.yml b/nextcloud/vars/default.yml index b70c0e2..33e3350 100644 --- a/nextcloud/vars/default.yml +++ b/nextcloud/vars/default.yml @@ -1,28 +1,112 @@ --- -container_name: nextcloud -container_image: nextcloud:latest - -docker_network: nextcloud_net - -postgres_container: postgres -caddy_container: caddy -redis_container: valkey +# --------------------------------------------------------------------------- +# Rootless Podman / Quadlet +# --------------------------------------------------------------------------- +# Duplicated on purpose from caddy/vars/default.yml: a play cannot include that +# file wholesale, because it also sets `host_directory` — to the Caddy data +# directory — and every playbook defines that for itself. Keep these three in +# sync with caddy/vars/default.yml. (Its fourth, podman_unprivileged_port_start, +# belongs to the podman tasks, which this play does not run: Nextcloud listens on +# 80 inside the container and publishes nothing host-side.) +# +# podman_user is derived from the connection: this play carries no become, so the +# Quadlet units belong to whoever Ansible connects as. Requires gather_facts (set +# in playbook.yml). +podman_user: "{{ ansible_facts['user_id'] }}" +podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" +quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" +# --------------------------------------------------------------------------- +# Nextcloud +# --------------------------------------------------------------------------- host: cloud.hopsngrains.com -caddyfile_path: /var/lib/caddy/conf/Caddyfile +container_name: nextcloud +unit_name: "{{ container_name }}.service" + +# Fully qualified on purpose: rootless Podman resolves short names against +# unqualified-search-registries, and there is no TTY to answer a prompt when the +# systemd unit starts. The Docker-era playbook said `nextcloud:latest`. +image: docker.io/library/nextcloud:latest + +# The Quadlet file is .network. caddy/roles/register pins the +# Podman network name to _net (so: nextcloud.network, +# nextcloud_net). +network_name: "{{ container_name }}" + +# The image's VOLUME and Apache document root — and, unlike the other services +# here, it holds the *code* as well as the state: config.php, data/, +# custom_apps/ and every app's PHP files live under it. The Docker-era playbook +# bind-mounted the whole thing from /var/lib/nextcloud, and this one keeps that +# shape on purpose. See readme.md, "What is state, and what is not". +host_directory: "{{ podman_data_dir }}/{{ container_name }}" +webroot: /var/www/html + +# The uid the *server* writes as: the image's entrypoint starts as container-root, +# rsyncs the code in with --chown www-data:www-data, and Apache then drops to +# www-data. `docker exec nextcloud id -u` reports 0, which is misleading +# (docs/podman-compat-audit.md §3.2). +app_uid: 33 +app_gid: 33 + +# The port Apache listens on inside the container, and the one Caddy proxies to. +container_port: 80 + +# POSTGRES_* and REDIS_HOST are read by the entrypoint on every start — REDIS_HOST +# in particular rewrites PHP's session handler config — and POSTGRES_PASSWORD is in +# there, so it goes in this 0600 file rather than in the world-readable unit. +env_file: "{{ host_directory }}/nextcloud.env" + +# The shared Postgres unit this app attaches itself to (postgres/playbook.yml). +# roles/postgres owns this app's drop-ins there and the "Restart Postgres" +# handler that applies them. +postgres_container: postgres +postgres_unit: "{{ postgres_container }}.service" + +# The credentials and database name the Docker-era playbook embedded in its +# `env:` block, kept as-is so the already-restored `nextcloud` database (320 MB, +# one of the 12 in postgres/migrate-from-monoco.md) and config.php agree. +pg_user: postgres +pg_password: '123456' +pg_database: nextcloud + +# `db` is the host config.php actually connects to — the Docker-era container set +# POSTGRES_HOST=db, and postgres/roles/attach adds that alias to the shared +# Postgres unit. +db_host: db + +# The shared Valkey unit this app attaches itself to (valkey/playbook.yml), and +# the alias it is reached by. REDIS_HOST is not cosmetic: config.php's +# `memcache.locking` is Redis, so an unresolvable `redis` breaks uploads rather +# than merely slowing them down. +valkey_container: valkey +valkey_unit: "{{ valkey_container }}.service" +redis_host: redis + +# The shared Caddy unit and its Caddyfile, both owned by caddy/playbook.yml. +caddy_name: caddy +caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" + +# --------------------------------------------------------------------------- +# Background jobs +# --------------------------------------------------------------------------- +# Nextcloud wants `cron.php` every five minutes. The Docker-era playbook put it in +# *root's* crontab as `docker exec -u www-data nextcloud php cron.php`; root +# cannot `podman exec` into another user's rootless containers, so it becomes a +# systemd user timer next to the Quadlet units (docs/podman-migration.md §8(b)). +cron_unit: "{{ container_name }}-cron" +cron_schedule: "*:0/5" +user_unit_dir: "{{ ansible_facts['user_dir'] }}/.config/systemd/user" + +# The site block, unchanged from the Docker-era vars: Caddy terminates TLS and +# Nextcloud has always asked for HSTS. There is no request-body limit to carry +# over — Caddy streams bodies and imposes none by default, whereas the dead nginx +# front end needed client_max_body_size spelled out. snippet_block: | {{ host }} { - reverse_proxy {{ container_name }}:80 + reverse_proxy {{ container_name }}:{{ container_port }} header { Strict-Transport-Security "max-age=15552000; includeSubDomains; preload" } } - -host_directory: nextcloud - -db_name: "nextcloud" -db_user: "postgres" -db_pass: "123456" -db_host: "db"