diff --git a/.containerignore b/.containerignore index baa251c..a78d48f 100644 --- a/.containerignore +++ b/.containerignore @@ -8,7 +8,7 @@ public/fonts/OFL.txt docs test compose.yaml -Containerfile +Dockerfile .containerignore README.md # design prototype, never committed to main and never shipped diff --git a/Containerfile b/Dockerfile similarity index 86% rename from Containerfile rename to Dockerfile index e18af39..825a5d8 100644 --- a/Containerfile +++ b/Dockerfile @@ -1,5 +1,6 @@ -# Built with podman. `podman build` reads a Containerfile as readily as a -# Dockerfile, and the name says which toolchain the pipeline actually uses. +# Built with Podman/Buildah, not Docker — see the Makefile. The file keeps the +# 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 # compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt diff --git a/Makefile b/Makefile index d4074d5..7f6760f 100644 --- a/Makefile +++ b/Makefile @@ -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 -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 -# carries both architectures, pushed to the private registry. -build-production: ## Build and push the production image. Usage: make build-production TAG=2026-09-22 - @test -n "$(TAG)" || { echo "Usage: make build-production TAG=2026-09-22" >&2; exit 1; } - @echo "Building image..." - @podman manifest rm $(IMAGE):latest 2>/dev/null || true - @podman build --platform $(PLATFORMS) --manifest $(IMAGE):latest --pull . - @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 . +.PHONY: build-check +build-check: ## Compile check from HEAD with podman, nothing pushed + @echo "==> podman build --platform $(PLATFORM) (no push)" + git archive --format=tar HEAD | podman build \ + --platform $(PLATFORM) \ + --tag localhost/menu-admin:check \ + --pull=always \ + - diff --git a/README.md b/README.md index 2e3e59d..97a3005 100644 --- a/README.md +++ b/README.md @@ -17,17 +17,24 @@ and moving DNS, which needs a human present. ## Deploying ```sh -./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy -SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only +./deploy.sh # push, build , deploy every Site to emily +./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 -`registry.tanshu.com/menu-admin` (`Makefile`, mirrored from the other apps on -this machine). 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 -services in `ansible-playbooks` do; see `ansible/readme.md` for how a deploy runs -and how to roll one back. +The image is built with **Podman/Buildah** (not Docker) from the committed tree +(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host +is exactly what git knows about; `deploy.sh` refuses a dirty tracked tree and +requires `HEAD == origin/main`. It is built for **linux/arm64** because `emily` +is aarch64, and pushed to `registry.tanshu.com/menu-admin`. + +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 @@ -50,9 +57,9 @@ test/ node:test suites docs/plan.md The agreed plan docs/operating.md Publishing, fixing and rolling back, for whoever operates it docs/adr/ Decisions worth remembering -Containerfile The image, built by podman +Dockerfile The image, built by podman 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 ``` diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 2ee898e..4eaa0da 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -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 (repo root) -# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e 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= - name: Deploy the menu app as rootless Podman Quadlets hosts: menus gather_facts: true diff --git a/ansible/readme.md b/ansible/readme.md index df90600..0e35726 100644 --- a/ansible/readme.md +++ b/ansible/readme.md @@ -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.json` in the repo, baked into the image | | The unit that runs | `~/.config/containers/systemd/menu-.container` | | The network and Caddy drop-in | `menu-.network`, `caddy.container.d/50-menu-.conf` | -| Which image tag runs | `-e tag=` | +| Which image tag runs | `-e image_tag=` | | Which Site is deployed | the inventory entry in `hosts` | | The web address it serves | `host:` in `vars/.yml` | -`` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`). The -container itself keeps the Docker-era name `menu-app.`, because -`provision-site-env.sh` and muscle memory look it up that way; the Caddyfile -proxies to the dotless `menu-` alias, because a container name with dots in -it is a liability in DNS. +`` 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-`. That is how the sibling `hops` and `greatbear` +apps are deployed, and it keeps `podman logs menu-` and +`systemctl --user status menu-` 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 , 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=`, 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=`, 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.json`. The image is shared, so the config has to be in it: a -new Site needs a new tag. +`config/.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 +cd ansible +ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag= ``` -`podman logs menu-app.` and `journalctl --user -u menu-` are where a +`podman logs menu-` and `journalctl --user -u menu-` 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. diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml index 87b0911..a539678 100644 --- a/ansible/roles/caddy/tasks/main.yaml +++ b/ansible/roles/caddy/tasks/main.yaml @@ -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' diff --git a/ansible/roles/menu-app/tasks/main.yaml b/ansible/roles/menu-app/tasks/main.yaml index e5523cc..5007b40 100644 --- a/ansible/roles/menu-app/tasks/main.yaml +++ b/ansible/roles/menu-app/tasks/main.yaml @@ -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 diff --git a/ansible/roles/menu-app/templates/menu-app.container.j2 b/ansible/roles/menu-app/templates/menu-app.container.j2 index 33cf9ba..f5ab0e6 100644 --- a/ansible/roles/menu-app/templates/menu-app.container.j2 +++ b/ansible/roles/menu-app/templates/menu-app.container.j2 @@ -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 diff --git a/ansible/roles/register/tasks/main.yaml b/ansible/roles/register/tasks/main.yaml index a5336ec..9fc50e0 100644 --- a/ansible/roles/register/tasks/main.yaml +++ b/ansible/roles/register/tasks/main.yaml @@ -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 .network unit, and +# 1. its own menu-.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. diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index 07f6a17..7a2902a 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -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=; `tag` is the fallback when running the +# play by hand. Rollback = -e image_tag=. +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: '{{ inventory_hostname }}' - -# --------------------------------------------------------------------------- -# The app -# --------------------------------------------------------------------------- -image_name: registry.tanshu.com/menu-admin -image_tag: "{{ tag | default('latest') }}" - -# The unit file is menu-.container and the Podman network is -# menu-_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-_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-_net; no host ports are involved. +snippet_block: | {{ host }} { - reverse_proxy {{ network_name }}:{{ container_port }} + reverse_proxy {{ container_name }}:{{ site_port }} } diff --git a/ansible/vars/greatbear.yml b/ansible/vars/greatbear.yml index 60c3e11..1ff8c4e 100644 --- a/ansible/vars/greatbear.yml +++ b/ansible/vars/greatbear.yml @@ -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 }}' diff --git a/ansible/vars/hngmohali.yml b/ansible/vars/hngmohali.yml index 3bfc7f2..3112c28 100644 --- a/ansible/vars/hngmohali.yml +++ b/ansible/vars/hngmohali.yml @@ -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 }}' diff --git a/ansible/vars/hopsngrains.yml b/ansible/vars/hopsngrains.yml index 0d22fdd..b37df42 100644 --- a/ansible/vars/hopsngrains.yml +++ b/ansible/vars/hopsngrains.yml @@ -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 }}' diff --git a/compose.yaml b/compose.yaml index 9b578af..e3e564c 100644 --- a/compose.yaml +++ b/compose.yaml @@ -4,7 +4,7 @@ # npm run seed && podman compose up --build # # `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: menu: build: . diff --git a/deploy.sh b/deploy.sh index 500ea08..c9f8add 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,57 +1,94 @@ #!/usr/bin/env bash # -# Builds the image with podman, pushes it to the private registry, then deploys -# the Site with the playbook that lives in ansible/. The playbook runs rootless -# Podman Quadlet units on emily, behind the shared Caddy unit from -# ansible-playbooks/caddy. +# menu-admin deploy — push the current commit, build it with podman, and install +# the Quadlet units on emily with ansible. # -# ./deploy.sh 2026-09-22 deploy the default Site -# SITE=greatbear ./deploy.sh 2026-09-22 deploy another Site -# 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 +# The image is built from the committed tree (`git archive HEAD`) and tagged +# with its commit id, so what runs on the host is exactly what git knows about. # +# ./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= deploys one Site instead of every Site. +# +# Log in to the registry once beforehand: podman login registry.tanshu.com set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" -TAG="${1:-}" -if [[ -z "$TAG" ]]; then - echo "usage: $0 (e.g. $(date +%Y-%m-%d) or a git short sha)" >&2 - exit 1 +BRANCH="${BRANCH:-main}" + +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 + ;; + 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 -parent_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) -cd "$parent_path" - -if ! command -v podman >/dev/null 2>&1; then - echo "podman is required to build and push the image" >&2 - exit 1 +# Guards. Only tracked-file state matters — untracked and ignored files never +# 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 [ "$FORCE" != true ]; then + if [ -n "$(git status --porcelain --untracked-files=no)" ]; then + echo "x tracked files modified - commit or stash first (or --force)" >&2 + exit 1 + fi + git fetch origin --quiet + if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then + echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2 + exit 1 + fi fi -if [[ -z "${SKIP_DEPLOY:-}" ]] && ! command -v ansible-playbook >/dev/null 2>&1; then - echo "ansible-playbook is required to deploy; SKIP_DEPLOY=1 builds and pushes only" >&2 - exit 1 +# 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" + npm run lint + npm run format:check + npm run typecheck + npm test fi -if [[ -z "${SKIP_CHECKS:-}" ]]; then - echo "==> lint, format, types and tests" - npm run lint - npm run format:check - npm run typecheck - npm test -fi +TAG=$(git rev-parse --short HEAD) +echo "==> deploying commit $TAG" -echo "==> building and pushing registry.tanshu.com/menu-admin:$TAG" -make build-production TAG="$TAG" +echo "========================================================" +echo " Building & Uploading Container Image (Podman)" +echo "========================================================" +make build-production COMMIT="$TAG" -if [[ -n "${SKIP_DEPLOY:-}" ]]; then - echo "SKIP_DEPLOY is set, so the playbook was not run." - exit 0 -fi - -cd ansible -if [[ -n "${SITE:-}" ]]; then - echo "==> deploying $SITE" - ansible-playbook --inventory=hosts playbook.yml --limit "$SITE" -e "tag=$TAG" +echo "" +echo "========================================================" +echo " Executing Ansible Deployment" +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=. +# 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 - echo "==> deploying every Site" - ansible-playbook --inventory=hosts playbook.yml -e "tag=$TAG" + ansible-playbook -u "$deploy_user" --inventory=hosts playbook.yml -e "image_tag=$TAG" fi diff --git a/docs/adr/0009-rootless-podman-quadlets.md b/docs/adr/0009-rootless-podman-quadlets.md index 336f629..6022d56 100644 --- a/docs/adr/0009-rootless-podman-quadlets.md +++ b/docs/adr/0009-rootless-podman-quadlets.md @@ -2,8 +2,9 @@ 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 -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 +than a long-lived rootful Docker container. The image is built by **Podman** too, +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. 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 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 -same root-owned data and loses the plain-backup property; and naming the Quadlet -unit after the container (`menu-app.`), rejected because the dots make -the unit name awkward and the name fragile in DNS — the unit is `menu-` -while the container keeps its old name for the scripts that look it up. +same root-owned data and loses the plain-backup property; and keeping the +Docker-era container name `menu-app.`, rejected because a name with dots +in it is a liability in DNS and it would have meant a second name (a network +alias) for Caddy to proxy to. The inventory name is now the single slug for +everything — container, network and data directory are all `menu-`, 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, -on the shared Podman network, can reach it — and deployment, logs and restarts -move from `docker` to `systemctl --user` / `podman`. The data root moves to +**Consequences**: the app is not published on a host port at all — only Caddy, on +the shared Podman network, can reach it — and deployment, logs and restarts move +from `docker` to `systemctl --user` / `podman`. The data root moves to `~/podman-data/menu-`, so each Site's content has to be copied across from `/var/lib/menu.` once, before DNS points at it. diff --git a/docs/operating.md b/docs/operating.md index df480e1..9ba7c8f 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -12,7 +12,7 @@ this path. | Thing | Where | | --------------- | ----------------------------------------------------------------------------------------- | -| The app | user unit `menu-.service` on `emily`, container `menu-app.`, image `registry.tanshu.com/menu-admin:` | +| The app | user unit `menu-.service` on `emily`, container `menu-`, image `registry.tanshu.com/menu-admin:` | | Menu content | `~/podman-data/menu-/data` — `originals/`, `drafts/`, `published/` | | Admin | `https:///admin` | | The password | `~/podman-data/menu-/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'))") printf 'ADMIN_PASSWORD_HASH=%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: -c 'umask 077 && cat > /data/site.env'" 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. **Change the colours.** Those are still `config/.site.json`, so they need a -rebuild and push (`./deploy.sh `) 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 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 \ -e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \ -v ~/podman-data/menu-hopsngrains:/data \ - --entrypoint node registry.tanshu.com/menu-admin: src/cli/repair.ts + --entrypoint node registry.tanshu.com/menu-admin: src/cli/repair.ts ``` `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 | | A Section 404s | that Section has no Current set, or the URL is misspelled | -| Sign-in rejects the right password | `podman logs menu-app.`, then check `site.env` exists and is owned by `tanshu` | +| Sign-in rejects the right password | `podman logs menu-`, 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-` | | 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-` and `podman logs menu-app.` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust | +| The container will not start | `systemctl --user status menu-` and `podman logs menu-` — 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/` | | 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 | -**Logs.** `podman logs -f menu-app.`, or `journalctl --user -u menu- +**Logs.** `podman logs -f menu-`, or `journalctl --user -u menu- -f` for the unit. The app logs JSON lines, one per event, including every upload, publish, restore and failed sign-in. @@ -129,7 +129,7 @@ to trip over and no `podman unshare` needed. ## Rolling back Deploy the previous tag — `./deploy.sh ` — which rebuilds the Site -at that version. `podman logs menu-app.` is where a container that will not +at that version. `podman logs menu-` is where a container that will not start says why. The Docker-era container was left on `monoco`, stopped and not removed, so the diff --git a/scripts/copy-site-data.sh b/scripts/copy-site-data.sh index a4f5fd2..bb7321e 100755 --- a/scripts/copy-site-data.sh +++ b/scripts/copy-site-data.sh @@ -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 echo "next:" -echo " cd ansible && ansible-playbook --inventory=hosts playbook.yml --limit $SITE -e 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" diff --git a/scripts/provision-site-env.sh b/scripts/provision-site-env.sh index 17dac89..c8ca7ce 100755 --- a/scripts/provision-site-env.sh +++ b/scripts/provision-site-env.sh @@ -50,16 +50,16 @@ if ! command -v podman >/dev/null 2>&1; then fi # 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 -# every Site's container menu-app.. All Sites share the image, so which -# one answers does not matter. +# container is already running rather than guessing a commit tag. The playbook +# names every Site's container and unit menu-. All Sites share the image, +# so which one answers does not matter. 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 if [[ -z "$IMAGE" ]]; then 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:" >&2 + echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:" >&2 exit 1 fi diff --git a/test/site-configs.test.ts b/test/site-configs.test.ts index cd703cd..f45a245 100644 --- a/test/site-configs.test.ts +++ b/test/site-configs.test.ts @@ -51,7 +51,7 @@ describe('every Site in the inventory is complete', () => { // quoted, which is what a first deploy got wrong. 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, /^app_root: /m, `${name}: no app_root`); + assert.match(varsRaw, /^host_directory: /m, `${name}: no host_directory`); } });