diff --git a/.vscode/extensions.json b/.vscode/extensions.json index d7df89c..c83e263 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"] + "recommendations": ["esbenp.prettier-vscode"] } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 1085401..7e0a9e2 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -13,12 +13,6 @@ "script": "format", "problemMatcher": [] }, - { - "label": "lint", - "type": "npm", - "script": "lint", - "problemMatcher": ["$eslint-stylish"] - }, { "label": "dev: seed placeholder menus", "type": "npm", diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..d4074d5 --- /dev/null +++ b/Makefile @@ -0,0 +1,23 @@ +IMAGE ?= registry.tanshu.com/menu-admin +PLATFORMS ?= linux/amd64,linux/arm64 + +.PHONY: build-production build-check build-check-local + +# 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 . diff --git a/README.md b/README.md index d5ec9b6..7ef3e0c 100644 --- a/README.md +++ b/README.md @@ -16,18 +16,15 @@ which needs a human present. ## Deploying ```sh -./deploy.sh 2026-09-22 # tests, build, push to registry.tanshu.com +./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy +SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only ``` -then the playbook in `ansible-playbooks/menu-admin/` — its readme covers the -parallel run, the `site.env` step that gives the admin a password, and cutover. - -After deploying, check the running site from the outside (read-only, exits -non-zero on failure, so it can gate a cutover): - -```sh -npm run check-site -- https://menu-new.hopsngrains.com -``` +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. ## Layout @@ -85,13 +82,11 @@ npm run verify # integrity of .dev/data npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside ``` -### Linting and formatting +### Formatting ```sh -npm run lint # eslint -npm run lint:fix npm run format # prettier --write -npm run format:check # what CI and deploy.sh run +npm run format:check # what deploy.sh runs ``` Prettier owns formatting (`.prettierrc.json`); `docs/` is excluded because those @@ -99,17 +94,14 @@ are hand-wrapped notes rather than code. `embeddedLanguageFormatting` is off on purpose: the renderers build HTML inside `html` tagged template literals, and letting Prettier reflow those would change the markup customers receive. -**ESLint does not lint the TypeScript, and cannot as things stand.** This project -uses the native TypeScript port (`typescript@7`), which ships no JavaScript -compiler API — and every ESLint TypeScript parser needs one. `typescript-eslint` -declares `typescript >=4.8.4 <6.1.0` as a peer and refuses to install; forcing it -through would leave a parser that cannot read the compiler it was given. The -TypeScript gate is therefore `npm run typecheck` (strict, with `noUnusedLocals`, -`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and -`erasableSyntaxOnly`), and ESLint covers the JavaScript. To get ESLint over the -`.ts` files as well, either pin `typescript@5.9` — which supports everything this -project uses — and add `typescript-eslint`, or add `oxlint`, which parses -TypeScript without the compiler API. +**ESLint is deliberately absent.** This project's `typescript@7` is the native +port and ships no JavaScript compiler API, which every ESLint TypeScript parser +needs — `typescript-eslint` requires `typescript >=4.8.4 <6.1.0` and refuses to +install, and forcing it through leaves a parser that cannot read the compiler. +Linting only the JavaScript was not worth the config, so the TypeScript gate is +`npm run typecheck` (strict, with `noUnusedLocals`, `noUncheckedIndexedAccess`, +`exactOptionalPropertyTypes` and `erasableSyntaxOnly`) and ESLint can come back, +with `typescript-eslint`, once it supports TypeScript 7. Dragging Pages is the one interaction that only a browser can check, so there is a harness for it. It needs `geckodriver` on PATH and a running dev server: diff --git a/ansible/hosts b/ansible/hosts new file mode 100644 index 0000000..5f66e8c --- /dev/null +++ b/ansible/hosts @@ -0,0 +1,15 @@ +# The Ansible inventory for the menus. Every Site runs on the same box, so each +# entry names it and points at the vars file that describes it. +# +# cd ansible +# ansible-playbook --inventory=hosts playbook.yml # every Site +# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains +# +# ansible_host is written as the resolvable name rather than an ssh alias so the +# inventory works on any machine, whatever its ~/.ssh/config says. + +[menus] +hopsngrains ansible_host=monoco.tanshu.com var_file=vars/hopsngrains.yml + +[all:vars] +ansible_python_interpreter=/usr/bin/python3 diff --git a/ansible/playbook.yml b/ansible/playbook.yml new file mode 100644 index 0000000..6a8d037 --- /dev/null +++ b/ansible/playbook.yml @@ -0,0 +1,25 @@ +--- +# Deploys the menu app for one or more Sites. It lives here, beside the app it +# deploys, and is run from this directory: +# +# 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. +- hosts: menus + become: true + vars_files: + - vars/default.yml + + pre_tasks: + - name: Load the vars file this Site names in the inventory + ansible.builtin.include_vars: + file: '{{ var_file }}' + when: var_file is defined + + roles: + - network + - menu-app + - caddy diff --git a/ansible/readme.md b/ansible/readme.md new file mode 100644 index 0000000..eb5ae1c --- /dev/null +++ b/ansible/readme.md @@ -0,0 +1,100 @@ +# 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. + +## 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` | + +## Deploying + +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` 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 the Site +named by `SITE` (default `hopsngrains`). + +By hand: + +```sh +make build-production TAG=2026-09-22 +cd ansible +ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=2026-09-22 +``` + +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`, not something this playbook works around.) + +## The first run, and cutover + +The first run targets a temporary vhost, so the live site keeps serving: + +```sh +ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \ + -e tag=2026-09-22 -e vhost=menu-new.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. + +## 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: + +```sh +scp scripts/provision-site-env.sh monoco:/tmp/ +ssh monoco bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com +ssh monoco docker restart menu-app.hopsngrains.com +``` + +## Rolling back + +```sh +ssh monoco docker start nginx-menu.hopsngrains.com +``` + +then point Caddy back at it — the cutover flags above with +`retire_nginx_snippet=false`. diff --git a/ansible/roles/caddy/handlers/main.yaml b/ansible/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..bd3fe90 --- /dev/null +++ b/ansible/roles/caddy/handlers/main.yaml @@ -0,0 +1,6 @@ +--- +- name: Reload Caddy + # Caddy validates before it applies, so a mistake here leaves the running + # config untouched and fails the play loudly. + command: 'docker exec -w /etc/caddy {{ caddy_container }} caddy reload' + listen: Reload Caddy diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..709d0fe --- /dev/null +++ b/ansible/roles/caddy/tasks/main.yaml @@ -0,0 +1,39 @@ +--- +- name: Check the Caddyfile exists + stat: + path: '{{ caddyfile_path }}' + register: menu_caddyfile + +- 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. + 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: + 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/roles/menu-app/tasks/main.yaml b/ansible/roles/menu-app/tasks/main.yaml new file mode 100644 index 0000000..039f295 --- /dev/null +++ b/ansible/roles/menu-app/tasks/main.yaml @@ -0,0 +1,111 @@ +--- +- name: Pull the app image + docker_image: + name: '{{ image_name }}' + tag: '{{ image_tag }}' + source: pull + force_source: yes + +- name: Ensure the app root exists + file: + path: '{{ app_root }}' + state: directory + owner: '{{ app_uid }}' + group: '{{ app_gid }}' + mode: '0750' + +- name: Ensure the data directory exists + file: + path: '{{ app_root }}/data' + state: directory + owner: '{{ app_uid }}' + group: '{{ app_gid }}' + mode: '0750' + +- name: Look for the untracked site.env + stat: + path: '{{ app_root }}/site.env' + register: menu_site_env + +- name: Warn when the admin has no password yet + 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. + when: not menu_site_env.stat.exists + +- name: Run the menu app + docker_container: + name: '{{ docker_container }}' + image: '{{ image_name }}:{{ image_tag }}' + state: started + restart_policy: unless-stopped + user: '{{ app_uid }}:{{ app_gid }}' + env: + MENU_ROOT: /data + MENU_SITE_CONFIG: '/app/config/{{ site }}.site.json' + SECURE_COOKIES: 'true' + LOG_LEVEL: info + PORT: '3000' + volumes: + - '{{ app_root }}:/data' + networks: + - name: '{{ docker_network }}' + +- name: Keep site.env readable only by the app user + file: + path: '{{ app_root }}/site.env' + owner: '{{ app_uid }}' + group: '{{ app_gid }}' + mode: '0600' + when: menu_site_env.stat.exists + +- name: Wait for the app to report healthy + # The image's own HEALTHCHECK fetches /healthz inside the container. Polling + # that is deliberate: a container name resolves only on the Docker network, so + # the host cannot fetch http://{{ docker_container }}:3000/healthz. + docker_container_info: + name: '{{ docker_container }}' + register: menu_health + changed_when: false + retries: 20 + delay: 5 + until: >- + menu_health.container is defined and + menu_health.container.State.Health is defined and + menu_health.container.State.Health.Status == 'healthy' + +- name: Fail when the app never becomes healthy + fail: + msg: >- + {{ docker_container }} did not report healthy within 100s. Check + `docker logs {{ docker_container }}`. + when: menu_health.container.State.Health.Status | default('unknown') != 'healthy' + +- name: Check the data directory for integrity problems + # Reads the same directory the container writes, as the same user, so a + # permissions mistake shows up here rather than at 8pm service. + command: + argv: + - docker + - run + - --rm + - --user + - '{{ app_uid }}:{{ app_gid }}' + - -e + - MENU_ROOT=/data + - -e + - 'MENU_SITE_CONFIG=/app/config/{{ site }}.site.json' + - --entrypoint + - node + - '{{ image_name }}:{{ image_tag }}' + - src/cli/verify.ts + register: menu_verify + changed_when: false + failed_when: false + +- name: Report integrity problems + debug: + msg: '{{ menu_verify.stdout_lines | default([]) }}' + when: menu_verify.rc != 0 diff --git a/ansible/roles/network/tasks/main.yaml b/ansible/roles/network/tasks/main.yaml new file mode 100644 index 0000000..979dde4 --- /dev/null +++ b/ansible/roles/network/tasks/main.yaml @@ -0,0 +1,8 @@ +--- +- name: 'Ensure {{ docker_network }} exists and Caddy is on it' + docker_network: + name: '{{ docker_network }}' + state: present + connected: + - '{{ caddy_container }}' + appends: yes diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml new file mode 100644 index 0000000..5077460 --- /dev/null +++ b/ansible/vars/default.yml @@ -0,0 +1,29 @@ +--- +# 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. +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 + +caddy_container: caddy +caddyfile_path: /var/lib/caddy/conf/Caddyfile + +caddy_snippet: | + {{ vhost }} { + reverse_proxy {{ docker_container }}:3000 + } diff --git a/ansible/vars/hopsngrains.yml b/ansible/vars/hopsngrains.yml new file mode 100644 index 0000000..038603a --- /dev/null +++ b/ansible/vars/hopsngrains.yml @@ -0,0 +1,18 @@ +--- +# 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. +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/deploy.sh b/deploy.sh index 930be55..5018615 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,47 +1,45 @@ #!/usr/bin/env bash # -# Builds the image, runs the tests and pushes it to the private registry. +# Builds the image with podman, pushes it to the private registry, then deploys +# the Site with the playbook that lives in ansible/. # -# ./deploy.sh 2026-09-22 +# ./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 # -# Then deploy with the printed ansible-playbook command, or with the cutover -# flags from deploy/ansible/readme.md. set -euo pipefail TAG="${1:-}" if [[ -z "$TAG" ]]; then - echo "usage: deploy.sh (e.g. $(date +%Y-%m-%d) or a git short sha)" >&2 + echo "usage: $0 (e.g. $(date +%Y-%m-%d) or a git short sha)" >&2 exit 1 fi -IMAGE="registry.tanshu.com/menu-admin:${TAG}" -ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +parent_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P) +cd "$parent_path" -cd "$ROOT" +if ! command -v podman >/dev/null 2>&1; then + echo "podman is required to build and push the image" >&2 + exit 1 +fi -if [[ -z "${SKIP_TESTS:-}" ]]; then - echo "==> lint, format and tests" - npm run lint +if [[ -z "${SKIP_CHECKS:-}" ]]; then + echo "==> format, types and tests" npm run format:check npm run typecheck npm test fi -echo "==> building ${IMAGE}" -docker build -t "$IMAGE" . +echo "==> building and pushing registry.tanshu.com/menu-admin:$TAG" +make build-production TAG="$TAG" -echo "==> pushing ${IMAGE}" -docker push "$IMAGE" +if [[ -n "${SKIP_DEPLOY:-}" ]]; then + echo "SKIP_DEPLOY is set, so the playbook was not run." + exit 0 +fi -cat < deploying $SITE" +cd ansible +ansible-playbook --inventory=hosts playbook.yml --limit "$SITE" -e "tag=$TAG" diff --git a/eslint.config.js b/eslint.config.js deleted file mode 100644 index a8c1a92..0000000 --- a/eslint.config.js +++ /dev/null @@ -1,56 +0,0 @@ -// ESLint, flat config. Lints the JavaScript: the two browser scripts the menus -// and the admin actually run, the build and check scripts, and this file. -// -// TypeScript is deliberately not linted here. The project's TypeScript is the -// native port (typescript@7), which ships no JavaScript compiler API, and every -// ESLint TypeScript parser needs one — typescript-eslint requires -// `typescript >=4.8.4 <6.1.0` and refuses to install. `npm run typecheck` -// (tsc --noEmit, strict, with noUnusedLocals, noUncheckedIndexedAccess, -// exactOptionalPropertyTypes and erasableSyntaxOnly) is the TypeScript gate. -// See README.md for the two ways to put ESLint on the .ts files as well. - -import js from '@eslint/js'; -import globals from 'globals'; - -export default [ - { - ignores: [ - 'node_modules/**', - '.dev/**', - '.tmp/**', - '.npm-cache/**', - 'public/fonts/**', - 'public/prototype/**', - 'docs/design/**', - ], - }, - js.configs.recommended, - { - // The browser scripts: old-fashioned IIFEs loaded with a plain