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:
1 parent
4297e5a71e
commit
4cd9308ae3
13 files changed
+654
-128
No files matched your search
@@ -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
@@ -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
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 }}
|
||||
}
|
||||
Reference in new issue
Block a user