From c87cd15c8cc05b2dae134fe5282b66f33365887c Mon Sep 17 00:00:00 2001 From: Amritanshu Date: Tue, 22 Sep 2026 21:27:13 +0530 Subject: [PATCH] Add the menu-admin playbook Deploys the menu app for one Site: pulls the tag, creates the data root, runs the container against a config baked into the image, waits for health, verifies the data directory, and points Caddy at the app with a marker it can update in place. The parallel run uses a temporary hostname; cutover sets the retire flags. --- menu-admin/playbook.yml | 19 +++++ menu-admin/readme.md | 81 ++++++++++++++++++++ menu-admin/roles/caddy/handlers/main.yaml | 6 ++ menu-admin/roles/caddy/tasks/main.yaml | 39 ++++++++++ menu-admin/roles/menu-app/tasks/main.yaml | 91 +++++++++++++++++++++++ menu-admin/roles/network/tasks/main.yaml | 8 ++ menu-admin/vars/default.yml | 29 ++++++++ menu-admin/vars/hopsngrains.yml | 20 +++++ 8 files changed, 293 insertions(+) create mode 100644 menu-admin/playbook.yml create mode 100644 menu-admin/readme.md create mode 100644 menu-admin/roles/caddy/handlers/main.yaml create mode 100644 menu-admin/roles/caddy/tasks/main.yaml create mode 100644 menu-admin/roles/menu-app/tasks/main.yaml create mode 100644 menu-admin/roles/network/tasks/main.yaml create mode 100644 menu-admin/vars/default.yml create mode 100644 menu-admin/vars/hopsngrains.yml diff --git a/menu-admin/playbook.yml b/menu-admin/playbook.yml new file mode 100644 index 0000000..b420741 --- /dev/null +++ b/menu-admin/playbook.yml @@ -0,0 +1,19 @@ +--- +# Deploys the menu app for one Site. Copy this directory to +# ansible-playbooks/menu-admin/ and run it from there: +# +# ansible-playbook -l monoco -e site=hopsngrains -e tag= 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/menu-admin/readme.md b/menu-admin/readme.md new file mode 100644 index 0000000..010267d --- /dev/null +++ b/menu-admin/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/menu-admin/roles/caddy/handlers/main.yaml b/menu-admin/roles/caddy/handlers/main.yaml new file mode 100644 index 0000000..e33300c --- /dev/null +++ b/menu-admin/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/menu-admin/roles/caddy/tasks/main.yaml b/menu-admin/roles/caddy/tasks/main.yaml new file mode 100644 index 0000000..e67b55e --- /dev/null +++ b/menu-admin/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/menu-admin/roles/menu-app/tasks/main.yaml b/menu-admin/roles/menu-app/tasks/main.yaml new file mode 100644 index 0000000..95107ba --- /dev/null +++ b/menu-admin/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/menu-admin/roles/network/tasks/main.yaml b/menu-admin/roles/network/tasks/main.yaml new file mode 100644 index 0000000..049e60b --- /dev/null +++ b/menu-admin/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/menu-admin/vars/default.yml b/menu-admin/vars/default.yml new file mode 100644 index 0000000..a4be919 --- /dev/null +++ b/menu-admin/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/menu-admin/vars/hopsngrains.yml b/menu-admin/vars/hopsngrains.yml new file mode 100644 index 0000000..2b70206 --- /dev/null +++ b/menu-admin/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