From f2d8058456fd1ce39c3247b9f6c1f3732413af23 Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 23:27:17 +0530 Subject: [PATCH] Deploy in one run, with nothing older to preserve There is no previous system kept serving, so the playbook no longer knows about one: the retire flags, the temporary web address, the task that removed the old nginx snippet and the task that stopped the old container are gone. One run starts the container and points the Site's web address at it, and deploying an earlier tag is the rollback. The vars lose vhost, retire_nginx_snippet, retire_nginx_container and legacy_container; the Caddy role keeps only the snippet upsert. The deployment readme drops its staged-rollout sections and the glossary of words that no longer exist, and the plan's sequence and the operating notes describe the single run instead. --- README.md | 4 +- ansible/playbook.yml | 6 +- ansible/readme.md | 100 ++++++++-------------------- ansible/roles/caddy/tasks/main.yaml | 27 ++------ ansible/vars/default.yml | 13 +--- ansible/vars/hopsngrains.yml | 13 +--- docs/operating.md | 98 +++++++++++++++++++++++++++ docs/plan.md | 22 +++--- 8 files changed, 152 insertions(+), 131 deletions(-) diff --git a/README.md b/README.md index eec990b..dd06f4e 100644 --- a/README.md +++ b/README.md @@ -23,8 +23,8 @@ SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only 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; see `ansible/readme.md` for the first run, cutover and -rollback. +inventory entry per Site; see `ansible/readme.md` for how a deploy runs and how to +roll one back. ## Layout diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 6a8d037..91031c7 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -5,9 +5,9 @@ # make build-production TAG= (from the repo root) # ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains # -# The first run targets a temporary vhost (menu-new...) so the new site can be -# checked against the live one. Cutover is the same playbook with the real vhost -# and the two retire flags set; see readme.md. +# One run deploys the Site: it starts the container and points the Site's web +# address at it. Nothing older is kept serving, so there is no staged rollout — +# deploying an earlier tag is the rollback. - hosts: menus become: true vars_files: diff --git a/ansible/readme.md b/ansible/readme.md index 462a44d..f07c750 100644 --- a/ansible/readme.md +++ b/ansible/readme.md @@ -1,44 +1,20 @@ # The menu deployment -Lives here, beside the app it deploys, the way the other apps on this machine do. -It owns the `menu-app` container and the Site's snippet in the shared Caddyfile. - -## Words used below - -The deployment reads as jargon if you have not done one before, so: - -- **web address** — one hostname the web server answers for. Caddy keeps a block - per address; `menu.hopsngrains.com` is one. Older writing calls this a - _vhost_, which just means "virtual host", and `vhost` is the variable name in - `vars/`. -- **parallel run** — putting the app on a second, temporary address - (`menu-new.hopsngrains.com`) while the old site keeps the real one, so the new - one can be checked on a phone without customers seeing it. It needs a DNS - record for the temporary name, because Caddy has to fetch a certificate for it. -- **cutover** — the moment the real address is pointed at the new app instead of - the old nginx container, and that container is stopped. Customers are on the - new app from then on. It is reversible. - -| Stage | Address customers use | Address you check | -| ------------- | ---------------------------------- | ------------------------------------- | -| today | `menu.hopsngrains.com` → old nginx | — | -| parallel run | unchanged | `menu-new.hopsngrains.com` → this app | -| after cutover | `menu.hopsngrains.com` → this app | — | - -Skipping the parallel run is a legitimate choice: cutover on its own is one -command, one Caddy reload, and a rollback if anything looks wrong. The parallel -run is for finding out that a container will not start, or a font will not load, -without anyone watching. +Lives here, beside the app it deploys. One run deploys a Site: it starts the +app's container and points the Site's web address at it. 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. ## What lives where -| Thing | Where | -| -------------------------------------- | --------------------------------------------------------------------------- | -| Name, tagline, icon, categories, menus | on the server, under `/var/lib/menu-app./data` — the admin edits them | -| Theme colours | `config/.site.json` in the repo, baked into the image | -| Admin password hash, session secret | `/var/lib/menu-app./site.env` (untracked, mode 600) | -| Which image tag runs | `-e tag=` | -| Which Site is being deployed | the inventory entry in `hosts` | +| Thing | Where | +| -------------------------------------- | -------------------------------------------------------------------------- | +| Name, tagline, icon, categories, menus | on the server under `/var/lib/menu-app./data` — the admin edits them | +| Theme colours | `config/.site.json` in the repo, baked into the image | +| Admin password hash, session secret | `/var/lib/menu-app./site.env` (untracked, mode 600) | +| Which image tag runs | `-e tag=` | +| Which Site is deployed | the inventory entry in `hosts` | +| The web address it serves | `host:` in `vars/.yml` | ## Deploying @@ -66,50 +42,27 @@ Every Site runs on the same box, so `hosts` has one entry per Site pointing at the same `ansible_host` with its own `var_file`. `ansible_host` is written as the resolvable name rather than an ssh alias, so the inventory works whatever a machine's `~/.ssh/config` says. (`ssh` on some machines complains about -`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that -is a local file-permission bug with the fix `sudo chown -R root:root +`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that is +a local file-permission bug whose fix is `sudo chown -R root:root /etc/ssh/ssh_config.d`, not something this playbook works around.) -## The first run, and cutover +Adding a Site is one line in `hosts` and a copy of `vars/hopsngrains.yml`. -The first run targets a temporary vhost, so the live site keeps serving: +## Checking it landed ```sh -ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \ - -e tag=2026-09-22 -e vhost=menu-new.hopsngrains.com +npm run check-site -- https://menu.hopsngrains.com ``` -`menu-new.hopsngrains.com` needs a DNS A record pointing at the same address as -the live site before this runs: Caddy fetches a certificate for the name, which -needs it to resolve publicly. - -Then, from the outside (read-only, exits non-zero on failure): - -```sh -npm run check-site -- https://menu-new.hopsngrains.com -``` - -And from a phone on restaurant wifi: the landing page, a Section page, the page -counter, tap-to-zoom, and signing into `/admin` to publish something. - -Cutover is the same playbook with the real vhost and the two retire flags: - -```sh -ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \ - -e tag=2026-09-22 -e vhost=menu.hopsngrains.com \ - -e retire_nginx_snippet=true -e retire_nginx_container=true -``` - -That repoints the real vhost at the app, removes the snippet the old `nginx-menu` -playbook wrote for that hostname, and stops the old container. The old container -and `/var/lib/menu.hopsngrains.com` are kept, not deleted: deleting 319 MB of the -only copy of the old menus is a human decision, made once the new site has been -trusted. +Read-only, exits non-zero on failure. Then from a phone: the landing page, a +Section page, the page counter, tap-to-zoom, and signing into `/admin` to +publish something. ## The admin password -The app starts with the admin disabled when `/var/lib/menu-app./site.env` -is missing, and the playbook says so. Create it once per Site: +The app starts with the admin disabled while `/var/lib/menu-app./site.env` +is missing, and the playbook says so rather than failing. Create it once per +Site: ```sh scp scripts/provision-site-env.sh monoco:/tmp/ @@ -119,9 +72,10 @@ ssh monoco docker restart menu-app.hopsngrains.com ## Rolling back +Deploy the previous tag: + ```sh -ssh monoco docker start nginx-menu.hopsngrains.com +./deploy.sh ``` -then point Caddy back at it — the cutover flags above with -`retire_nginx_snippet=false`. +`docker logs menu-app.` is where a failed container says why. diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml index 709d0fe..e539d3e 100644 --- a/ansible/roles/caddy/tasks/main.yaml +++ b/ansible/roles/caddy/tasks/main.yaml @@ -7,33 +7,16 @@ - name: Fail when Caddy is not where we expect it fail: msg: >- - {{ caddyfile_path }} is missing. Deploy the caddy playbook (which owns the - Caddy container and its config) before this one. + {{ caddyfile_path }} is missing. Deploy the caddy playbook, which owns the + Caddy container and its config, before this one. when: not menu_caddyfile.stat.exists -# blockinfile replaces whatever sits between its markers, so changing `vhost` -# updates the snippet in place. The sibling nginx-menu role only appended a -# block when its exact text was absent, which cannot change a proxy target. -- name: Point Caddy at the app +# 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 blockinfile: path: '{{ caddyfile_path }}' marker: '# {mark} ANSIBLE MANAGED: menu app ({{ site }})' block: '{{ caddy_snippet }}' create: no notify: Reload Caddy - -- name: Remove the snippet the nginx-menu playbook wrote for this vhost - # A different marker, written by a different role. Leaving it in place would - # leave Caddy with two blocks for the same hostname. - blockinfile: - path: '{{ caddyfile_path }}' - marker: '# {mark} Ansible managed Caddy snippet for {{ host }}' - state: absent - when: retire_nginx_snippet | bool - notify: Reload Caddy - -- name: Stop the old nginx container - docker_container: - name: '{{ legacy_container }}' - state: stopped - when: retire_nginx_container | bool diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index 5077460..ab4ba28 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -2,21 +2,12 @@ # Defaults shared by every Site. Each Site's own file is named by the `var_file` # in ansible/hosts, and its slug is its inventory name. -# Which Site config inside the image to run, and therefore which vhost it is -# for. The file lives in the app repo at config/.site.json. +# Which Site config inside the image to run, and which web address it serves. site: '{{ inventory_hostname }}' image_name: registry.tanshu.com/menu-admin image_tag: "{{ tag | default('latest') }}" -# The hostname Caddy serves this Site on. The parallel run uses a temporary -# name; cutover points the real one at the app. -vhost: '{{ host }}' - -# Cutover switches. Both stay off until the parallel run has been checked. -retire_nginx_snippet: false -retire_nginx_container: false - app_uid: 1000 app_gid: 1000 @@ -24,6 +15,6 @@ caddy_container: caddy caddyfile_path: /var/lib/caddy/conf/Caddyfile caddy_snippet: | - {{ vhost }} { + {{ host }} { reverse_proxy {{ docker_container }}:3000 } diff --git a/ansible/vars/hopsngrains.yml b/ansible/vars/hopsngrains.yml index 038603a..921110a 100644 --- a/ansible/vars/hopsngrains.yml +++ b/ansible/vars/hopsngrains.yml @@ -1,18 +1,11 @@ --- -# Hops n Grains. The name, icon, categories and theme live in the app repo -# (config/hopsngrains.site.json seeds the theme and `data/` holds the rest), so -# this file only says where to run it. +# 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. host: menu.hopsngrains.com docker_container: menu-app.hopsngrains.com docker_network: '{{ docker_container }}_net' -# First deployment: a temporary hostname beside the existing site. -vhost: menu-new.hopsngrains.com - # Everything the app writes — data/ and the untracked site.env. app_root: /var/lib/menu-app.hopsngrains.com - -# The old nginx container serving /var/lib/menu.hopsngrains.com. Left alone -# until cutover. -legacy_container: nginx-menu.hopsngrains.com diff --git a/docs/operating.md b/docs/operating.md index 6071668..4e70f69 100644 --- a/docs/operating.md +++ b/docs/operating.md @@ -1,5 +1,103 @@ # Operating the menus +For whoever is on the other end of "the menu is wrong". The design is in +`plan.md`, the vocabulary in `CONTEXT.md`, and the deployment in +`ansible-playbooks/menu-admin/readme.md`. + +## Where things are + +| Thing | Where | +| --- | --- | +| The app | container `menu-app.` on `monoco`, image `registry.tanshu.com/menu-admin:` | +| Menu content | `/var/lib/menu-app./data` — `originals/`, `drafts/`, `published/` | +| Admin | `https:///admin` | +| The password | `/var/lib/menu-app./site.env` (mode 600, never in git) | +| Categories | `data/sections.json`, edited in the admin | +| Brand and theme | `config/.site.json` in this repo, baked into the image (the palette follows the Stitch system in `docs/design/stitch/`) | + +## Everyday tasks + +**Publish a new menu.** Sign in at `/admin`, find the Section, fill in "New Menu +Set" (label, optional valid-until date), Create Draft, then drop the photos in. +Drag to reorder — the order in the editor is the order customers see. Press +Preview to check it, then Publish. Publishing archives whatever was current. + +**Put an older menu back.** Open the Section's "older menus" fold in the admin, +open the set you want and press "Make current". Its URL does not change. + +**Change the admin password.** On the server: + +```sh +bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com --force +docker restart menu-app.hopsngrains.com +``` + +**Add, rename, reorder or remove a category.** In the admin, open the +**Categories** fold at the bottom of the dashboard. A new category starts empty, +so customers do not see it until you publish a menu into it. Reordering there is +the order customers see. Two rules: a category's web address is fixed when it is +created (rename the name as often as you like — a printed QR code keeps working), +and a category can only be removed once it holds no menus. + +**Change the restaurant's name or tagline, the icon, or the published dates.** In +the admin, open the **Site identity** fold: the name and tagline customers see, +a checkbox for whether a menu says when it was published, and an SVG upload for +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. + +**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its +Original survives, the app can rebuild them: + +```sh +docker run --rm --user 1000:1000 -e MENU_ROOT=/data \ + -e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \ + -v /var/lib/menu-app.hopsngrains.com:/data \ + --entrypoint node registry.tanshu.com/menu-admin: src/cli/repair.ts +``` + +`src/cli/verify.ts` does the same inventory without changing anything, and is +what the server refuses to boot on. + +## When something looks wrong + +| Symptom | Look here | +| --- | --- | +| 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 | `docker logs menu-app.`, then check `site.env` exists and is owned by the app user | +| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `docker restart` | +| 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 | `docker logs` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust | +| 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.** `docker logs -f menu-app.` — JSON lines, one per event, including +every upload, publish, restore and failed sign-in. + +**Checking from outside.** No sign-in, no writes, exits non-zero on failure, so +it can gate a cutover: + +```sh +npm run check-site -- https://menu.hopsngrains.com +``` + +## Backups + +The app is the only writer. The irreplaceable part is +`/var/lib/menu-app./data/originals` — the photos exactly as uploaded, from +which everything else can be regenerated with `repair`. Back that directory up; +`site.env` is worth keeping too (otherwise you re-provision the password). + +## Rolling back + +Deploy the previous tag — `./deploy.sh ` — which rebuilds the Site at +that version. `docker logs menu-app.` is where a container that will not start +says why. + + For whoever is on the other end of "the menu is wrong". The design is in `plan.md`, the vocabulary in `CONTEXT.md`, and the deployment in `ansible-playbooks/menu-admin/readme.md`. diff --git a/docs/plan.md b/docs/plan.md index d62ac99..846ddde 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -123,16 +123,18 @@ The look is the Atelier Dining & Hospitality system from Stitch (`docs/design/st ## Sequence -1. Commit `nginx-menu/` to git so the current state is reproducible and revertible; create the `menu-admin` repo and move `CONTEXT.md`, `docs/`, and the ADRs into it. -2. Storage layer: config, manifest read/write, atomic writes, boot validation, tests. -3. Image pipeline: sharp variants, content-hashed filenames, tests. -4. Public renderer: landing, Section and archive routes, design tokens and CSS. -5. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete. -6. Container, compose dev fixtures, Ansible role, deploy to `menu-new.hopsngrains.com`, verify from a phone on restaurant wifi. -7. Cutover: re-upload the current Food, Bar and Delivery menus, create What's On from the posters, switch `menu.hopsngrains.com` to the app, stop the old nginx container. -8. After a week: remove the old container and `/var/lib/menu.hopsngrains.com`, delete the dated folders, drop the dead `Caddyfile`, and reduce this folder to dev fixtures. - -Deleting the old 319MB directory is the only irreversible step, so it is last. +1. Storage layer: config, manifest read/write, atomic writes, boot validation, tests. +2. Image pipeline: sharp variants, content-hashed filenames, tests. +3. Public renderer: landing, Section and archive routes, design tokens and CSS. +4. Admin: login, upload with browser pre-shrink, ordering, preview, publish/restore/delete. +5. Container, compose dev fixtures, Ansible role. +6. Deploy: `make build-production TAG=` builds a multi-arch podman manifest and + pushes it; the playbook in `ansible/` runs one Site to completion — it starts the + container and points the Site's web address at it. There is no older system kept + serving and nothing to retire, so there is no staged rollout; deploying an earlier + tag is the rollback. +7. Create the admin password once per Site (`scripts/provision-site-env.sh`), then + `npm run check-site` and a phone. ## Out of scope