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:
1 parent
bb9ddb437f
commit
1f06e32854
11 files changed
+359
-9
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user