Run Tandoor as a rootless Podman Quadlet

Replaces the docker_container role with a Quadlet systemd user unit, and moves
state from /var/lib/tandoor to ~/podman-data/tandoor. Tandoor's entrypoint
starts as root and drops privileges, but root is what ends up owning the data,
so keep-id maps container root onto the connecting user and everything stays
owned by tanshu rather than a subuid.

The play does not run the rootless-Podman prerequisites: they are the shared
task file at caddy/roles/podman, and a host that has run caddy/playbook.yml has
them. Both integrations go through the shared includes rather than local copies
-- caddy/roles/register for the network and the Caddy drop-in, postgres/roles/
attach for the database -- so this app writes the same drop-in bytes as every
other service and a new service never means editing Caddy or Postgres.

Three ordering details are load-bearing:

- The register include runs in pre_tasks, before the unit is started. Quadlet
  fails unit *generation*, not merely startup, when a referenced .network file
  does not exist yet, so starting first reports "unit not found".
- roles/postgres flushes handlers immediately after attaching. The restart that
  joins Postgres to this network is a handler, so by default it runs at the end
  of the play -- after the app has already started and failed to resolve `db`.
- The role waits for a real HTTP response from inside the container instead of
  trusting systemctl is-active. The entrypoint runs Django migrations before
  gunicorn answers, and until it does the image's own nginx returns 502, which
  Caddy relays verbatim -- complete with `server: nginx`, which reads as a stray
  reverse proxy that is not actually there.

Also drops the dead nginx.conf.j2 and the network role, both superseded by the
shared includes, and moves .env into the role's templates/ as tandoor.env.j2,
rendered at mode 0600 and read through EnvironmentFile=.
This commit is contained in:
tanshu committed 2026-10-16 06:52:27 +05:30
1 parent 4297e5a71e
commit 4cd9308ae3
13 files changed
+654 -128

No files matched your search

-13
View File
@@ -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;
}
}
+46 -6
View File
@@ -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
+299 -33
View File
@@ -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.
+30 -6
View File
@@ -1,7 +1,31 @@
---
# Handlers for Nginx role
- name: Restart caddy container
docker_container:
name: "{{ caddy_container }}"
state: started
restart: true
# 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
+15 -15
View File
@@ -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
owner: "{{ podman_user }}"
group: "{{ podman_user }}"
mode: '0644'
notify: Reload Caddy
-11
View File
@@ -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
+17
View File
@@ -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
+26
View File
@@ -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
+16
View File
@@ -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
+115 -36
View File
@@ -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
@@ -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
@@ -5,4 +5,4 @@ POSTGRES_PORT=5432
POSTGRES_USER=postgres
POSTGRES_PASSWORD=123456
POSTGRES_DB=tandoor
ALLOWED_HOSTS={{ host }},localhost,127.0.0.1
ALLOWED_HOSTS={{ host }},localhost,127.0.0.1
+46 -7
View File
@@ -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_name>.network. caddy/roles/register pins the
# Podman network name to <network_name>_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 }}
}