Move the menu-admin deployment into the menu-admin repo
It lives beside the app it deploys now, with podman building the image and an inventory carrying one entry per Site (which is what the local 'hosts: monoco' edit was reaching for). Nothing in this repo referenced it.
This commit is contained in:
1 parent
bf824cae26
commit
f2d1b606ce
8 files changed
-327
No files matched your search
@@ -1,19 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
@@ -1,95 +0,0 @@
|
||||
# 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. The script lives in the
|
||||
# app repo and writes site.env through a container running as the app user,
|
||||
# so the SSH user never needs write access to the data root.
|
||||
scp scripts/provision-site-env.sh monoco:/tmp/
|
||||
ssh monoco bash /tmp/provision-site-env.sh /var/lib/menu-app.hopsngrains.com
|
||||
ssh monoco docker restart 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
|
||||
|
||||
`menu-new.hopsngrains.com` needs a DNS A record pointing at the same address as
|
||||
`menu.hopsngrains.com` before this runs: Caddy fetches a certificate for the
|
||||
name, which needs it to resolve publicly.
|
||||
|
||||
Then, from the outside (read-only, exits non-zero on failure):
|
||||
|
||||
```sh
|
||||
npm run check-site -- https://menu-new.hopsngrains.com
|
||||
```
|
||||
|
||||
And 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.
|
||||
@@ -1,6 +0,0 @@
|
||||
---
|
||||
- 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
|
||||
@@ -1,39 +0,0 @@
|
||||
---
|
||||
- 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
|
||||
@@ -1,111 +0,0 @@
|
||||
---
|
||||
- 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: Keep site.env readable only by the app user
|
||||
file:
|
||||
path: "{{ app_root }}/site.env"
|
||||
owner: "{{ app_uid }}"
|
||||
group: "{{ app_gid }}"
|
||||
mode: "0600"
|
||||
when: menu_site_env.stat.exists
|
||||
|
||||
- name: Wait for the app to report healthy
|
||||
# The image's own HEALTHCHECK fetches /healthz inside the container. Polling
|
||||
# that is deliberate: a container name resolves only on the Docker network, so
|
||||
# the host cannot fetch http://{{ docker_container }}:3000/healthz.
|
||||
docker_container_info:
|
||||
name: "{{ docker_container }}"
|
||||
register: menu_health
|
||||
changed_when: false
|
||||
retries: 20
|
||||
delay: 5
|
||||
until: >-
|
||||
menu_health.container is defined and
|
||||
menu_health.container.State.Health is defined and
|
||||
menu_health.container.State.Health.Status == 'healthy'
|
||||
|
||||
- name: Fail when the app never becomes healthy
|
||||
fail:
|
||||
msg: >-
|
||||
{{ docker_container }} did not report healthy within 100s. Check
|
||||
`docker logs {{ docker_container }}`.
|
||||
when: menu_health.container.State.Health.Status | default('unknown') != 'healthy'
|
||||
|
||||
- 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
|
||||
@@ -1,8 +0,0 @@
|
||||
---
|
||||
- name: "Ensure {{ docker_network }} exists and Caddy is on it"
|
||||
docker_network:
|
||||
name: "{{ docker_network }}"
|
||||
state: present
|
||||
connected:
|
||||
- "{{ caddy_container }}"
|
||||
appends: yes
|
||||
@@ -1,29 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
---
|
||||
# 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
|
||||
Reference in new issue
Block a user