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
+398 -298

No files matched your search

+1 -1
View File
@@ -8,7 +8,7 @@ public/fonts/OFL.txt
docs docs
test test
compose.yaml compose.yaml
Containerfile Dockerfile
.containerignore .containerignore
README.md README.md
# design prototype, never committed to main and never shipped # design prototype, never committed to main and never shipped
+3 -2
View File
@@ -1,5 +1,6 @@
# Built with podman. `podman build` reads a Containerfile as readily as a # Built with Podman/Buildah, not Docker — see the Makefile. The file keeps the
# Dockerfile, and the name says which toolchain the pipeline actually uses. # Dockerfile name because podman reads it as readily as a Containerfile, and the
# sibling apps use the same name.
# #
# The app runs TypeScript directly on Node 24, so there is no build step and no # The app runs TypeScript directly on Node 24, so there is no build step and no
# compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt # compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt
+39 -20
View File
@@ -1,23 +1,42 @@
# menu-admin — container image builds.
#
# Built with Podman/Buildah, not Docker, from the committed tree
# (`git archive HEAD`) and tagged with that commit, so a deploy ships exactly
# what git knows about. deploy.sh pushes first and refuses a dirty tree.
COMMIT ?= $(shell git rev-parse --short HEAD)
# The deploy host (emily) is aarch64, so that is the default target: the image
# is built for the machine it runs on, not the one running make. Podman carries
# no built-in QEMU (unlike docker buildx), so a foreign platform needs binfmt
# handlers on this host — qemu-user-binfmt is installed here. Override with e.g.
# `make build-production PLATFORM=linux/amd64`.
PLATFORM ?= linux/arm64
IMAGE ?= registry.tanshu.com/menu-admin IMAGE ?= registry.tanshu.com/menu-admin
PLATFORMS ?= linux/amd64,linux/arm64
.PHONY: build-production build-check build-check-local .PHONY: build-production
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest"
@git archive --format=tar HEAD | podman build \
--platform $(PLATFORM) \
--tag $(IMAGE):$(COMMIT) \
--tag $(IMAGE):latest \
--pull=always \
- || { \
echo "✗ podman build failed."; \
echo " Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \
echo " sudo apt install qemu-user-binfmt # or qemu-user-static"; \
exit 1; \
}
podman push $(IMAGE):$(COMMIT)
podman push $(IMAGE):latest
# Mirrors the other apps on this machine: a podman manifest build, so one tag .PHONY: build-check
# carries both architectures, pushed to the private registry. build-check: ## Compile check from HEAD with podman, nothing pushed
build-production: ## Build and push the production image. Usage: make build-production TAG=2026-09-22 @echo "==> podman build --platform $(PLATFORM) (no push)"
@test -n "$(TAG)" || { echo "Usage: make build-production TAG=2026-09-22" >&2; exit 1; } git archive --format=tar HEAD | podman build \
@echo "Building image..." --platform $(PLATFORM) \
@podman manifest rm $(IMAGE):latest 2>/dev/null || true --tag localhost/menu-admin:check \
@podman build --platform $(PLATFORMS) --manifest $(IMAGE):latest --pull . --pull=always \
@echo "Pushing manifests..." -
@podman manifest push --all $(IMAGE):latest docker://$(IMAGE):latest
@podman manifest push --all $(IMAGE):latest docker://$(IMAGE):$(TAG)
build-check: ## Multi-arch build without pushing, to prove both platforms build
@podman manifest rm menu-admin:check 2>/dev/null || true
@podman build --platform $(PLATFORMS) --manifest menu-admin:check --pull .
build-check-local: ## Single-arch build without pushing: the quick local check
@podman manifest rm menu-admin:check 2>/dev/null || true
@podman build --platform linux/amd64 --manifest menu-admin:check .
+18 -11
View File
@@ -17,17 +17,24 @@ and moving DNS, which needs a human present.
## Deploying ## Deploying
```sh ```sh
./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy ./deploy.sh # push, build <commit>, deploy every Site to emily
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only ./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
``` ```
The image is built with **podman** as a multi-arch manifest and pushed to The image is built with **Podman/Buildah** (not Docker) from the committed tree
`registry.tanshu.com/menu-admin` (`Makefile`, mirrored from the other apps on (`git archive HEAD`) and tagged with its **commit id**, so what runs on the host
this machine). The Ansible deployment lives in `ansible/` in this repo, with one is exactly what git knows about; `deploy.sh` refuses a dirty tracked tree and
inventory entry per Site. It writes a rootless Podman **Quadlet** unit per Site requires `HEAD == origin/main`. It is built for **linux/arm64** because `emily`
and appends the Site's block to the shared Caddyfile, exactly as the sibling is aarch64, and pushed to `registry.tanshu.com/menu-admin`.
services in `ansible-playbooks` do; see `ansible/readme.md` for how a deploy runs
and how to roll one back. The Ansible deployment lives in `ansible/` in this repo, with one inventory entry
per Site. It writes a rootless Podman **Quadlet** unit per Site and appends the
Site's block to the shared Caddyfile, exactly as the sibling `hops` and
`greatbear` apps do; see `ansible/readme.md` for how a deploy runs and how to
roll one back.
## Layout ## Layout
@@ -50,9 +57,9 @@ test/ node:test suites
docs/plan.md The agreed plan docs/plan.md The agreed plan
docs/operating.md Publishing, fixing and rolling back, for whoever operates it docs/operating.md Publishing, fixing and rolling back, for whoever operates it
docs/adr/ Decisions worth remembering docs/adr/ Decisions worth remembering
Containerfile The image, built by podman Dockerfile The image, built by podman
compose.yaml Local dev: podman compose compose.yaml Local dev: podman compose
deploy.sh Tests, build, push, deploy deploy.sh Build HEAD, push it, deploy it
ansible/ The deployment: one rootless Quadlet unit per Site ansible/ The deployment: one rootless Quadlet unit per Site
``` ```
+14 -13
View File
@@ -1,23 +1,24 @@
--- ---
# Deploys the menu app for one or more Sites as rootless Podman Quadlet units on # 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 # emily, behind the fleet's shared Caddy unit (caddy/playbook.yml in the
# here, beside the app it deploys, and is run from this directory: # ansible-playbooks repo). It lives here, beside the app it deploys.
#
# ./deploy.sh <tag> (repo root)
# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=<tag>
# #
# Connects as the container user and runs everything as that user: the Quadlet # 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 # 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 # gather_facts is required: podman_user, podman_data_dir and quadlet_dir are
# user_id / user_dir facts. # derived from the user_id / user_dir facts.
# #
# The rootless-Podman prerequisites (installing podman, enabling lingering, the # The inventory is the one difference from the sibling apps, which deploy a
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied # single app: this play carries three Sites and a var_file per Site.
# here. They are the shared task file at #
# ansible-playbooks/caddy/roles/podman/tasks/main.yaml, and a host that has # ./deploy.sh (repo root)
# already run ansible-playbooks/caddy/playbook.yml has them. # 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 - name: Deploy the menu app as rootless Podman Quadlets
hosts: menus hosts: menus
gather_facts: true 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 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 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 `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 there is no staged rollout and nothing to retire — deploying the image built from
the rollback. an earlier commit is the rollback.
This is the same shape as the sibling services in `ansible-playbooks` (gitea, This is the same shape as the sibling services in `ansible-playbooks` (gitea,
nextcloud, tandoor, bitwarden, valkey, postgres, trillium): rootless Podman, one 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 | | Theme colours | `config/<site>.site.json` in the repo, baked into the image |
| The unit that runs | `~/.config/containers/systemd/menu-<site>.container` | | 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` | | 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` | | Which Site is deployed | the inventory entry in `hosts` |
| The web address it serves | `host:` in `vars/<site>.yml` | | The web address it serves | `host:` in `vars/<site>.yml` |
`<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`). The `<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`), and it
container itself keeps the Docker-era name `menu-app.<domain>`, because is the single slug for everything: the container, its Podman network and its data
`provision-site-env.sh` and muscle memory look it up that way; the Caddyfile directory are all `menu-<site>`. That is how the sibling `hops` and `greatbear`
proxies to the dotless `menu-<site>` alias, because a container name with dots in apps are deployed, and it keeps `podman logs menu-<site>` and
it is a liability in DNS. `systemctl --user status menu-<site>` the same two commands across all three.
## What the host needs ## 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 not run that playbook will fail at the first `systemctl --user` — see
`ansible-playbooks/caddy/readme.md`. `ansible-playbooks/caddy/readme.md`.
The image is in the private registry, so log in once as `tanshu` on `emily`, The registry is private, but nothing here needs a manual login: the play logs in
before the first deploy: 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
```sh auth file. The unit keeps podman's default `Pull=missing` on purpose, so a
podman login registry.tanshu.com restart or a reboot never depends on the registry being reachable.
```
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.
`ssh` on some machines complains about `ssh` on some machines complains about
`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that is `/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: From the repo root:
```sh ```sh
./deploy.sh 2026-09-22 # checks, podman build+push, then deploys ./deploy.sh # push HEAD, build <commit>, deploy every Site
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only ./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 `deploy.sh` requires a clean tracked tree and `HEAD == origin/main`, tags the
podman manifest (`linux/amd64,linux/arm64`) and pushes `latest` and the tag to build with the commit it came from, and refuses a dirty tree for a reason: the
`registry.tanshu.com/menu-admin`. Then it runs this playbook against every Site, image is built from `git archive HEAD`, so what runs on the host is exactly what
or the one named by `SITE`. 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: By hand:
```sh ```sh
make build-production TAG=2026-09-22 git push
make build-production COMMIT=$(git rev-parse --short HEAD)
cd ansible 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 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. 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 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 `config/<site>.site.json`. The image is shared and carries every Site's config,
new Site needs a new tag. so a new Site needs a new build — which the commit tag gives it.
## Migrating a Site's data from `monoco` ## Migrating a Site's data from `monoco`
@@ -205,13 +205,15 @@ something.
## Rolling back ## 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 ```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 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 deployment, start the container that was left on `monoco` and point Caddy at it
again. again.
+11 -36
View File
@@ -1,50 +1,25 @@
--- ---
# Append this Site's block to the shared Caddyfile. # Append this Site's block to the shared Caddyfile.
# #
# Caddy is its own playbook (ansible-playbooks/caddy/playbook.yml) and a rootless # The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
# Quadlet unit there, so this role does not manage the container — it only points # change as soon as the file is written; the handler only asks Caddy to re-read
# it at the app. The Caddyfile is a bind mount into that container, so Caddy sees # it. That is a reload, not a restart, so the other hosted sites are untouched.
# 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 # 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* # 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. # 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 - name: 'Add the {{ host }} site block to the Caddyfile'
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
ansible.builtin.blockinfile: ansible.builtin.blockinfile:
path: '{{ caddyfile_path }}' path: '{{ caddyfile_path }}'
marker: '# {mark} Ansible managed Caddy snippet for {{ host }}' marker: '# {mark} Ansible managed Caddy snippet for {{ host }}'
state: absent block: '{{ snippet_block }}'
notify: Reload Caddy create: true
# 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
owner: '{{ podman_user }}' owner: '{{ podman_user }}'
group: '{{ podman_user }}' group: '{{ podman_user }}'
mode: '0644' mode: '0644'
+79 -47
View File
@@ -2,29 +2,28 @@
# The menu app as a rootless Podman Quadlet unit. # The menu app as a rootless Podman Quadlet unit.
# #
# Everything here runs as {{ podman_user }} — the connection user. No become, no # 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. # user manager is reachable through the standard environment.
# The pull is explicit, rather than left to the Quadlet unit, for two reasons. # The registry is private, so the pull below has to be authenticated. Podman
# The image lives in a private registry, so a failure here is where a missing # keeps the credential in the user's own auth file; it is removed again at the
# `podman login` should show up — not two tasks later as "unit not found". And # end of this role, so nothing but the image is left behind. Skipped when no
# the unit keeps podman's default `Pull=missing`, so it never reaches for the # credentials are configured.
# registry on a restart or after a reboot: that is what lets a box come back up - name: Log in to the private registry
# when the registry is down. Pulling here is what keeps a redeployed tag current. ansible.builtin.command:
- name: Pull the app image cmd: 'podman login --username {{ registry_username }} --password-stdin {{ registry }}'
ansible.builtin.command: 'podman pull {{ image_name }}:{{ image_tag }}' stdin: '{{ registry_password }}'
register: menu_pull no_log: true
changed_when: false changed_when: false
failed_when: false when:
- registry_username | length > 0
- registry_password | length > 0
- name: Fail when the image cannot be pulled # Pulled here rather than left to the unit: Quadlet only pulls when the image is
ansible.builtin.fail: # missing, and at that point the service has no registry credential to offer.
msg: >- - name: Pull the menu app image
podman could not pull {{ image_name }}:{{ image_tag }}. ansible.builtin.command: 'podman pull {{ image }}'
{{ menu_pull.stderr | default('') | trim }} changed_when: false
The registry is private: run `podman login registry.tanshu.com` once as
{{ podman_user }} on this host.
when: menu_pull.rc != 0
# The data directory is created here rather than left to the image. Under # 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 # 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 }}. # owned by {{ podman_user }}.
- name: Ensure the app root exists - name: Ensure the app root exists
ansible.builtin.file: ansible.builtin.file:
path: '{{ app_root }}' path: '{{ host_directory }}'
state: directory state: directory
owner: '{{ podman_user }}' owner: '{{ podman_user }}'
group: '{{ podman_user }}' group: '{{ podman_user }}'
@@ -42,7 +41,7 @@
# data/ (originals/, drafts/, published/, site.json, sections.json). # data/ (originals/, drafts/, published/, site.json, sections.json).
- name: Ensure the data directory exists - name: Ensure the data directory exists
ansible.builtin.file: ansible.builtin.file:
path: '{{ app_root }}/data' path: '{{ host_directory }}/data'
state: directory state: directory
owner: '{{ podman_user }}' owner: '{{ podman_user }}'
group: '{{ podman_user }}' group: '{{ podman_user }}'
@@ -50,21 +49,29 @@
- name: Look for the untracked site.env - name: Look for the untracked site.env
ansible.builtin.stat: ansible.builtin.stat:
path: '{{ app_root }}/site.env' path: '{{ host_directory }}/site.env'
register: menu_site_env register: menu_site_env
- name: Warn when the admin has no password yet - name: Warn when the admin has no password yet
ansible.builtin.debug: ansible.builtin.debug:
msg: >- msg: >-
{{ app_root }}/site.env is missing, so the admin is disabled: the public {{ host_directory }}/site.env is missing, so the admin is disabled: the
menu still serves. Run scripts/provision-site-env.sh {{ app_root }} on this public menu still serves. Run scripts/provision-site-env.sh
host, then re-run this playbook. {{ host_directory }} on this host, then re-run this playbook.
when: not menu_site_env.stat.exists 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 - name: Install the menu app Quadlet unit
ansible.builtin.template: ansible.builtin.template:
src: menu-app.container.j2 src: menu-app.container.j2
dest: '{{ quadlet_dir }}/{{ network_name }}.container' dest: '{{ quadlet_dir }}/{{ container_name }}.container'
owner: '{{ podman_user }}' owner: '{{ podman_user }}'
group: '{{ podman_user }}' group: '{{ podman_user }}'
mode: '0644' mode: '0644'
@@ -82,14 +89,14 @@
daemon_reload: true daemon_reload: true
rescue: rescue:
# systemd reports "control process exited with error code" and nothing else, # systemd reports "control process exited with error code" and nothing else,
# which is useless for the usual failures (a missing network unit, an # which is useless for the usual failures (a missing image, an unattached
# unwritable /data, a bad uid mapping). Pull the real reason out. # network, an unwritable data directory). Pull the real reason out.
- name: Collect diagnostics - name: Collect diagnostics
ansible.builtin.command: '{{ item }}' ansible.builtin.command: '{{ item }}'
loop: loop:
- 'systemctl --user status {{ unit_name }} --no-pager' - 'systemctl --user status {{ unit_name }} --no-pager'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 40' - 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 40 {{ container_name }}' - 'podman logs --tail 60 {{ container_name }}'
register: menu_diagnostics register: menu_diagnostics
changed_when: false changed_when: false
failed_when: false failed_when: false
@@ -102,9 +109,18 @@
ansible.builtin.fail: ansible.builtin.fail:
msg: 'The menu app did not start — see the diagnostics above.' 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 # Quadlet honours [Install] WantedBy= when it generates the unit, so
# `systemctl --user enable` is neither needed nor possible on a generated unit. # `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 }}' ansible.builtin.command: 'systemctl --user is-active {{ unit_name }}'
register: menu_active register: menu_active
changed_when: false changed_when: false
@@ -112,13 +128,13 @@
# `is-active` says the container is running; it does not say the app can serve. # `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 # 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 # is what a fatal integrity problem in the data directory produces. The unit
# unit declares the health check, so run that once, synchronously. # declares the health check, so run that once, synchronously.
- name: Wait for the app to pass its health check - name: Wait for the container healthcheck to pass
ansible.builtin.command: 'podman healthcheck run {{ container_name }}' ansible.builtin.command: 'podman healthcheck run {{ container_name }}'
register: menu_health register: menu_health
until: menu_health.rc == 0 until: menu_health.rc == 0
retries: 24 retries: 36
delay: 5 delay: 5
changed_when: false changed_when: false
failed_when: false failed_when: false
@@ -130,8 +146,8 @@
ansible.builtin.command: '{{ item }}' ansible.builtin.command: '{{ item }}'
loop: loop:
- 'systemctl --user status {{ unit_name }} --no-pager' - 'systemctl --user status {{ unit_name }} --no-pager'
- 'journalctl --user -u {{ unit_name }} --no-pager -n 40' - 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
- 'podman logs --tail 40 {{ container_name }}' - 'podman logs --tail 60 {{ container_name }}'
register: menu_health_diagnostics register: menu_health_diagnostics
changed_when: false changed_when: false
failed_when: false failed_when: false
@@ -143,16 +159,8 @@
- name: Fail - name: Fail
ansible.builtin.fail: ansible.builtin.fail:
msg: >- msg: >-
{{ container_name }} did not pass its health check within two minutes. {{ container_name }} did not pass its health check within three
Its logs are above, and: podman logs {{ container_name }} 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
- name: Check the data directory for integrity problems - name: Check the data directory for integrity problems
# Reads the same directory the container writes, as the same user, so a # Reads the same directory the container writes, as the same user, so a
@@ -176,3 +184,27 @@
ansible.builtin.debug: ansible.builtin.debug:
msg: '{{ menu_verify.stdout_lines | default([]) }}' msg: '{{ menu_verify.stdout_lines | default([]) }}'
when: menu_verify.rc != 0 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. # {{ container_name }} — rootless Podman Quadlet unit.
# Managed by Ansible (menu-admin/roles/menu-app). Do not edit by hand. # Managed by Ansible (menu-admin/roles/menu-app). Do not edit by hand.
# #
# Joins {{ network_name }}.network, written by roles/register — the same include # This unit joins {{ network_name }}.network, which is created by
# that attaches Caddy to it. Caddy reaches the app by container name, so nothing # roles/register — the same include that attaches the shared Caddy unit to it,
# is published on the host and the app is not reachable from outside the Podman # so Caddy can resolve {{ container_name }} by name.
# network at all.
[Unit] [Unit]
Description=Menu app for {{ host }} Description=Menu app for {{ host }} ({{ site }})
After=network-online.target After=network-online.target
Wants=network-online.target Wants=network-online.target
[Container] [Container]
ContainerName={{ container_name }} ContainerName={{ container_name }}
Image={{ image_name }}:{{ image_tag }} Image={{ image }}
# keep-id maps the image's own account — `node`, uid/gid {{ app_uid }} — onto # 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_user }} on the host: plain tar/rsync backups, no subuids, no
# `podman unshare`. The same mapping is why scripts/provision-site-env.sh writes # `podman unshare`. scripts/provision-site-env.sh writes site.env with the same
# site.env with --userns=keep-id. # 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 }} 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_ROOT=/data
Environment=MENU_SITE_CONFIG=/app/config/{{ site }}.site.json Environment=MENU_SITE_CONFIG=/app/config/{{ site }}.site.json
Environment=SECURE_COOKIES=true Environment=SECURE_COOKIES=true
Environment=LOG_LEVEL=info Environment=LOG_LEVEL=info
Environment=PORT={{ container_port }} Environment=PORT={{ site_port }}
# Everything the app writes: data/ (originals, drafts, published, site.json, # 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 # sections.json) and site.env. The Site's own config is baked into the image and
# read from /app. # read from /app.
Volume={{ app_root }}:/data Volume={{ host_directory }}:/data
Network={{ network_name }}.network Network={{ network_name }}.network
# podman builds an OCI image by default and drops the Containerfile's # No PublishPort on purpose: Caddy reaches the app over {{ network_name }}_net,
# HEALTHCHECK instruction, so the same check is declared here and the play gates # so nothing is exposed on the host.
# on it with `podman healthcheck run`. node is in the image.
# 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 }} HealthCmd={{ health_cmd }}
HealthInterval=30s HealthInterval=30s
HealthTimeout=5s 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 # Caddy is the base unit (caddy/playbook.yml in the ansible-playbooks repo) and
# carries its own deployment, so it cannot include that file by path. Keep the # knows nothing about its upstreams. A service adds itself by writing two files
# two in step. # into the Quadlet directory:
# #
# Caddy is the *base* unit and knows nothing about its upstreams. A service adds # 1. its own menu-<site>.network unit, and
# itself by writing two files into the Quadlet directory:
#
# 1. its own <name>.network unit, and
# 2. a drop-in next to caddy.container that pulls that network into Caddy. # 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 # This mirrors ansible-playbooks/caddy/roles/register/tasks/main.yaml, vendored
# registers with it. The including play defines a "Restart Caddy" handler. # 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' - name: 'Create the {{ network_name }} network unit'
ansible.builtin.copy: ansible.builtin.copy:
@@ -28,13 +36,21 @@
# create "systemd-{{ network_name }}". # create "systemd-{{ network_name }}".
# #
# isolate blocks traffic between separate Podman networks, so a container # isolate blocks traffic between separate Podman networks, so a container
# on this network cannot reach one that is only on another. Caddy and any # on this network cannot reach one that is only on another. Caddy still
# shared service still reach it, because they are members of it. # reaches it, because it is a member of it.
[Network] [Network]
NetworkName={{ network_name }}_net NetworkName={{ network_name }}_net
Options=isolate=true Options=isolate=true
notify: Restart Caddy 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' - name: 'Register {{ network_name }} with Caddy'
ansible.builtin.copy: ansible.builtin.copy:
dest: '{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf' dest: '{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf'
@@ -42,7 +58,7 @@
group: '{{ podman_user }}' group: '{{ podman_user }}'
mode: '0644' mode: '0644'
content: | 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 # Quadlet merges this into caddy.container. Deleting this file removes
# the network from Caddy on the next daemon-reload. # the network from Caddy on the next daemon-reload.
+40 -31
View File
@@ -5,55 +5,64 @@
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Rootless Podman / Quadlet # Rootless Podman / Quadlet
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# podman_user is derived from the connection: this play carries no become, so the # Derived from the connection: this play carries no become, so the Quadlet
# Quadlet units belong to whoever Ansible connects as. Requires gather_facts # units belong to whoever Ansible connects as. Requires gather_facts (set in
# (set in playbook.yml). # playbook.yml). A host that has already run caddy/playbook.yml from the
# # ansible-playbooks repo has the rootless prerequisites.
# 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_user: "{{ ansible_facts['user_id'] }}"
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" 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 }}' site: '{{ inventory_hostname }}'
app_name: 'menu-{{ site }}'
# --------------------------------------------------------------------------- container_name: '{{ app_name }}'
# The app unit_name: '{{ container_name }}.service'
# --------------------------------------------------------------------------- network_name: '{{ app_name }}'
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. # The image's own account (`node`), which keep-id maps onto podman_user.
app_uid: 1000 app_uid: 1000
app_gid: 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 # podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
# HEALTHCHECK instruction, so the unit declares the same check and the play gates # instruction, so the same check is declared on the Quadlet unit and the play
# on it. node is in the image and the sh -c podman wraps it in handles the quotes. # gates on `podman healthcheck run`. node is in the image, and the sh -c podman
# wraps it in handles the quotes.
health_cmd: >- 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))" process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# The shared Caddy unit and its Caddyfile, both owned by the caddy playbook in # Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
# ansible-playbooks (caddy/playbook.yml).
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
caddy_name: caddy caddy_name: caddy
caddyfile_path: '{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile' 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 }} { {{ 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 # 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 # server under host_directory and are edited in the admin;
# only seeds the theme and the first category list. # config/greatbear.site.json only seeds the theme and the first category list.
title: greatbear.in title: greatbear.in
host: 'menu.{{ title }}' host: 'menu.{{ title }}'
# Everything the app writes — data/ (the content) and site.env (the admin # 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 # password hash and session secret). One directory per Site, named for the
# also its own Podman network and its own Caddy upstream. # container that mounts it.
app_root: '{{ podman_data_dir }}/{{ network_name }}' 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 # 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 # server under host_directory and are edited in the admin;
# only seeds the theme and the first category list. # config/hngmohali.site.json only seeds the theme and the first category list.
title: hngmohali.com title: hngmohali.com
host: 'menu.{{ title }}' host: 'menu.{{ title }}'
# Everything the app writes — data/ (the content) and site.env (the admin # 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 # password hash and session secret). One directory per Site, named for the
# also its own Podman network and its own Caddy upstream. # container that mounts it.
app_root: '{{ podman_data_dir }}/{{ network_name }}' 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 # 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 # under host_directory and are edited in the admin; config/hopsngrains.site.json
# seeds the theme. This file says where the Site runs and what it answers. # 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 `{` # 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 # as a flow mapping, which is the parse error Ansible reports as "missing quotes
@@ -10,6 +10,6 @@ title: hopsngrains.com
host: 'menu.{{ title }}' host: 'menu.{{ title }}'
# Everything the app writes — data/ (the content) and site.env (the admin # 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 # password hash and session secret). One directory per Site, named for the
# also its own Podman network and its own Caddy upstream. # container that mounts it.
app_root: '{{ podman_data_dir }}/{{ network_name }}' host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
+1 -1
View File
@@ -4,7 +4,7 @@
# npm run seed && podman compose up --build # npm run seed && podman compose up --build
# #
# `podman compose` needs a provider on PATH — podman-compose or docker-compose. # `podman compose` needs a provider on PATH — podman-compose or docker-compose.
# The image is built from the Containerfile beside this file. # The image is built from the Dockerfile beside this file.
services: services:
menu: menu:
build: . build: .
+69 -32
View File
@@ -1,37 +1,65 @@
#!/usr/bin/env bash #!/usr/bin/env bash
# #
# Builds the image with podman, pushes it to the private registry, then deploys # menu-admin deploy — push the current commit, build it with podman, and install
# the Site with the playbook that lives in ansible/. The playbook runs rootless # the Quadlet units on emily with ansible.
# Podman Quadlet units on emily, behind the shared Caddy unit from
# ansible-playbooks/caddy.
# #
# ./deploy.sh 2026-09-22 deploy the default Site # The image is built from the committed tree (`git archive HEAD`) and tagged
# SITE=greatbear ./deploy.sh 2026-09-22 deploy another Site # with its commit id, so what runs on the host is exactly what git knows about.
# SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 build and push only
# SKIP_CHECKS=1 ./deploy.sh 2026-09-22 skip format, typecheck and tests
# #
# ./deploy.sh deploy HEAD (tracked tree must be clean and pushed)
# ./deploy.sh --force skip the guards (know what you are doing)
# ./deploy.sh --no-push skip the push, if you already pushed
# ./deploy.sh --checks lint, format, typecheck and test first
#
# SITE=<name> deploys one Site instead of every Site.
#
# Log in to the registry once beforehand: podman login registry.tanshu.com
set -euo pipefail set -euo pipefail
cd "$(dirname "${BASH_SOURCE[0]}")"
TAG="${1:-}" BRANCH="${BRANCH:-main}"
if [[ -z "$TAG" ]]; then
echo "usage: $0 <tag> (e.g. $(date +%Y-%m-%d) or a git short sha)" >&2 FORCE=false
NO_PUSH=false
CHECKS=false
for arg in "$@"; do
case "$arg" in
--force) FORCE=true ;;
--no-push) NO_PUSH=true ;;
--checks) CHECKS=true ;;
*)
echo "Usage: $0 [--force] [--no-push] [--checks]" >&2
exit 1 exit 1
;;
esac
done
# The build takes HEAD, so push first: otherwise the registry collects an image
# for a commit that exists only on this machine.
if [ "$NO_PUSH" != true ]; then
echo "==> pushing"
git push
fi fi
parent_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) # Guards. Only tracked-file state matters — untracked and ignored files never
cd "$parent_path" # enter a `git archive HEAD` build. HEAD must match origin/$BRANCH so the commit
# the image is tagged with is one the remote actually has.
if ! command -v podman >/dev/null 2>&1; then if [ "$FORCE" != true ]; then
echo "podman is required to build and push the image" >&2 if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
echo "x tracked files modified - commit or stash first (or --force)" >&2
exit 1 exit 1
fi fi
git fetch origin --quiet
if [[ -z "${SKIP_DEPLOY:-}" ]] && ! command -v ansible-playbook >/dev/null 2>&1; then if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then
echo "ansible-playbook is required to deploy; SKIP_DEPLOY=1 builds and pushes only" >&2 echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2
exit 1 exit 1
fi fi
fi
if [[ -z "${SKIP_CHECKS:-}" ]]; then # Opt-in rather than part of the deploy path, so a deploy behaves the same here
# as it does in the sibling apps. Run it before building when the change touches
# the app rather than only the deployment.
if [ "$CHECKS" = true ]; then
echo "==> lint, format, types and tests" echo "==> lint, format, types and tests"
npm run lint npm run lint
npm run format:check npm run format:check
@@ -39,19 +67,28 @@ if [[ -z "${SKIP_CHECKS:-}" ]]; then
npm test npm test
fi fi
echo "==> building and pushing registry.tanshu.com/menu-admin:$TAG" TAG=$(git rev-parse --short HEAD)
make build-production TAG="$TAG" echo "==> deploying commit $TAG"
if [[ -n "${SKIP_DEPLOY:-}" ]]; then echo "========================================================"
echo "SKIP_DEPLOY is set, so the playbook was not run." echo " Building & Uploading Container Image (Podman)"
exit 0 echo "========================================================"
fi make build-production COMMIT="$TAG"
cd ansible echo ""
if [[ -n "${SITE:-}" ]]; then echo "========================================================"
echo "==> deploying $SITE" echo " Executing Ansible Deployment"
ansible-playbook --inventory=hosts playbook.yml --limit "$SITE" -e "tag=$TAG" echo "========================================================"
# The play configures rootless Podman Quadlet units that belong to the
# connecting user, so it must connect as that user and must NOT become root.
# `-u` is the account that owns ~/.config/containers/systemd on the target.
deploy_user="${ANSIBLE_USER:-tanshu}"
cd ansible || exit
# image_tag pins the exact commit; rollback is -e image_tag=<older-commit>.
# 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.
if [ -n "${SITE:-}" ]; then
ansible-playbook -u "$deploy_user" --inventory=hosts playbook.yml --limit "$SITE" -e "image_tag=$TAG"
else else
echo "==> deploying every Site" ansible-playbook -u "$deploy_user" --inventory=hosts playbook.yml -e "image_tag=$TAG"
ansible-playbook --inventory=hosts playbook.yml -e "tag=$TAG"
fi fi
+12 -9
View File
@@ -2,8 +2,9 @@
Each Site is deployed as a **rootless Podman** container managed by a systemd Each Site is deployed as a **rootless Podman** container managed by a systemd
user unit (Quadlet) on `emily`, reached through the one shared Caddy unit rather user unit (Quadlet) on `emily`, reached through the one shared Caddy unit rather
than a long-lived rootful Docker container. The image is built by `podman` too, than a long-lived rootful Docker container. The image is built by **Podman** too,
from a `Containerfile`. This matches how every other service on that host is from the committed tree, the same way the sibling `hops` and `greatbear` apps are
built and deployed. This matches how every other service on that host is
deployed, and it takes the deployment off Docker entirely. deployed, and it takes the deployment off Docker entirely.
The app owns its whole serving path, so it needed the smallest deployment of the The app owns its whole serving path, so it needed the smallest deployment of the
@@ -19,13 +20,15 @@ and never has to be edited when a Site is added or removed.
**Considered Options**: staying on rootful Docker on `monoco`, rejected because **Considered Options**: staying on rootful Docker on `monoco`, rejected because
the fleet is moving and leaving one app behind means two runtimes and two Caddy the fleet is moving and leaving one app behind means two runtimes and two Caddy
configs to reason about; running rootful Podman, rejected because it keeps the configs to reason about; running rootful Podman, rejected because it keeps the
same root-owned data and loses the plain-backup property; and naming the Quadlet same root-owned data and loses the plain-backup property; and keeping the
unit after the container (`menu-app.<domain>`), rejected because the dots make Docker-era container name `menu-app.<domain>`, rejected because a name with dots
the unit name awkward and the name fragile in DNS — the unit is `menu-<site>` in it is a liability in DNS and it would have meant a second name (a network
while the container keeps its old name for the scripts that look it up. alias) for Caddy to proxy to. The inventory name is now the single slug for
everything — container, network and data directory are all `menu-<site>`, so the
two commands an operator needs are the same across all three menu apps.
**Consequences**: the app is not published on a host port at all — only Caddy, **Consequences**: the app is not published on a host port at all — only Caddy, on
on the shared Podman network, can reach it — and deployment, logs and restarts the shared Podman network, can reach it — and deployment, logs and restarts move
move from `docker` to `systemctl --user` / `podman`. The data root moves to from `docker` to `systemctl --user` / `podman`. The data root moves to
`~/podman-data/menu-<site>`, so each Site's content has to be copied across from `~/podman-data/menu-<site>`, so each Site's content has to be copied across from
`/var/lib/menu.<domain>` once, before DNS points at it. `/var/lib/menu.<domain>` once, before DNS points at it.
+8 -8
View File
@@ -12,7 +12,7 @@ this path.
| Thing | Where | | Thing | Where |
| --------------- | ----------------------------------------------------------------------------------------- | | --------------- | ----------------------------------------------------------------------------------------- |
| The app | user unit `menu-<site>.service` on `emily`, container `menu-app.<site>`, image `registry.tanshu.com/menu-admin:<tag>` | | The app | user unit `menu-<site>.service` on `emily`, container `menu-<site>`, image `registry.tanshu.com/menu-admin:<commit>` |
| Menu content | `~/podman-data/menu-<site>/data` — `originals/`, `drafts/`, `published/` | | Menu content | `~/podman-data/menu-<site>/data` — `originals/`, `drafts/`, `published/` |
| Admin | `https://<vhost>/admin` | | Admin | `https://<vhost>/admin` |
| The password | `~/podman-data/menu-<site>/site.env` (mode 600, never in git) | | The password | `~/podman-data/menu-<site>/site.env` (mode 600, never in git) |
@@ -52,7 +52,7 @@ HASH=$(printf '%s' "$PW" | node -e "let s='';process.stdin.setEncoding('utf8');p
SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))") SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")
printf 'ADMIN_PASSWORD_HASH=%s printf 'ADMIN_PASSWORD_HASH=%s
SESSION_SECRET=%s SESSION_SECRET=%s
' "$HASH" "$SECRET" | ssh emily "podman run --rm -i --userns=keep-id:uid=1000,gid=1000 -v ~/podman-data/menu-hopsngrains:/data --entrypoint sh registry.tanshu.com/menu-admin:2026-09-22 -c 'umask 077 && cat > /data/site.env'" ' "$HASH" "$SECRET" | ssh emily "podman run --rm -i --userns=keep-id:uid=1000,gid=1000 -v ~/podman-data/menu-hopsngrains:/data --entrypoint sh registry.tanshu.com/menu-admin:<commit> -c 'umask 077 && cat > /data/site.env'"
ssh emily systemctl --user restart menu-hopsngrains ssh emily systemctl --user restart menu-hopsngrains
``` ```
@@ -70,7 +70,7 @@ the icon (under 256 KB; the upload replaces the bundled mark and "Remove the
uploaded icon" brings it back). None of this changes your web addresses. uploaded icon" brings it back). None of this changes your web addresses.
**Change the colours.** Those are still `config/<site>.site.json`, so they need a **Change the colours.** Those are still `config/<site>.site.json`, so they need a
rebuild and push (`./deploy.sh <tag>`) and the playbook with the new tag. new build and push (`./deploy.sh`).
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its **Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
Original survives, the app can rebuild them: Original survives, the app can rebuild them:
@@ -79,7 +79,7 @@ Original survives, the app can rebuild them:
podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \ podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \ -e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
-v ~/podman-data/menu-hopsngrains:/data \ -v ~/podman-data/menu-hopsngrains:/data \
--entrypoint node registry.tanshu.com/menu-admin:<tag> src/cli/repair.ts --entrypoint node registry.tanshu.com/menu-admin:<commit> src/cli/repair.ts
``` ```
`src/cli/verify.ts` does the same inventory without changing anything, and is `src/cli/verify.ts` does the same inventory without changing anything, and is
@@ -92,15 +92,15 @@ they would see the files as a different user and fail to write.
| --- | --- | | --- | --- |
| Landing page says the menus are being updated | no Section has a Current Menu Set — publish one in the admin | | Landing page says the menus are being updated | no Section has a Current Menu Set — publish one in the admin |
| A Section 404s | that Section has no Current set, or the URL is misspelled | | A Section 404s | that Section has no Current set, or the URL is misspelled |
| Sign-in rejects the right password | `podman logs menu-app.<site>`, then check `site.env` exists and is owned by `tanshu` | | Sign-in rejects the right password | `podman logs menu-<site>`, then check `site.env` exists and is owned by `tanshu` |
| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `systemctl --user restart menu-<site>` | | Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `systemctl --user restart menu-<site>` |
| A photo uploads but looks wrong | the browser shrinks to 3000px before upload; that is expected, and the Original is kept | | A photo uploads but looks wrong | the browser shrinks to 3000px before upload; that is expected, and the Original is kept |
| The container will not start | `systemctl --user status menu-<site>` and `podman logs menu-app.<site>` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust | | The container will not start | `systemctl --user status menu-<site>` and `podman logs menu-<site>` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
| The unit is "not found" | its `.network` file is missing, so Quadlet refused to generate it: check `~/.config/containers/systemd/` | | The unit is "not found" | its `.network` file is missing, so Quadlet refused to generate it: check `~/.config/containers/systemd/` |
| Caddy reload failed during a deploy | nothing is down: Caddy validates before applying. Read the error in the playbook output | | Caddy reload failed during a deploy | nothing is down: Caddy validates before applying. Read the error in the playbook output |
| Everything is slow | check the tag is current; a Viewing image is ~1600px, so a Section page should not be megabytes | | Everything is slow | check the tag is current; a Viewing image is ~1600px, so a Section page should not be megabytes |
**Logs.** `podman logs -f menu-app.<site>`, or `journalctl --user -u menu-<site> **Logs.** `podman logs -f menu-<site>`, or `journalctl --user -u menu-<site>
-f` for the unit. The app logs JSON lines, one per event, including every upload, -f` for the unit. The app logs JSON lines, one per event, including every upload,
publish, restore and failed sign-in. publish, restore and failed sign-in.
@@ -129,7 +129,7 @@ to trip over and no `podman unshare` needed.
## Rolling back ## Rolling back
Deploy the previous tag — `./deploy.sh <previous-tag>` — which rebuilds the Site Deploy the previous tag — `./deploy.sh <previous-tag>` — which rebuilds the Site
at that version. `podman logs menu-app.<site>` is where a container that will not at that version. `podman logs menu-<site>` is where a container that will not
start says why. start says why.
The Docker-era container was left on `monoco`, stopped and not removed, so the The Docker-era container was left on `monoco`, stopped and not removed, so the
+2 -1
View File
@@ -136,5 +136,6 @@ echo
echo "ok ${dst_sum:0:12} $(ssh -n "$EMILY" "du -sb '$DEST'" | awk '{print $1}') bytes, all owned by $user" echo "ok ${dst_sum:0:12} $(ssh -n "$EMILY" "du -sb '$DEST'" | awk '{print $1}') bytes, all owned by $user"
echo echo
echo "next:" echo "next:"
echo " cd ansible && ansible-playbook --inventory=hosts playbook.yml --limit $SITE -e tag=<tag>" echo " ./deploy.sh # builds HEAD and deploys every Site"
echo " SITE=$SITE ./deploy.sh # or just this one"
echo " then check the Site on a Host: override before moving DNS to $EMILY" echo " then check the Site on a Host: override before moving DNS to $EMILY"
+5 -5
View File
@@ -50,16 +50,16 @@ if ! command -v podman >/dev/null 2>&1; then
fi fi
# The image is only needed to borrow node for hashing, so take the one an app # The image is only needed to borrow node for hashing, so take the one an app
# container is already running rather than guessing a tag. The playbook names # container is already running rather than guessing a commit tag. The playbook
# every Site's container menu-app.<domain>. All Sites share the image, so which # names every Site's container and unit menu-<site>. All Sites share the image,
# one answers does not matter. # so which one answers does not matter.
if [[ -z "$IMAGE" ]]; then if [[ -z "$IMAGE" ]]; then
IMAGE="$(podman ps --filter 'name=^menu-app\.' --format '{{.Image}}' | head -n 1 || true)" IMAGE="$(podman ps --filter 'name=^menu-' --format '{{.Image}}' | head -n 1 || true)"
fi fi
if [[ -z "$IMAGE" ]]; then if [[ -z "$IMAGE" ]]; then
echo "could not tell which image to use: no running app container to borrow one from." >&2 echo "could not tell which image to use: no running app container to borrow one from." >&2
echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<tag>" >&2 echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<commit>" >&2
exit 1 exit 1
fi fi
+1 -1
View File
@@ -51,7 +51,7 @@ describe('every Site in the inventory is complete', () => {
// quoted, which is what a first deploy got wrong. // quoted, which is what a first deploy got wrong.
assert.match(varsRaw, /^title: /m, `${name}: ${varFile} has no title`); assert.match(varsRaw, /^title: /m, `${name}: ${varFile} has no title`);
assert.match(varsRaw, /^host: 'menu\.\{\{ title \}\}'/m, `${name}: host is not quoted`); assert.match(varsRaw, /^host: 'menu\.\{\{ title \}\}'/m, `${name}: host is not quoted`);
assert.match(varsRaw, /^app_root: /m, `${name}: no app_root`); assert.match(varsRaw, /^host_directory: /m, `${name}: no host_directory`);
} }
}); });