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

+42 -3
View File
@@ -2,19 +2,58 @@
# Defaults shared by every Site. Each Site's own file is named by the `var_file`
# in ansible/hosts, and its slug is its inventory name.
# ---------------------------------------------------------------------------
# Rootless Podman / Quadlet
# ---------------------------------------------------------------------------
# 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).
#
# These three duplicate caddy/vars/default.yml in the ansible-playbooks repo,
# which owns the Caddy unit these Sites sit behind. Keep them in sync.
podman_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
# Which Site config inside the image to run, and which web address it serves.
site: '{{ inventory_hostname }}'
# ---------------------------------------------------------------------------
# The app
# ---------------------------------------------------------------------------
image_name: registry.tanshu.com/menu-admin
image_tag: "{{ tag | default('latest') }}"
# The unit file is menu-<site>.container and the Podman network is
# menu-<site>_net. The container keeps the Docker-era name, dots and all, because
# scripts/provision-site-env.sh and anything an operator types look it up that
# way — but a name with dots is a DNS liability, so the Caddyfile proxies to the
# dotless NetworkAlias the unit sets beside it.
network_name: 'menu-{{ site }}'
unit_name: '{{ network_name }}.service'
container_name: 'menu-app.{{ host }}'
# The image's own account (`node`), which keep-id maps onto podman_user.
app_uid: 1000
app_gid: 1000
caddy_container: caddy
caddyfile_path: /var/lib/caddy/conf/Caddyfile
container_port: 3000
# podman builds an OCI image by default and drops the Containerfile's
# HEALTHCHECK instruction, so the unit declares the same check and the play gates
# on it. node is in the image and the sh -c podman wraps it in handles the quotes.
health_cmd: >-
node -e "fetch('http://127.0.0.1:{{ container_port }}/healthz').then(r =>
process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
# ---------------------------------------------------------------------------
# The shared Caddy unit and its Caddyfile, both owned by the caddy playbook in
# ansible-playbooks (caddy/playbook.yml).
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: '{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile'
caddy_snippet: |
{{ host }} {
reverse_proxy {{ docker_container }}:3000
reverse_proxy {{ network_name }}:{{ container_port }}
}
+7 -9
View File
@@ -1,13 +1,11 @@
---
# The Great Bear. Its name, tagline, icon, categories and menus live on the server under
# app_root and are edited in the admin; config/greatbear.site.json only seeds the
# theme and the first category list.
# The Great Bear. Its name, tagline, icon, categories and menus live on the
# server under app_root and are edited in the admin; config/greatbear.site.json
# only seeds the theme and the first category list.
title: greatbear.in
host: 'menu.{{ title }}'
docker_container: 'menu-app.{{ title }}'
docker_network: '{{ docker_container }}_net'
# Everything the app writes — data/ and the untracked site.env. Deliberately the
# directory the old static menus were served from.
app_root: '/var/lib/{{ host }}'
# Everything the app writes — data/ (the content) and site.env (the admin
# password hash and session secret). One directory per Site, each of which is
# also its own Podman network and its own Caddy upstream.
app_root: '{{ podman_data_dir }}/{{ network_name }}'
+7 -9
View File
@@ -1,13 +1,11 @@
---
# Hops n Grains Mohali. Its name, tagline, icon, categories and menus live on the server under
# app_root and are edited in the admin; config/hngmohali.site.json only seeds the
# theme and the first category list.
# Hops n Grains Mohali. Its name, tagline, icon, categories and menus live on the
# server under app_root and are edited in the admin; config/hngmohali.site.json
# only seeds the theme and the first category list.
title: hngmohali.com
host: 'menu.{{ title }}'
docker_container: 'menu-app.{{ title }}'
docker_network: '{{ docker_container }}_net'
# Everything the app writes — data/ and the untracked site.env. Deliberately the
# directory the old static menus were served from.
app_root: '/var/lib/{{ host }}'
# Everything the app writes — data/ (the content) and site.env (the admin
# password hash and session secret). One directory per Site, each of which is
# also its own Podman network and its own Caddy upstream.
app_root: '{{ podman_data_dir }}/{{ network_name }}'
+7 -13
View File
@@ -1,7 +1,7 @@
---
# Hops n Grains. Its name, tagline, icon, categories and menus live on the
# server under app_root and are edited in the admin; config/hopsngrains.site.json
# only seeds the theme. This file says where the Site runs and what it answers.
# Hops n Grains. Its name, tagline, icon, categories and menus live on the server
# under app_root and are edited in the admin; config/hopsngrains.site.json only
# seeds the theme. This file says where the Site runs and what it answers.
#
# Every value that interpolates a template is quoted: YAML reads a leading `{`
# as a flow mapping, which is the parse error Ansible reports as "missing quotes
@@ -9,13 +9,7 @@
title: hopsngrains.com
host: 'menu.{{ title }}'
docker_container: 'menu-app.{{ title }}'
docker_network: '{{ docker_container }}_net'
# Everything the app writes — data/ and the untracked site.env.
#
# Deliberately the directory the old static menus were served from, so the app
# inherits the location rather than a second one. The app only ever reads and
# writes its own data/ inside it; the old menu files can be emptied out when
# they are no longer wanted, and the directory itself must stay.
app_root: '/var/lib/{{ host }}'
# Everything the app writes — data/ (the content) and site.env (the admin
# password hash and session secret). One directory per Site, each of which is
# also its own Podman network and its own Caddy upstream.
app_root: '{{ podman_data_dir }}/{{ network_name }}'