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