Run the Sites as rootless Podman Quadlets, and build with podman

The fleet on emily is rootless Podman with Quadlet units under
~/.config/containers/systemd and state in ~/podman-data, all behind one shared
Caddy unit. This brings the menus in line instead of leaving them the last
Docker deployment. The build moves with them: podman reads a Containerfile as
readily as a Dockerfile, so the name now says which toolchain is real.

The app is one .container and one .network file. Two details carry it.
UserNS=keep-id maps the image's own node account onto the user that owns the
units, so data/ and site.env stay owned by tanshu: plain tar backups, no
subuids. provision-site-env.sh writes site.env with the same mapping rather
than --user, which under rootless Podman would have produced a subuid-owned
file the app could not read. And the Site registers its own network with Caddy
by writing a drop-in beside caddy.container, so Caddy never lists its upstreams
and never changes when a Site is added. The unit keeps the Docker-era container
name for the scripts and muscle memory that look it up, while the Caddyfile
proxies to a dotless NetworkAlias, because a container name with dots in it is
a liability in DNS.

The unit keeps podman's Pull=missing, so a restart or a reboot never depends on
the registry being reachable, while the play pulls explicitly at deploy time.
That is also where a missing `podman login` for the private registry now fails,
with the instruction, rather than as a unit that will not start.

copy-site-data.sh moves a Site's data across, and is not a plain rsync:
/var/lib/menu.* on monoco belongs to uid 1000 while the ssh user there is 1001,
so the read needs sudo; monoco cannot ssh to emily, so the workstation relays
the tar; and the extraction runs as tanshu on emily, which is what makes the
files land owned by the user keep-id maps the app onto. It proves the copy by
comparing a SHA-256 of the whole tree against the source and checking every
file's owner.

Not yet done: the copies themselves and the DNS move, which need a human.
This commit is contained in:
tanshu committed 2026-10-16 09:54:52 +05:30
1 parent c412181159
commit e68f8ab00d
23 files changed
+876 -389

No files matched your search

+30 -5
View File
@@ -1,6 +1,31 @@
---
- name: Reload Caddy
# Caddy validates before it applies, so a mistake here leaves the running
# config untouched and fails the play loudly.
command: 'docker exec -w /etc/caddy {{ caddy_container }} caddy reload'
listen: Reload Caddy
# Caddy is its own playbook (ansible-playbooks/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 roles/register when this Site'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 the Caddy playbook's
# readme, "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
+25 -7
View File
@@ -1,14 +1,29 @@
---
# Append this Site's block to the shared Caddyfile.
#
# Caddy is its own playbook (ansible-playbooks/caddy/playbook.yml) and a rootless
# Quadlet unit there, so this role does not manage the container — it only points
# it at the app. The Caddyfile is a bind mount into that container, so Caddy sees
# a written change as soon as the file lands; 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: Check the Caddyfile exists
stat:
ansible.builtin.stat:
path: '{{ caddyfile_path }}'
register: menu_caddyfile
- name: Fail when Caddy is not where we expect it
fail:
ansible.builtin.fail:
msg: >-
{{ caddyfile_path }} is missing. Deploy the caddy playbook, which owns the
Caddy container and its config, before this one.
{{ caddyfile_path }} is missing. Deploy the Caddy playbook
(ansible-playbooks/caddy/playbook.yml), which owns the Caddy unit and its
config, before this one.
when: not menu_caddyfile.stat.exists
# The Site's web address may still be claimed by an older snippet: the retired
@@ -16,7 +31,7 @@
# Caddy refuse the whole config, and a refused reload leaves the old proxy in
# place, which is a 502 once the old container is gone.
- name: Drop any older snippet claiming this web address
blockinfile:
ansible.builtin.blockinfile:
path: '{{ caddyfile_path }}'
marker: '# {mark} Ansible managed Caddy snippet for {{ host }}'
state: absent
@@ -25,9 +40,12 @@
# blockinfile replaces whatever sits between its markers, so redeploying updates
# the snippet in place rather than appending a second one.
- name: Point the Site's web address at the app
blockinfile:
ansible.builtin.blockinfile:
path: '{{ caddyfile_path }}'
marker: '# {mark} ANSIBLE MANAGED: menu app ({{ site }})'
block: '{{ caddy_snippet }}'
create: no
create: false
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0644'
notify: Reload Caddy