diff --git a/tandoor/files/nginx.conf.j2 b/tandoor/files/nginx.conf.j2 deleted file mode 100644 index 2e6be92..0000000 --- a/tandoor/files/nginx.conf.j2 +++ /dev/null @@ -1,13 +0,0 @@ -server { - - listen 80; - server_name {{ http_host }}; - - location / { - proxy_pass http://localhost:8880; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - } -} diff --git a/tandoor/playbook.yml b/tandoor/playbook.yml index 9ab2848..d3d9227 100755 --- a/tandoor/playbook.yml +++ b/tandoor/playbook.yml @@ -1,13 +1,53 @@ -################################################# -# DO Community Playbooks: Docker -################################################# --- -- hosts: monoco - become: true +# Tandoor Recipes as a rootless Podman Quadlet unit, behind the shared Caddy +# unit (see caddy/playbook.yml) and the shared Postgres unit +# (postgres/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. (Applying the commented include above would also need +# podman_unprivileged_port_start, which lives in caddy/vars/default.yml.) +# +# Run with: ansible-playbook -u tanshu tandoor/playbook.yml +- name: Deploy Tandoor 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 the Postgres + # attach below needs the network unit to exist as well. + # + # network_name is a play var, so the include can see it directly. + - name: Register Tandoor's network with Caddy + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/register/tasks/main.yaml" + roles: - - network + # Attaches this app's network to the shared Postgres unit, which is what + # makes POSTGRES_HOST=db 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 app: data directories, .env, the Quadlet unit, start + verify. - tandoor + # Appends this site's block to the shared Caddyfile and reloads Caddy. - caddy diff --git a/tandoor/readme.md b/tandoor/readme.md index 2be90cb..f3f19d6 100644 --- a/tandoor/readme.md +++ b/tandoor/readme.md @@ -1,45 +1,311 @@ -# Gitea on Ubuntu +# Tandoor (rootless Podman + Quadlet) -This playbook will install Gitea on an Ubuntu 19.10 machine. -A number of containers will be created with the options specified in the `vars/default.yml` variable file. +Runs [Tandoor Recipes](https://tandoor.dev/) 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). Replaces the previous +`docker_container` deployment on the old host. + +The app's own uid is **0** — Tandoor's entrypoint starts as root and drops +privileges, but root is what owns the data directories (see +[docs/podman-compat-audit.md §3.2](../docs/podman-compat-audit.md)). The unit +therefore maps container root to the connecting user with +`UserNS=keep-id:uid=0,gid=0`, so `~/podman-data/tandoor` stays owned by +`tanshu` and no subuid-owned files appear to break backups. + +## Ordering + +Both shared services are already migrated and running on `emily`, so 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 `tandoor` database is already restored** + (16 MB, table counts verified identical); see + [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). +3. This playbook. + +The only thing left to move is the app's **file** data — see +[Migrating the file data](#migrating-the-file-data). The database is done. + +Tandoor's environment file sets `POSTGRES_HOST=db`. Until this playbook has +attached `tandoor_net` to Postgres, nothing resolves as `db` on that network, so +running this playbook is what makes the app reachable — there is no separate +step. + +## Layout + +``` +tandoor/ +├── playbook.yml +├── vars/default.yml +└── roles/ + ├── postgres/ # attaches this app's network to Postgres (+ its handler) + ├── tandoor/ # data dirs, .env, the Quadlet unit, start + verify + └── caddy/ # appends this site's block to the shared Caddyfile +``` + +Quadlet units and state: + +| On the host | What | +|---|---| +| `~/.config/containers/systemd/tandoor.container` | the generated unit (Ansible-templated) | +| `~/.config/containers/systemd/tandoor.network` | the app network, written by `caddy/roles/register` | +| `~/.config/containers/systemd/caddy.container.d/50-tandoor.conf` | attaches Caddy to that network | +| `~/.config/containers/systemd/postgres.container.d/50-tandoor.conf` | attaches Postgres to it | +| `~/.config/containers/systemd/postgres.container.d/10-network-aliases.conf` | the shared `db` alias | +| `~/podman-data/tandoor/` | `mediafiles/`, `staticfiles/`, `externalfiles/`, `.env` | + +## 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 Tandoor'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 +`tandoor.container` references `tandoor.network`: Quadlet fails *unit +generation* — so `systemctl --user start` reports "unit not found" — when a +referenced `.network` file does not exist yet. The Postgres attach in the next +section depends on it for the same reason. + +`network_name` is `tandoor`, so the *file* is `tandoor.network` and the *Podman +network* is `tandoor_net` (`NetworkName=` in the network unit pins it, keeping +the Docker-era name). + +## How it attaches to Postgres + +Same idea at the other end: 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 those drop-ins itself. That writes two files, both mergeable into +`postgres.container` by Quadlet: + +| File | Content | +|---|---| +| `postgres.container.d/50-tandoor.conf` | `Network=tandoor.network` | +| `postgres.container.d/10-network-aliases.conf` | `NetworkAlias=db` | + +`db` is the alias Tandoor's `.env` actually asks for; `postgres` also resolves, +from `ContainerName=`. The alias lives in one shared file with a fixed value +because `NetworkAlias=` applies to *every* network a container joins — every app +in this repo reaches Postgres as `db`, and a per-service alias file would make +Quadlet pass `--network-alias` once per service. + +[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 both drop-ins + are new, so Postgres has to be *recreated* to join `tandoor_net` — and that + restart is a handler, which by default runs at the end of the play, after the + `tandoor` role has already started the app. The app would come up before `db` + resolves, fail its own `is-active` check, and fail the play on a machine where + nothing is wrong. Flushing applies the drop-in first; on later runs nothing is + pending and it is a no-op. + +Note the cost stated plainly 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. Quadlet has no live equivalent of +`podman network connect`. ## Settings -- `create_containers`: number of containers to create. -- `default_container_name`: default name for new containers. -- `default_container_image`: default image for new containers. -- `default_container_command`: default command to run on new containers. +| 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` | `recipes.tanshu.com` — the site block Caddy serves | +| `container_name` | `tandoor`, and the Quadlet unit / container name | +| `image` | `docker.io/vabene1111/recipes:latest` — fully qualified on purpose | +| `network_name` | `tandoor` → `tandoor.network` / `tandoor_net` | +| `container_port` | `80`, the port inside the container that Caddy proxies to | +| `host_directory` | `~/podman-data/tandoor` | +| `postgres_container` / `postgres_unit` | The shared database unit this app attaches to | +| `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 — Tandoor publishes nothing host-side. -## Running this Playbook +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`. -Quick Steps: +The app's secrets (`SECRET_KEY`) and database coordinates live in +`roles/tandoor/templates/tandoor.env.j2`, rendered to +`~/podman-data/tandoor/.env` at mode `0600` and read by systemd via +`EnvironmentFile=`, not by Ansible. The password is the fleet's shared `123456`, +which matches `pg_password` in [postgres/vars/default.yml](../postgres/vars/default.yml) +— a migrated app connects unchanged. Both are committed in plaintext as before; +flagged as a follow-up, not changed here. -### 1. Obtain the playbook -```shell -git clone https://github.com/do-community/ansible-playbooks.git -cd ansible-playbooks/docker_ubuntu1804 -``` - -### 2. Customize Options - -```shell -nano vars/default.yml -``` - -```yml -#vars/default.yml ---- -create_containers: 4 -default_container_name: docker -default_container_image: ubuntu -default_container_command: sleep 1d -``` - -### 3. Run the Playbook +## Running ```command -ansible-playbook -l [target] -i [inventory file] -u [remote user] playbook.yml +ansible-playbook -u tanshu tandoor/playbook.yml ``` -For more information on how to run this Ansible setup, please check this guide: [How to Use Ansible to Install and Set Up Docker on Ubuntu 18.04](https://www.digitalocean.com/community/tutorials/how-to-use-ansible-to-install-and-set-up-docker-on-ubuntu-18-04). +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** Tandoor's own units and data. Its `pre_tasks` run +Caddy's `register` include and nothing else. 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. +To apply them on their own: + +```yaml +- name: Rootless Podman prerequisites + ansible.builtin.include_tasks: "{{ playbook_dir }}/../caddy/roles/podman/tasks/main.yaml" +``` + +`systemctl --user enable tandoor` is neither needed nor possible: the unit is +*generated*, and Quadlet applies `[Install] WantedBy=default.target` itself. + +## Migrating the file data + +The database already moved; this is the app's own files, and the playbook does +not move them because they are on the old host while the playbook runs on the +new one. Run this from the machine that holds the `monoco` and `emily` SSH +aliases, as in [postgres/migrate-from-monoco.md](../postgres/migrate-from-monoco.md). + +`mediafiles/` is the only irreplaceable part, and on this host it is **900K**; +the 98 MB is `staticfiles/`, which is `collectstatic` output. Copy both anyway — +the bind mount shadows whatever static files the image ships, so if the +entrypoint does not re-run `collectstatic`, an empty volume is a site with no +CSS, and copying stale files is the strictly safer failure mode. + +```bash +# 1. Look before you copy: size, and whether it is readable without sudo. +ssh monoco 'du -sh /var/lib/tandoor/*; ls -ln /var/lib/tandoor' +ssh monoco 'find /var/lib/tandoor -type f ! -readable' # empty = no sudo needed + +# 2. Stop the old container so nothing writes while you copy. Leave it stopped: +# its database is on emily now, and two live copies would diverge. +ssh monoco 'docker stop tandoor' + +# 3. Create the destination (its parent exists from the Postgres move). +ssh emily 'mkdir -p ~/podman-data/tandoor' + +# 4. Copy. tar streams it across the workstation; rsync cannot copy +# remote-to-remote in one command. `.env` is excluded — Ansible templates it, +# at mode 0600, before the unit ever starts. +# +# `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. +set -o pipefail +ssh monoco 'tar --exclude=./.env -C /var/lib/tandoor -cf - .' \ + | ssh emily 'tar --no-same-owner -C ~/podman-data/tandoor -xf -' + +# If step 1 listed unreadable files, read them as root instead: +# ssh monoco 'sudo tar --exclude=./.env -C /var/lib/tandoor -cf - .' \ +# | ssh emily 'tar --no-same-owner -C ~/podman-data/tandoor -xf -' + +# 5. Verify by content, and over the *whole* tree. Checking mediafiles alone +# would pass on a copy that dropped almost everything: it is under 1 MB here +# while staticfiles is the 98 MB. +ssh monoco 'cd /var/lib/tandoor && find . -type f ! -name .env -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +ssh emily 'cd ~/podman-data/tandoor && find . -type f -print0 | sort -z | xargs -0 -r sha256sum | sha256sum' +# identical digests = a byte-faithful copy + +# 6. Nothing may end up owned by root or by a subuid. +ssh emily 'find ~/podman-data/tandoor ! -user tanshu' # empty = correct + +# 7. Deploy. +cd prepare-server && ansible-playbook -u tanshu ../tandoor/playbook.yml +``` + +Ownership needs no `chown` pass: the app runs as uid 0 in-container, which +`keep-id` maps to `tanshu`, so files extracted as `tanshu` are already right. +That is also why `--no-same-owner` is explicit on the receiving side — the +source is root-owned, and restoring that ownership would be wrong here. + +If the link is slow or drops, do it in two hops instead: resumable, and it shows +progress. + +```bash +rsync -aP --exclude=.env monoco:/var/lib/tandoor/ ~/tandoor-copy/ +rsync -aP ~/tandoor-copy/ emily:~/podman-data/tandoor/ +``` + +Rolling back means rolling back both halves: `ssh monoco 'docker start tandoor'` +is only meaningful while monoco's own Postgres 17 cluster is still intact. + +## Verification + +```bash +systemctl --user status tandoor +journalctl --user -u tandoor -f +podman logs tandoor + +# What the playbook checks for you: +podman exec tandoor getent hosts db # the alias the .env asks for +podman exec tandoor getent hosts postgres + +# The database really is the migrated one: +podman exec -e PGPASSWORD=123456 postgres psql -U postgres -d tandoor -c '\dt' | head + +# The site, end to end: +curl -sSI https://recipes.tanshu.com +``` + +Also exercise a **write** through the app (upload a recipe image) and confirm on +the host that the new file under `~/podman-data/tandoor/mediafiles` is owned by +`tanshu`. That is the check that catches a wrong `keep-id`; a read-only smoke +test will not. + +Then reboot the host and confirm the unit returns unattended — without +`loginctl enable-linger tanshu` it will not, and the failure is silent. + +## Notes and gotchas + +- **A 502 whose page says `nginx` is Tandoor's own front end, not a stray host + nginx.** The image runs nginx in front of gunicorn (80 → 8080, which is why + `podman ps` shows both ports), and Caddy relays upstream response headers + verbatim — so `server: nginx` reaches the browser from inside the container. + During the startup window, while Django is still running migrations, that + nginx answers 502 and Caddy passes it straight through. The role therefore + waits for a real HTTP response from inside the container + (`Wait for Tandoor to serve HTTP`) rather than trusting `is-active`; a 502 + after a *green* run means the app, not the proxy. +- **Client IPs.** Caddy's access log may show a gateway address rather than the + real client, depending on the rootless port forwarder. See + [caddy/readme.md](../caddy/readme.md), "Client IPs", before relying on it. +- **Attaching restarts Postgres for everyone.** See the note above; it is a few + seconds of downtime for every attached app. +- **Registering restarts all of Caddy.** Same mechanism at the proxy end — a + brief restart of every hosted site, not just this one. +- **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. +- **The backup scripts still assume Docker.** `~/bin/tandoor-backup.sh` uses + `--link`, which Podman does not have; it needs the `podman exec` form from + [postgres/readme.md](../postgres/readme.md), "Backups", before the old host is + decommissioned. +- **DNS.** `recipes.tanshu.com` must resolve to `emily` before Caddy can obtain + a certificate for it. +- **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. +- Tandoor needs no cache: its env sets no `REDIS_URL`, so `valkey` is not + attached to this network. diff --git a/tandoor/roles/caddy/handlers/main.yaml b/tandoor/roles/caddy/handlers/main.yaml index c3a3720..5fe87df 100644 --- a/tandoor/roles/caddy/handlers/main.yaml +++ b/tandoor/roles/caddy/handlers/main.yaml @@ -1,7 +1,31 @@ --- -# Handlers for Nginx role -- name: Restart caddy container - docker_container: - name: "{{ caddy_container }}" - state: started - restart: true \ No newline at end of file +# 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: >- + 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/tandoor/roles/caddy/tasks/main.yaml b/tandoor/roles/caddy/tasks/main.yaml index c3ccc90..ffbe0d8 100644 --- a/tandoor/roles/caddy/tasks/main.yaml +++ b/tandoor/roles/caddy/tasks/main.yaml @@ -1,22 +1,22 @@ --- -- name: Read current Caddyfile - ansible.builtin.slurp: - path: "{{ caddyfile_path }}" - register: caddyfile_raw +# 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. -- 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 +- 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: Restart caddy container \ No newline at end of file + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/tandoor/roles/network/tasks/main.yaml b/tandoor/roles/network/tasks/main.yaml deleted file mode 100644 index 4d8ce98..0000000 --- a/tandoor/roles/network/tasks/main.yaml +++ /dev/null @@ -1,11 +0,0 @@ ---- -- name: Ensure tandoor, caddy, postgres are on Docker network - docker_network: - name: "{{ docker_network }}" - state: present - connected: - - name: "{{ docker_container }}" - - name: "{{ postgres_container }}" - alias: db - - name: "{{ caddy_container }}" - appends: yes diff --git a/tandoor/roles/postgres/handlers/main.yaml b/tandoor/roles/postgres/handlers/main.yaml new file mode 100644 index 0000000..31a5d44 --- /dev/null +++ b/tandoor/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/tandoor/roles/postgres/tasks/main.yaml b/tandoor/roles/postgres/tasks/main.yaml new file mode 100644 index 0000000..d0b2943 --- /dev/null +++ b/tandoor/roles/postgres/tasks/main.yaml @@ -0,0 +1,26 @@ +--- +# 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-tandoor.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 tandoor.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 tandoor.network. + +- name: Attach Tandoor 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 tandoor_net — and that restart is a *handler*, which by default runs at +# the end of the play, after the tandoor role has already started the app. The +# app would then come up before `db` resolves, fail its own is-active check, and +# fail the play on a machine where nothing is actually wrong. +# +# Flushing applies the new drop-in before the app starts. On later runs the +# drop-ins are unchanged, nothing is pending, and this is a no-op. +- name: Apply the Postgres network change before starting Tandoor + ansible.builtin.meta: flush_handlers diff --git a/tandoor/roles/tandoor/handlers/main.yaml b/tandoor/roles/tandoor/handlers/main.yaml new file mode 100644 index 0000000..ffe3924 --- /dev/null +++ b/tandoor/roles/tandoor/handlers/main.yaml @@ -0,0 +1,16 @@ +--- +# 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 + +- name: Restart Tandoor + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Tandoor diff --git a/tandoor/roles/tandoor/tasks/main.yaml b/tandoor/roles/tandoor/tasks/main.yaml index 14b02b0..130c8af 100644 --- a/tandoor/roles/tandoor/tasks/main.yaml +++ b/tandoor/roles/tandoor/tasks/main.yaml @@ -1,42 +1,121 @@ --- -# Tasks for a Tandoor.dev role -- name: Pull image - docker_image: - name: "{{ docker_image }}" - source: pull - state: present - force_source: yes +# Tandoor Recipes 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 Host Directory exists - file: - path: "/var/lib/{{ host_directory }}" - state: directory - mode: 0755 +- name: Ensure the Tandoor data directories exist + ansible.builtin.file: + path: "{{ host_directory }}/{{ item }}" + state: directory + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' + loop: + # mediafiles is the irreplaceable one; the other two are regenerated by the + # app and are bind-mounted anyway because the image expects them to exist. + - mediafiles + - staticfiles + - externalfiles -- name: Upload the .env file - template: - src: "files/.env" - dest: "/var/lib/{{ host_directory }}/.env" +# The app's secrets and database 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: it holds SECRET_KEY and the +# postgres password. +- name: Write the environment file + ansible.builtin.template: + src: tandoor.env.j2 + dest: "{{ host_directory }}/.env" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0600' + notify: Restart Tandoor +- name: Install the Tandoor Quadlet unit + ansible.builtin.template: + src: tandoor.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Tandoor -# - name: Ensure Docker network exists -# docker_network: -# name: "{{ docker_network }}" -# connected: -# - name: "{{ postgres_container }}" -# alias: db -# - name: "{{ caddy_container }}" +- name: Start Tandoor + block: + - name: Start Tandoor + 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, a bad volume). Pull the real reason out. + - name: Collect Tandoor 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: tandoor_diag + changed_when: false + failed_when: false -- name: Create container - docker_container: - name: "{{ docker_container }}" - image: "{{ docker_image }}" - state: started - restart_policy: "unless-stopped" - env_file: "/var/lib/{{ host_directory }}/.env" - volumes: - - "/var/lib/{{ host_directory }}/staticfiles:/opt/recipes/staticfiles" - - "/var/lib/{{ host_directory }}/mediafiles:/opt/recipes/mediafiles" - - "/var/lib/{{ host_directory }}/externalfiles:/opt/recipes/externalfiles" - networks: - - name: "{{ docker_network }}" + - name: Show Tandoor diagnostics + ansible.builtin.debug: + msg: "{{ tandoor_diag.results | map(attribute='stdout') | list }}" + + - name: Fail + ansible.builtin.fail: + msg: "Tandoor 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 Tandoor is running + ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" + register: tandoor_active + changed_when: false + failed_when: tandoor_active.stdout != 'active' + +# `db` is the alias the environment file actually asks for; the container name +# is checked too because it is what the Quadlet network gives us for free. +- name: Confirm Tandoor can resolve its database + ansible.builtin.command: >- + podman exec {{ container_name }} getent hosts {{ item }} + loop: + - "{{ postgres_container }}" + - db + register: tandoor_db_lookup + changed_when: false + failed_when: false + +- name: Report database resolution + ansible.builtin.debug: + msg: >- + {{ item.item }} resolves as + {{ item.stdout | default('(no answer)') }} + loop: "{{ tandoor_db_lookup.results }}" + +# `is-active` says the container is running; it does not say the app can serve. +# Tandoor's entrypoint runs Django migrations before gunicorn answers, and until +# it does the image's own nginx returns 502 to Caddy — which Caddy relays as-is, +# complete with `server: nginx`. That is exactly the confusing "nginx 502" a +# first deploy shows after a play that reported success. +# +# python3 is used rather than curl or wget because this is a Django image: the +# interpreter is guaranteed present, the others are not. A 502 raises HTTPError, +# so the retry loop holds until a real page comes back. +- name: Wait for Tandoor to serve HTTP + ansible.builtin.command: >- + podman exec {{ container_name }} + python3 -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1/', timeout=5)" + register: tandoor_http + until: tandoor_http.rc == 0 + retries: 24 + delay: 5 + changed_when: false + failed_when: tandoor_http.rc != 0 diff --git a/tandoor/roles/tandoor/templates/tandoor.container.j2 b/tandoor/roles/tandoor/templates/tandoor.container.j2 new file mode 100644 index 0000000..bf42b13 --- /dev/null +++ b/tandoor/roles/tandoor/templates/tandoor.container.j2 @@ -0,0 +1,43 @@ +# Tandoor Recipes — rootless Podman Quadlet unit. +# Managed by Ansible (tandoor/roles/tandoor). 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, which +# is what makes POSTGRES_HOST=db in the environment file resolve. + +[Unit] +Description=Tandoor Recipes +Documentation=https://tandoor.dev/ +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ container_name }} +Image={{ image }} + +# Tandoor's entrypoint starts as root and drops privileges, but the uid that +# actually owns the data is root's (see docs/podman-compat-audit.md §3.2). +# Mapping container root onto {{ podman_user }} keeps {{ host_directory }} +# readable and writable by both the container and the host user, and leaves no +# subuid-owned files to trip over backups. +UserNS=keep-id:uid=0,gid=0 + +# SECRET_KEY and the postgres coordinates live here. Read by systemd, not by +# Ansible, so the file has to exist before this unit starts. +EnvironmentFile={{ host_directory }}/.env + +Network={{ network_name }}.network + +Volume={{ host_directory }}/staticfiles:/opt/recipes/staticfiles +Volume={{ host_directory }}/mediafiles:/opt/recipes/mediafiles +Volume={{ host_directory }}/externalfiles:/opt/recipes/externalfiles + +[Service] +Restart=always +# The entrypoint runs Django migrations on every start, so allow generously for +# a large database before systemd gives up. +TimeoutStartSec=600 + +[Install] +WantedBy=default.target diff --git a/tandoor/files/.env b/tandoor/roles/tandoor/templates/tandoor.env.j2 similarity index 82% rename from tandoor/files/.env rename to tandoor/roles/tandoor/templates/tandoor.env.j2 index 87db883..1b827c0 100644 --- a/tandoor/files/.env +++ b/tandoor/roles/tandoor/templates/tandoor.env.j2 @@ -5,4 +5,4 @@ POSTGRES_PORT=5432 POSTGRES_USER=postgres POSTGRES_PASSWORD=123456 POSTGRES_DB=tandoor -ALLOWED_HOSTS={{ host }},localhost,127.0.0.1 \ No newline at end of file +ALLOWED_HOSTS={{ host }},localhost,127.0.0.1 diff --git a/tandoor/vars/default.yml b/tandoor/vars/default.yml index a51c438..1365bbd 100644 --- a/tandoor/vars/default.yml +++ b/tandoor/vars/default.yml @@ -1,15 +1,54 @@ --- +# --------------------------------------------------------------------------- +# 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: Tandoor 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" + +# --------------------------------------------------------------------------- +# Tandoor +# --------------------------------------------------------------------------- host: recipes.tanshu.com -docker_image: vabene1111/recipes:latest -docker_container: tandoor -docker_network: "{{ docker_container }}_net" -host_directory: tandoor +container_name: tandoor +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. +image: docker.io/vabene1111/recipes:latest + +# The Quadlet file is .network. caddy/roles/register pins the +# Podman network name to _net (so: tandoor.network, tandoor_net). +network_name: "{{ container_name }}" + +host_directory: "{{ podman_data_dir }}/{{ container_name }}" + +# Port Caddy proxies to, inside the container (the image's own nginx). +container_port: 80 + +# 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 -caddy_container: caddy -caddyfile_path: /var/lib/caddy/conf/Caddyfile +postgres_unit: "{{ postgres_container }}.service" + +# 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" + snippet_block: | {{ host }} { - reverse_proxy {{ docker_container }}:80 + reverse_proxy {{ container_name }}:{{ container_port }} }