From 1f06e32854aaf2cf9f88b77f7e004c9a29559c6f Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 21:25:52 +0530 Subject: [PATCH] Add the playbook that deploys one Site The playbook pulls the tag from the registry, creates the data root owned by the app user, runs the container with its config baked into the image, waits for health, then verifies the data directory as the app user so a permissions mistake surfaces at deploy time. Caddy is pointed at the app with a stable marker, so a temporary hostname can be checked beside the live site and the cutover is one invocation with the retire flags set. --- README.md | 20 +++- deploy.sh | 45 +++++++++ deploy/ansible/playbook.yml | 19 ++++ deploy/ansible/readme.md | 81 +++++++++++++++++ deploy/ansible/roles/caddy/handlers/main.yaml | 6 ++ deploy/ansible/roles/caddy/tasks/main.yaml | 39 ++++++++ deploy/ansible/roles/menu-app/tasks/main.yaml | 91 +++++++++++++++++++ deploy/ansible/roles/network/tasks/main.yaml | 8 ++ deploy/ansible/vars/default.yml | 29 ++++++ deploy/ansible/vars/hopsngrains.yml | 20 ++++ docs/plan.md | 10 +- 11 files changed, 359 insertions(+), 9 deletions(-) create mode 100755 deploy.sh create mode 100644 deploy/ansible/playbook.yml create mode 100644 deploy/ansible/readme.md create mode 100644 deploy/ansible/roles/caddy/handlers/main.yaml create mode 100644 deploy/ansible/roles/caddy/tasks/main.yaml create mode 100644 deploy/ansible/roles/menu-app/tasks/main.yaml create mode 100644 deploy/ansible/roles/network/tasks/main.yaml create mode 100644 deploy/ansible/vars/default.yml create mode 100644 deploy/ansible/vars/hopsngrains.yml 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