Match the deploy system to hops and greatbear

menu-admin was the odd one out: it built from the working directory with a date
tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era
container name. The other two apps build from `git archive HEAD` and tag with the
commit, so what runs on the host is exactly what git knows about; build only for
the architecture emily actually runs; and use one slug for the container, its
network and its data directory.

The build is now their three lines: `podman build --platform linux/arm64` from
`git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push`
rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty
tracked tree or a HEAD that is not origin/main — which is what makes the commit
tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same
`-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays,
but behind `--checks`, so a plain deploy behaves as theirs does.

The registry credential moves into vars/default.yml and the role logs in, pulls
and logs out around it, as their roles do. The image is only pulled at deploy
time, so the unit keeps podman's default `Pull=missing` and a reboot never needs
the registry. This is the same plaintext credential the other two repos carry.

One real bug goes with it: the healthcheck gate ran before the handler that
restarts a changed unit, so a run that only changed the unit checked the stale
container. `meta: flush_handlers` before the gate is the fix the sibling repos
already made.

The container, network and data directory are all menu-<site> now. The dotted
Docker-era name and the NetworkAlias it needed are gone, so `podman logs
menu-<site>` and `systemctl --user status menu-<site>` are the same two commands
across all three apps. The Dockerfile keeps its name for the same reason: podman
reads it as readily as a Containerfile, and the siblings use that name too.
This commit is contained in:
tanshu committed 2026-10-16 11:10:59 +05:30
1 parent e68f8ab00d
commit 537a4f1e46
21 files changed
+407 -307

No files matched your search

+14 -13
View File
@@ -1,23 +1,24 @@
---
# Deploys the menu app for one or more Sites as rootless Podman Quadlet units on
# emily, behind the shared Caddy unit that ansible-playbooks/caddy owns. It lives
# here, beside the app it deploys, and is run from this directory:
#
# ./deploy.sh <tag> (repo root)
# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=<tag>
# emily, behind the fleet's shared Caddy unit (caddy/playbook.yml in the
# ansible-playbooks repo). It lives here, beside the app it deploys.
#
# 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.
# `systemctl --user` and `podman exec` work without any environment juggling.
# There is deliberately no `become`: root-only prerequisites (Podman,
# `loginctl enable-linger`, the low-port sysctl Caddy needs) belong to
# caddy/playbook.yml, which must have run on this host first.
#
# gather_facts is required: podman_user and the home directory come from the
# user_id / user_dir facts.
# gather_facts is required: podman_user, podman_data_dir and quadlet_dir are
# derived 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
# ansible-playbooks/caddy/roles/podman/tasks/main.yaml, and a host that has
# already run ansible-playbooks/caddy/playbook.yml has them.
# The inventory is the one difference from the sibling apps, which deploy a
# single app: this play carries three Sites and a var_file per Site.
#
# ./deploy.sh (repo root)
# SITE=greatbear ./deploy.sh one Site
# cd ansible && ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=<commit>
- name: Deploy the menu app as rootless Podman Quadlets
hosts: menus
gather_facts: true
+34 -32
View File
@@ -4,8 +4,8 @@ Lives here, beside the app it deploys. One run deploys a Site: it starts the
app's container as a **rootless Podman Quadlet unit** on `emily` and points the
Site's web address at it through the shared **Caddy** unit that
`ansible-playbooks/caddy` owns. There is no older system to keep serving, so
there is no staged rollout and nothing to retire — deploying an earlier tag is
the rollback.
there is no staged rollout and nothing to retire — deploying the image built from
an earlier commit is the rollback.
This is the same shape as the sibling services in `ansible-playbooks` (gitea,
nextcloud, tandoor, bitwarden, valkey, postgres, trillium): rootless Podman, one
@@ -49,15 +49,15 @@ the same mapping for the same reason.
| Theme colours | `config/<site>.site.json` in the repo, baked into the image |
| The unit that runs | `~/.config/containers/systemd/menu-<site>.container` |
| The network and Caddy drop-in | `menu-<site>.network`, `caddy.container.d/50-menu-<site>.conf` |
| Which image tag runs | `-e tag=<tag>` |
| Which image tag runs | `-e image_tag=<commit>` |
| Which Site is deployed | the inventory entry in `hosts` |
| The web address it serves | `host:` in `vars/<site>.yml` |
`<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`). The
container itself keeps the Docker-era name `menu-app.<domain>`, because
`provision-site-env.sh` and muscle memory look it up that way; the Caddyfile
proxies to the dotless `menu-<site>` alias, because a container name with dots in
it is a liability in DNS.
`<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`), and it
is the single slug for everything: the container, its Podman network and its data
directory are all `menu-<site>`. That is how the sibling `hops` and `greatbear`
apps are deployed, and it keeps `podman logs menu-<site>` and
`systemctl --user status menu-<site>` the same two commands across all three.
## What the host needs
@@ -69,17 +69,11 @@ Those prerequisites deliberately are **not** duplicated here, so a host that has
not run that playbook will fail at the first `systemctl --user` — see
`ansible-playbooks/caddy/readme.md`.
The image is in the private registry, so log in once as `tanshu` on `emily`,
before the first deploy:
```sh
podman login registry.tanshu.com
```
The play pulls the image itself, so a missing login fails there with that
instruction rather than as a container that will not start. The unit keeps
podman's default `Pull=missing` on purpose, so a restart or a reboot never
depends on the registry being reachable.
The registry is private, but nothing here needs a manual login: the play logs in
from `registry_username` / `registry_password` in `vars/default.yml`, pulls the
image, and logs out again, so the credential is never left in the target user's
auth file. The unit keeps podman's default `Pull=missing` on purpose, so a
restart or a reboot never depends on the registry being reachable.
`ssh` on some machines complains about
`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that is
@@ -91,21 +85,27 @@ a local file-permission bug whose fix is `sudo chown -R root:root
From the repo root:
```sh
./deploy.sh 2026-09-22 # checks, podman build+push, then deploys
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
./deploy.sh # push HEAD, build <commit>, deploy every Site
./deploy.sh --no-push # skip the push (you already pushed)
./deploy.sh --force # skip the guards
./deploy.sh --checks # lint, format, typecheck and test first
SITE=greatbear ./deploy.sh # one Site instead of every Site
```
`deploy.sh` runs `make build-production TAG=<tag>`, which builds a multi-arch
podman manifest (`linux/amd64,linux/arm64`) and pushes `latest` and the tag to
`registry.tanshu.com/menu-admin`. Then it runs this playbook against every Site,
or the one named by `SITE`.
`deploy.sh` requires a clean tracked tree and `HEAD == origin/main`, tags the
build with the commit it came from, and refuses a dirty tree for a reason: the
image is built from `git archive HEAD`, so what runs on the host is exactly what
git knows about. It runs `make build-production COMMIT=<commit>`, which builds
`linux/arm64` — the architecture `emily` runs — and pushes both the commit tag
and `latest` to `registry.tanshu.com/menu-admin`.
By hand:
```sh
make build-production TAG=2026-09-22
git push
make build-production COMMIT=$(git rev-parse --short HEAD)
cd ansible
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=2026-09-22
ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=$(git rev-parse --short HEAD)
```
Every Site runs on the same box, so `hosts` has one entry per Site pointing at
@@ -115,8 +115,8 @@ what gives us a real PAM session, which is what makes `systemctl --user` work.
Connecting as anyone else would write units into the wrong home.
Adding a Site is one line in `hosts`, a copy of `vars/hopsngrains.yml`, and a
`config/<site>.site.json`. The image is shared, so the config has to be in it: a
new Site needs a new tag.
`config/<site>.site.json`. The image is shared and carries every Site's config,
so a new Site needs a new build — which the commit tag gives it.
## Migrating a Site's data from `monoco`
@@ -205,13 +205,15 @@ something.
## Rolling back
Deploy the previous tag:
Deploy the image built from an earlier commit — the commit tag is what makes
this a one-liner, since every commit ever pushed to the registry is still there:
```sh
./deploy.sh <previous-tag>
cd ansible
ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=<older-commit>
```
`podman logs menu-app.<site>` and `journalctl --user -u menu-<site>` are where a
`podman logs menu-<site>` and `journalctl --user -u menu-<site>` are where a
container that will not start says why. To go all the way back to the Docker
deployment, start the container that was left on `monoco` and point Caddy at it
again.
+11 -36
View File
@@ -1,50 +1,25 @@
---
# 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.
# 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
# when the Caddyfile does not exist yet, on a host where caddy/playbook.yml has
# not run.
#
# The marker is the same one the retired nginx-menu playbook wrote per hostname,
# so a Site cut over from it has its block replaced in place rather than a second
# block for one address appended, which would make Caddy refuse the whole config.
- name: Check the Caddyfile exists
ansible.builtin.stat:
path: '{{ caddyfile_path }}'
register: menu_caddyfile
- name: Fail when Caddy is not where we expect it
ansible.builtin.fail:
msg: >-
{{ 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
# nginx-menu playbook wrote one per hostname. Two blocks for one address make
# 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
- name: 'Add the {{ host }} site block to the Caddyfile'
ansible.builtin.blockinfile:
path: '{{ caddyfile_path }}'
marker: '# {mark} Ansible managed Caddy snippet for {{ host }}'
state: absent
notify: Reload Caddy
# 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
ansible.builtin.blockinfile:
path: '{{ caddyfile_path }}'
marker: '# {mark} ANSIBLE MANAGED: menu app ({{ site }})'
block: '{{ caddy_snippet }}'
create: false
block: '{{ snippet_block }}'
create: true
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0644'
+79 -47
View File
@@ -2,29 +2,28 @@
# The menu app 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
# XDG_RUNTIME_DIR: the SSH connection gives a real PAM session, so systemd's
# user manager is reachable through the standard environment.
# The pull is explicit, rather than left to the Quadlet unit, for two reasons.
# The image lives in a private registry, so a failure here is where a missing
# `podman login` should show up — not two tasks later as "unit not found". And
# the unit keeps podman's default `Pull=missing`, so it never reaches for the
# registry on a restart or after a reboot: that is what lets a box come back up
# when the registry is down. Pulling here is what keeps a redeployed tag current.
- name: Pull the app image
ansible.builtin.command: 'podman pull {{ image_name }}:{{ image_tag }}'
register: menu_pull
# The registry is private, so the pull below has to be authenticated. Podman
# keeps the credential in the user's own auth file; it is removed again at the
# end of this role, so nothing but the image is left behind. Skipped when no
# credentials are configured.
- name: Log in to the private registry
ansible.builtin.command:
cmd: 'podman login --username {{ registry_username }} --password-stdin {{ registry }}'
stdin: '{{ registry_password }}'
no_log: true
changed_when: false
failed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0
- name: Fail when the image cannot be pulled
ansible.builtin.fail:
msg: >-
podman could not pull {{ image_name }}:{{ image_tag }}.
{{ menu_pull.stderr | default('') | trim }}
The registry is private: run `podman login registry.tanshu.com` once as
{{ podman_user }} on this host.
when: menu_pull.rc != 0
# Pulled here rather than left to the unit: Quadlet only pulls when the image is
# missing, and at that point the service has no registry credential to offer.
- name: Pull the menu app image
ansible.builtin.command: 'podman pull {{ image }}'
changed_when: false
# The data directory is created here rather than left to the image. Under
# keep-id the image's own root phase maps to a subuid, so anything it tries to
@@ -32,7 +31,7 @@
# owned by {{ podman_user }}.
- name: Ensure the app root exists
ansible.builtin.file:
path: '{{ app_root }}'
path: '{{ host_directory }}'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
@@ -42,7 +41,7 @@
# data/ (originals/, drafts/, published/, site.json, sections.json).
- name: Ensure the data directory exists
ansible.builtin.file:
path: '{{ app_root }}/data'
path: '{{ host_directory }}/data'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
@@ -50,21 +49,29 @@
- name: Look for the untracked site.env
ansible.builtin.stat:
path: '{{ app_root }}/site.env'
path: '{{ host_directory }}/site.env'
register: menu_site_env
- name: Warn when the admin has no password yet
ansible.builtin.debug:
msg: >-
{{ app_root }}/site.env is missing, so the admin is disabled: the public
menu still serves. Run scripts/provision-site-env.sh {{ app_root }} on this
host, then re-run this playbook.
{{ host_directory }}/site.env is missing, so the admin is disabled: the
public menu still serves. Run scripts/provision-site-env.sh
{{ host_directory }} on this host, then re-run this playbook.
when: not menu_site_env.stat.exists
- name: Keep site.env readable only by the app user
ansible.builtin.file:
path: '{{ host_directory }}/site.env'
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0600'
when: menu_site_env.stat.exists
- name: Install the menu app Quadlet unit
ansible.builtin.template:
src: menu-app.container.j2
dest: '{{ quadlet_dir }}/{{ network_name }}.container'
dest: '{{ quadlet_dir }}/{{ container_name }}.container'
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0644'
@@ -82,14 +89,14 @@
daemon_reload: true
rescue:
# systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a missing network unit, an
# unwritable /data, a bad uid mapping). Pull the real reason out.
# which is useless for the usual failures (a missing image, an unattached
# network, an unwritable data directory). Pull the real reason out.
- name: Collect 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 }}'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 60 {{ container_name }}'
register: menu_diagnostics
changed_when: false
failed_when: false
@@ -102,9 +109,18 @@
ansible.builtin.fail:
msg: 'The menu app did not start — see the diagnostics above.'
# The unit template notifies "Restart the menu app", but handlers otherwise run
# at the very end of the play — after the healthcheck gate below. Flush them
# here so the gate tests the container that has the current HealthCmd; without
# this, a run that only changes the unit would check the stale container and
# fail. On a run where nothing changed, no handler is pending and this is a
# no-op.
- name: Apply pending unit changes before verifying
ansible.builtin.meta: flush_handlers
# Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit.
- name: Verify the menu app is running
- name: Verify the menu app unit is active
ansible.builtin.command: 'systemctl --user is-active {{ unit_name }}'
register: menu_active
changed_when: false
@@ -112,13 +128,13 @@
# `is-active` says the container is running; it does not say the app can serve.
# Restart=always means even a crash-looping container is reported active — which
# is exactly what a fatal integrity problem in the data directory produces. The
# unit declares the health check, so run that once, synchronously.
- name: Wait for the app to pass its health check
# is what a fatal integrity problem in the data directory produces. The unit
# declares the health check, so run that once, synchronously.
- name: Wait for the container healthcheck to pass
ansible.builtin.command: 'podman healthcheck run {{ container_name }}'
register: menu_health
until: menu_health.rc == 0
retries: 24
retries: 36
delay: 5
changed_when: false
failed_when: false
@@ -130,8 +146,8 @@
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 }}'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 60 {{ container_name }}'
register: menu_health_diagnostics
changed_when: false
failed_when: false
@@ -143,16 +159,8 @@
- name: Fail
ansible.builtin.fail:
msg: >-
{{ container_name }} did not pass its health check within two minutes.
Its logs are above, and: podman logs {{ container_name }}
- name: Keep site.env readable only by the app user
ansible.builtin.file:
path: '{{ app_root }}/site.env'
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0600'
when: menu_site_env.stat.exists
{{ container_name }} did not pass its health check within three
minutes. Its logs are above, and: podman logs {{ container_name }}
- name: Check the data directory for integrity problems
# Reads the same directory the container writes, as the same user, so a
@@ -176,3 +184,27 @@
ansible.builtin.debug:
msg: '{{ menu_verify.stdout_lines | default([]) }}'
when: menu_verify.rc != 0
- name: Show the networks the container is attached to
ansible.builtin.command: >-
podman inspect {{ container_name }}
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
register: menu_networks_now
changed_when: false
failed_when: false
- name: Report the network
ansible.builtin.debug:
msg: >-
{{ container_name }} is on
{{ menu_networks_now.stdout | default('(container not running)') }}
# Removes the credential from the user's auth file. The image is already local
# and the unit only pulls when it is missing, so this does not break restarts.
- name: Log out of the private registry
ansible.builtin.command: 'podman logout {{ registry }}'
changed_when: false
failed_when: false
when:
- registry_username | length > 0
- registry_password | length > 0
@@ -1,49 +1,46 @@
# {{ container_name }} — rootless Podman Quadlet unit.
# Managed by Ansible (menu-admin/roles/menu-app). Do not edit by hand.
#
# Joins {{ network_name }}.network, written by roles/register — the same include
# that attaches Caddy to it. Caddy reaches the app by container name, so nothing
# is published on the host and the app is not reachable from outside the Podman
# network at all.
# This unit joins {{ network_name }}.network, which is created by
# roles/register — the same include that attaches the shared Caddy unit to it,
# so Caddy can resolve {{ container_name }} by name.
[Unit]
Description=Menu app for {{ host }}
Description=Menu app for {{ host }} ({{ site }})
After=network-online.target
Wants=network-online.target
[Container]
ContainerName={{ container_name }}
Image={{ image_name }}:{{ image_tag }}
Image={{ image }}
# keep-id maps the image's own account — `node`, uid/gid {{ app_uid }} — onto
# {{ podman_user }}, which is what keeps every file the app writes owned by
# {{ podman_user }}, which keeps every file the app writes owned by
# {{ podman_user }} on the host: plain tar/rsync backups, no subuids, no
# `podman unshare`. The same mapping is why scripts/provision-site-env.sh writes
# site.env with --userns=keep-id.
# `podman unshare`. scripts/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.
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
# The alias is deliberately dotless. ContainerName keeps the Docker-era name so
# operators and scripts find it, but a name with dots in it is a liability in
# DNS, so {{ network_name }} is what the Caddyfile proxies to. Both resolve from
# inside the network.
NetworkAlias={{ network_name }}
Environment=MENU_ROOT=/data
Environment=MENU_SITE_CONFIG=/app/config/{{ site }}.site.json
Environment=SECURE_COOKIES=true
Environment=LOG_LEVEL=info
Environment=PORT={{ container_port }}
Environment=PORT={{ site_port }}
# Everything the app writes: data/ (originals, drafts, published, site.json,
# sections.json) and site.env. The Site's own config is baked into the image and
# read from /app.
Volume={{ app_root }}:/data
Volume={{ host_directory }}:/data
Network={{ network_name }}.network
# podman builds an OCI image by default and drops the Containerfile's
# HEALTHCHECK instruction, so the same check is declared here and the play gates
# on it with `podman healthcheck run`. node is in the image.
# No PublishPort on purpose: Caddy reaches the app over {{ network_name }}_net,
# so nothing is exposed on the host.
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
# instruction, so the same check is declared here and the play gates on it with
# `podman healthcheck run`. node is in the image.
HealthCmd={{ health_cmd }}
HealthInterval=30s
HealthTimeout=5s
+29 -13
View File
@@ -1,18 +1,26 @@
---
# Register this Site's logical network with Caddy.
# Register this Site's network with the shared Caddy unit.
#
# Copied from ansible-playbooks/caddy/roles/register/tasks/main.yaml: this repo
# carries its own deployment, so it cannot include that file by path. Keep the
# two in step.
# Caddy is the base unit (caddy/playbook.yml in the ansible-playbooks repo) and
# knows nothing about its upstreams. A service adds itself by writing two files
# into the Quadlet directory:
#
# Caddy is the *base* unit and knows nothing about its upstreams. A service adds
# itself by writing two files into the Quadlet directory:
#
# 1. its own <name>.network unit, and
# 1. its own menu-<site>.network unit, and
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
#
# Caddy itself is owned by ansible-playbooks/caddy/playbook.yml; this play only
# registers with it. The including play defines a "Restart Caddy" handler.
# This mirrors ansible-playbooks/caddy/roles/register/tasks/main.yaml, vendored
# so this repo's deploy.sh stands on its own. Keep the bytes in sync with it.
#
# Included from playbook.yml's pre_tasks, before the app unit is generated. Runs
# as the connection user, which must be {{ podman_user }}.
- name: Ensure the Quadlet directory exists
ansible.builtin.file:
path: '{{ quadlet_dir }}'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0755'
- name: 'Create the {{ network_name }} network unit'
ansible.builtin.copy:
@@ -28,13 +36,21 @@
# create "systemd-{{ network_name }}".
#
# isolate blocks traffic between separate Podman networks, so a container
# on this network cannot reach one that is only on another. Caddy and any
# shared service still reach it, because they are members of it.
# on this network cannot reach one that is only on another. Caddy still
# reaches it, because it is a member of it.
[Network]
NetworkName={{ network_name }}_net
Options=isolate=true
notify: Restart Caddy
- name: Ensure the Caddy Quadlet drop-in directory exists
ansible.builtin.file:
path: '{{ quadlet_dir }}/{{ caddy_name }}.container.d'
state: directory
owner: '{{ podman_user }}'
group: '{{ podman_user }}'
mode: '0755'
- name: 'Register {{ network_name }} with Caddy'
ansible.builtin.copy:
dest: '{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf'
@@ -42,7 +58,7 @@
group: '{{ podman_user }}'
mode: '0644'
content: |
# Added by the {{ network_name }} playbook. Do not edit by hand.
# Added by the menu-admin playbook. Do not edit by hand.
#
# Quadlet merges this into caddy.container. Deleting this file removes
# the network from Caddy on the next daemon-reload.
+40 -31
View File
@@ -5,55 +5,64 @@
# ---------------------------------------------------------------------------
# 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.
# 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). A host that has already run caddy/playbook.yml from the
# ansible-playbooks repo has the rootless prerequisites.
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.
# ---------------------------------------------------------------------------
# Image
# ---------------------------------------------------------------------------
registry: registry.tanshu.com
registry_username: ta-registry
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
image_name: '{{ registry }}/menu-admin'
tag: latest
# deploy.sh passes -e image_tag=<commit>; `tag` is the fallback when running the
# play by hand. Rollback = -e image_tag=<older-commit>.
image: '{{ image_name }}:{{ image_tag | default(tag) }}'
# ---------------------------------------------------------------------------
# The Site
# ---------------------------------------------------------------------------
# One image serves every Site; this picks which config inside it to run. The
# inventory name is the slug, so the container, its network and its data
# directory are all menu-<site>.
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 }}'
app_name: 'menu-{{ site }}'
container_name: '{{ app_name }}'
unit_name: '{{ container_name }}.service'
network_name: '{{ app_name }}'
# The image's own account (`node`), which keep-id maps onto podman_user.
app_uid: 1000
app_gid: 1000
container_port: 3000
# Port inside the container. Caddy proxies to it over menu-<site>_net; nothing
# is published on the host.
site_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.
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
# instruction, so the same check is declared on the Quadlet unit and the play
# gates on `podman healthcheck run`. 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 =>
node -e "fetch('http://127.0.0.1:{{ site_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).
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
# ---------------------------------------------------------------------------
caddy_name: caddy
caddyfile_path: '{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile'
caddy_snippet: |
# Appended to the shared Caddyfile by roles/caddy. The Site is served from the
# container name over menu-<site>_net; no host ports are involved.
snippet_block: |
{{ host }} {
reverse_proxy {{ network_name }}:{{ container_port }}
reverse_proxy {{ container_name }}:{{ site_port }}
}
+5 -5
View File
@@ -1,11 +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.
# server under host_directory 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 }}'
# 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 }}'
# password hash and session secret). One directory per Site, named for the
# container that mounts it.
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
+5 -5
View File
@@ -1,11 +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.
# server under host_directory 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 }}'
# 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 }}'
# password hash and session secret). One directory per Site, named for the
# container that mounts it.
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
+5 -5
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.
# under host_directory 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
@@ -10,6 +10,6 @@ title: hopsngrains.com
host: 'menu.{{ title }}'
# 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 }}'
# password hash and session secret). One directory per Site, named for the
# container that mounts it.
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'