diff --git a/README.md b/README.md index 0bc0451..2747504 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,21 @@ photographed Pages, replacing the `scp`-a-JPG workflow. One instance per Site; ## Status -Built: Site config, the Menu Set store, the image pipeline, the -customer-facing renderer (landing page, Section pages, archived sets, media -route) with the approved design, and the admin (sign in, create a set, upload -and reorder Pages, preview, publish, restore, delete). Not yet built: the -container, the Ansible playbook and the deploy. +Built: Site config, the Menu Set store, the image pipeline, the customer-facing +renderer (landing page, Section pages, archived sets, media route) with the +approved design, the admin (sign in, create a set, upload and reorder Pages, +preview, publish, restore, delete), the `verify`/`repair` CLI, the container and +the Ansible playbook. Not yet done: the parallel-run deployment on `monoco`, +which needs a human present. + +## Deploying + +```sh +./deploy.sh 2026-09-22 # tests, build, push to registry.tanshu.com +``` + +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. ## Layout diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..b1d64fe --- /dev/null +++ b/deploy.sh @@ -0,0 +1,45 @@ +#!/usr/bin/env bash +# +# Builds the image, runs the tests and pushes it to the private registry. +# +# ./deploy.sh 2026-09-22 +# +# 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 + exit 1 +fi + +IMAGE="registry.tanshu.com/menu-admin:${TAG}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +cd "$ROOT" + +if [[ -z "${SKIP_TESTS:-}" ]]; then + echo "==> tests" + npm test + npm run typecheck +fi + +echo "==> building ${IMAGE}" +docker build -t "$IMAGE" . + +echo "==> pushing ${IMAGE}" +docker push "$IMAGE" + +cat < playbook.yml +# +# 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: all + become: true + vars_files: + - vars/default.yml + - "vars/{{ site }}.yml" + + roles: + - network + - menu-app + - caddy diff --git a/deploy/ansible/readme.md b/deploy/ansible/readme.md new file mode 100644 index 0000000..010267d --- /dev/null +++ b/deploy/ansible/readme.md @@ -0,0 +1,81 @@ +# menu-admin playbook + +Deploys the menu app for one Site, beside the existing nginx container at first +and in place of it after cutover. Copy this directory to +`ansible-playbooks/menu-admin/` and run it from there, so `playbook_dir` and the +inventory match the other playbooks. + +This playbook owns the `menu-app` container and the Site's snippet in the shared +Caddyfile. It does not touch the app's source, the image, or the other Sites' +containers. + +## What lives where + +| Thing | Where | +| --- | --- | +| Sections, brand, theme | `config/.site.json` in the app repo, baked into the image | +| Menu content, Drafts, Originals | `/var/lib/menu-app./data` on the host | +| Admin password hash, session secret | `/var/lib/menu-app./site.env` (untracked, mode 600) | +| Which image tag runs | `-e tag=` | + +Sections are deliberately not editable on the server: changing them is a commit +to the app repo, a new image, and a redeploy (ADR 0005). + +## Deploying + +```sh +# 1. Build and push from the app repo +./deploy.sh 2026-09-22 # -> registry.tanshu.com/menu-admin:2026-09-22 + +# 2. First run: a temporary hostname, so the live site keeps serving +ansible-playbook -l monoco -e site=hopsngrains -e tag=2026-09-22 playbook.yml + +# 3. The admin needs a password before it can be used +ssh monoco scripts/provision-site-env.sh /var/lib/menu-app.hopsngrains.com +``` + +The first run creates `/var/lib/menu-app.hopsngrains.com`, starts the container +on the `menu-app.hopsngrains.com_net` network, adds a snippet for +`menu-new.hopsngrains.com` to the shared Caddyfile, and reloads Caddy. The +existing `menu.hopsngrains.com` vhost still points at the old nginx container. + +`site.env` is deliberately not created by Ansible: the password is typed by a +human on the server. Until it exists the admin is disabled and the playbook says +so — the public menu still serves. + +## Checking the parallel run + +From a phone on restaurant wifi, not from the server: + +- `https://menu-new.hopsngrains.com/` lists the Sections that have a current menu. +- A Section page scrolls, the pour line tracks, tapping a page opens it full width. +- The admin signs in, uploads a real photo, reorders, previews, publishes. +- After publishing, the phone shows the new menu and the old set appears under + "Older menus". + +## Cutover + +```sh +ansible-playbook -l monoco -e site=hopsngrains -e tag=2026-09-22 \ + -e vhost=menu.hopsngrains.com \ + -e retire_nginx_snippet=true \ + -e retire_nginx_container=true \ + playbook.yml +``` + +This repoints the real vhost at the app, removes the snippet the old 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 a week later, once the new +site has been trusted. + +## Rolling back + +```sh +docker start nginx-menu.hopsngrains.com +ansible-playbook -l monoco -e site=hopsngrains -e tag= \ + -e vhost=menu.hopsngrains.com -e retire_nginx_snippet=false playbook.yml +``` + +Then delete the app's snippet from `/var/lib/caddy/conf/Caddyfile` by hand, or +set `vhost` back to the temporary name, and reload Caddy. diff --git a/deploy/ansible/roles/caddy/handlers/main.yaml b/deploy/ansible/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..e33300c --- /dev/null +++ b/deploy/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/deploy/ansible/roles/caddy/tasks/main.yaml b/deploy/ansible/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..e67b55e --- /dev/null +++ b/deploy/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/deploy/ansible/roles/menu-app/tasks/main.yaml b/deploy/ansible/roles/menu-app/tasks/main.yaml new file mode 100644 index 0000000..95107ba --- /dev/null +++ b/deploy/ansible/roles/menu-app/tasks/main.yaml @@ -0,0 +1,91 @@ +--- +- 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: Wait for the app to answer + uri: + url: "http://{{ docker_container }}:3000/healthz" + status_code: 200 + register: menu_health + retries: 10 + delay: 2 + until: menu_health.status == 200 + changed_when: false + +- 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/deploy/ansible/roles/network/tasks/main.yaml b/deploy/ansible/roles/network/tasks/main.yaml new file mode 100644 index 0000000..049e60b --- /dev/null +++ b/deploy/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/deploy/ansible/vars/default.yml b/deploy/ansible/vars/default.yml new file mode 100644 index 0000000..a4be919 --- /dev/null +++ b/deploy/ansible/vars/default.yml @@ -0,0 +1,29 @@ +--- +# Defaults shared by every Site. Override per Site in vars/.yml, and pass +# -e site= on the command line. + +# 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: hopsngrains + +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/deploy/ansible/vars/hopsngrains.yml b/deploy/ansible/vars/hopsngrains.yml new file mode 100644 index 0000000..2b70206 --- /dev/null +++ b/deploy/ansible/vars/hopsngrains.yml @@ -0,0 +1,20 @@ +--- +# Hops n Grains. The Section list and branding live in the app repo at +# config/hopsngrains.site.json, which is baked into the image, so this file +# only says where to run it. +site: hopsngrains + +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/plan.md b/docs/plan.md index 0cb112a..678c708 100644 --- a/docs/plan.md +++ b/docs/plan.md @@ -111,11 +111,13 @@ Text-led landing page; Sections as tap-list rows with copper hairlines; no hero ## Repos, build and deploy -- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev against a `fixtures/` folder, `docs/` (this plan, `CONTEXT.md`, the ADRs), and `node --test` tests for the storage and image layers. +- New repo `menu-admin` on `git.tanshu.com`: app source, `Dockerfile`, `compose.yaml` for local dev, `docs/` (this plan, `CONTEXT.md`, the ADRs), `node --test` tests, and `deploy.sh` (tests, build, push). +- The container is `node:24-alpine` running TypeScript directly, so there is no build step; sharp's musl binaries come from `npm ci` on the image's own platform. It writes only under `MENU_ROOT` and healthchecks its own `/healthz`. +- The Site's Sections, brand and theme live in `config/.site.json`, baked into the image and selected with `MENU_SITE_CONFIG`. Changing Sections is therefore a commit, a new image and a redeploy; the playbook only names the Site. - Build locally and push `registry.tanshu.com/menu-admin:`, matching the sibling `deploy.sh` / `make build-production TAG=` convention. -- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.`, start container), `caddy` (snippet upsert). -- The existing `roles/caddy` only *adds* a snippet when missing, which cannot change a proxy target; it is fixed to update an existing managed block in place. -- One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it, and writes `/var/lib/menu-app./site.env` (mode 600, never in git). +- New `menu-admin` playbook in `ansible-playbooks` with per-Site vars (`vars/hopsngrains.yml` first, the other two stubbed): roles `network`, `menu-app` (pull image, create `/var/lib/menu-app.`, start container, health check, then `verify` the data directory) and `caddy` (snippet upsert). +- The existing `roles/caddy` only *adds* a snippet when its exact text is missing, which cannot change a proxy target; the new playbook's role uses a stable marker so `blockinfile` updates in place, and removes the old role's per-hostname marker at cutover so Caddy never holds two blocks for one hostname. +- One-time human step on the server: a `scripts/provision-site-env.sh` prompts for the admin password, hashes it with the image's own hasher, and writes `/var/lib/menu-app./site.env` (mode 600, never in git). Until it exists the admin is disabled and the public menu still serves. - A small CLI: `verify` reports integrity problems in the data directory, `repair` regenerates missing variants from the Originals. ## Sequence