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.
This commit is contained in:
tanshu committed 2026-09-22 21:25:52 +05:30
1 parent bb9ddb437f
commit 1f06e32854
11 files changed
+359 -9

No files matched your search

+15 -5
View File
@@ -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
Executable
+45
View File
@@ -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 <tag> (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 <<EOF
pushed ${IMAGE}
parallel run:
ansible-playbook -l monoco -e site=hopsngrains -e tag=${TAG} playbook.yml
cutover:
ansible-playbook -l monoco -e site=hopsngrains -e tag=${TAG} \\
-e vhost=menu.hopsngrains.com -e retire_nginx_snippet=true \\
-e retire_nginx_container=true playbook.yml
EOF
+19
View File
@@ -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=<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
+81
View File
@@ -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>.site.json` in the app repo, baked into the image |
| Menu content, Drafts, Originals | `/var/lib/menu-app.<site>/data` on the host |
| Admin password hash, session secret | `/var/lib/menu-app.<site>/site.env` (untracked, mode 600) |
| Which image tag runs | `-e tag=<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=<previous> \
-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.
@@ -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
@@ -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
@@ -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
@@ -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
+29
View File
@@ -0,0 +1,29 @@
---
# Defaults shared by every Site. Override per Site in vars/<site>.yml, and pass
# -e site=<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>.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
}
+20
View File
@@ -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
+6 -4
View File
@@ -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>.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:<tag>`, 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.<site>`, 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>/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.<site>`, 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>/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