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:
1 parent
e68f8ab00d
commit
537a4f1e46
21 files changed
+407
-307
No files matched your search
+14
-13
@@ -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
@@ -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.
|
||||
@@ -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'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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 }}
|
||||
}
|
||||
@@ -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 }}'
|
||||
@@ -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 }}'
|
||||
@@ -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 }}'
|
||||
Reference in new issue
Block a user