Move the deployment into the app repo, and build it with podman

The playbook, its roles, its vars and a new inventory now live in ansible/,
beside the app they deploy, the way the other apps on this machine are laid out.
The inventory follows the same shape: one entry per Site, all on the same box,
each naming its own vars file through var_file, so adding a Site is one line and
a file rather than a command-line flag. ansible_host is written as the
resolvable name rather than an ssh alias so the inventory works on any machine.

The image is now built with podman as a multi-arch manifest and pushed to
registry.tanshu.com/menu-admin, mirroring the Makefile used elsewhere on this
machine: build-production, plus build-check and build-check-local for a build
that does not push. deploy.sh runs the checks, builds, pushes, then runs the
playbook for the Site named by SITE, with SKIP_DEPLOY for a push without a
deploy.

ESLint comes out again: this project's typescript@7 has no JavaScript compiler
API, so typescript-eslint cannot install, and linting only the JavaScript was
not worth keeping. Prettier stays, and the README records why ESLint is absent
and what would bring it back.
This commit is contained in:
tanshu committed 2026-09-22 23:18:18 +05:30
1 parent e64f65642f
commit 75b6492493
17 files changed
+417 -1067

No files matched your search

+1 -1
View File
@@ -1,3 +1,3 @@
{
"recommendations": ["esbenp.prettier-vscode", "dbaeumer.vscode-eslint"]
"recommendations": ["esbenp.prettier-vscode"]
}
-6
View File
@@ -13,12 +13,6 @@
"script": "format",
"problemMatcher": []
},
{
"label": "lint",
"type": "npm",
"script": "lint",
"problemMatcher": ["$eslint-stylish"]
},
{
"label": "dev: seed placeholder menus",
"type": "npm",
+23
View File
@@ -0,0 +1,23 @@
IMAGE ?= registry.tanshu.com/menu-admin
PLATFORMS ?= linux/amd64,linux/arm64
.PHONY: build-production build-check build-check-local
# Mirrors the other apps on this machine: a podman manifest build, so one tag
# carries both architectures, pushed to the private registry.
build-production: ## Build and push the production image. Usage: make build-production TAG=2026-09-22
@test -n "$(TAG)" || { echo "Usage: make build-production TAG=2026-09-22" >&2; exit 1; }
@echo "Building image..."
@podman manifest rm $(IMAGE):latest 2>/dev/null || true
@podman build --platform $(PLATFORMS) --manifest $(IMAGE):latest --pull .
@echo "Pushing manifests..."
@podman manifest push --all $(IMAGE):latest docker://$(IMAGE):latest
@podman manifest push --all $(IMAGE):latest docker://$(IMAGE):$(TAG)
build-check: ## Multi-arch build without pushing, to prove both platforms build
@podman manifest rm menu-admin:check 2>/dev/null || true
@podman build --platform $(PLATFORMS) --manifest menu-admin:check --pull .
build-check-local: ## Single-arch build without pushing: the quick local check
@podman manifest rm menu-admin:check 2>/dev/null || true
@podman build --platform linux/amd64 --manifest menu-admin:check .
+17 -25
View File
@@ -16,18 +16,15 @@ which needs a human present.
## Deploying
```sh
./deploy.sh 2026-09-22 # tests, build, push to registry.tanshu.com
./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
```
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.
After deploying, check the running site from the outside (read-only, exits
non-zero on failure, so it can gate a cutover):
```sh
npm run check-site -- https://menu-new.hopsngrains.com
```
The image is built with **podman** as a multi-arch manifest and pushed to
`registry.tanshu.com/menu-admin` (`Makefile`, mirrored from the other apps on
this machine). The Ansible deployment lives in `ansible/` in this repo, with one
inventory entry per Site; see `ansible/readme.md` for the first run, cutover and
rollback.
## Layout
@@ -85,13 +82,11 @@ npm run verify # integrity of .dev/data
npm run check-site -- http://127.0.0.1:3000 # checks a running site from outside
```
### Linting and formatting
### Formatting
```sh
npm run lint # eslint
npm run lint:fix
npm run format # prettier --write
npm run format:check # what CI and deploy.sh run
npm run format:check # what deploy.sh runs
```
Prettier owns formatting (`.prettierrc.json`); `docs/` is excluded because those
@@ -99,17 +94,14 @@ are hand-wrapped notes rather than code. `embeddedLanguageFormatting` is off on
purpose: the renderers build HTML inside `html` tagged template literals, and
letting Prettier reflow those would change the markup customers receive.
**ESLint does not lint the TypeScript, and cannot as things stand.** This project
uses the native TypeScript port (`typescript@7`), which ships no JavaScript
compiler API — and every ESLint TypeScript parser needs one. `typescript-eslint`
declares `typescript >=4.8.4 <6.1.0` as a peer and refuses to install; forcing it
through would leave a parser that cannot read the compiler it was given. The
TypeScript gate is therefore `npm run typecheck` (strict, with `noUnusedLocals`,
`noUncheckedIndexedAccess`, `exactOptionalPropertyTypes` and
`erasableSyntaxOnly`), and ESLint covers the JavaScript. To get ESLint over the
`.ts` files as well, either pin `typescript@5.9` — which supports everything this
project uses — and add `typescript-eslint`, or add `oxlint`, which parses
TypeScript without the compiler API.
**ESLint is deliberately absent.** This project's `typescript@7` is the native
port and ships no JavaScript compiler API, which every ESLint TypeScript parser
needs — `typescript-eslint` requires `typescript >=4.8.4 <6.1.0` and refuses to
install, and forcing it through leaves a parser that cannot read the compiler.
Linting only the JavaScript was not worth the config, so the TypeScript gate is
`npm run typecheck` (strict, with `noUnusedLocals`, `noUncheckedIndexedAccess`,
`exactOptionalPropertyTypes` and `erasableSyntaxOnly`) and ESLint can come back,
with `typescript-eslint`, once it supports TypeScript 7.
Dragging Pages is the one interaction that only a browser can check, so there is
a harness for it. It needs `geckodriver` on PATH and a running dev server:
+15
View File
@@ -0,0 +1,15 @@
# The Ansible inventory for the menus. Every Site runs on the same box, so each
# entry names it and points at the vars file that describes it.
#
# cd ansible
# ansible-playbook --inventory=hosts playbook.yml # every Site
# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains
#
# ansible_host is written as the resolvable name rather than an ssh alias so the
# inventory works on any machine, whatever its ~/.ssh/config says.
[menus]
hopsngrains ansible_host=monoco.tanshu.com var_file=vars/hopsngrains.yml
[all:vars]
ansible_python_interpreter=/usr/bin/python3
+25
View File
@@ -0,0 +1,25 @@
---
# Deploys the menu app for one or more Sites. It lives here, beside the app it
# deploys, and is run from this directory:
#
# make build-production TAG=<tag> (from the repo root)
# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains
#
# 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: menus
become: true
vars_files:
- vars/default.yml
pre_tasks:
- name: Load the vars file this Site names in the inventory
ansible.builtin.include_vars:
file: '{{ var_file }}'
when: var_file is defined
roles:
- network
- menu-app
- caddy
+100
View File
@@ -0,0 +1,100 @@
# The menu deployment
Lives here, beside the app it deploys, the way the other apps on this machine do.
It owns the `menu-app` container and the Site's snippet in the shared Caddyfile.
## What lives where
| Thing | Where |
| -------------------------------------- | --------------------------------------------------------------------------- |
| Name, tagline, icon, categories, menus | on the server, under `/var/lib/menu-app.<site>/data` — the admin edits them |
| Theme colours | `config/<site>.site.json` in the repo, baked into the image |
| Admin password hash, session secret | `/var/lib/menu-app.<site>/site.env` (untracked, mode 600) |
| Which image tag runs | `-e tag=<tag>` |
| Which Site is being deployed | the inventory entry in `hosts` |
## Deploying
From the repo root:
```sh
./deploy.sh 2026-09-22 # checks, podman build+push, then deploys
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
```
`deploy.sh` runs `make build-production TAG=<tag>`, which builds a multi-arch
podman manifest (`linux/amd64,linux/arm64`) and pushes `latest` and the tag to
`registry.tanshu.com/menu-admin`. Then it runs this playbook against the Site
named by `SITE` (default `hopsngrains`).
By hand:
```sh
make build-production TAG=2026-09-22
cd ansible
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=2026-09-22
```
Every Site runs on the same box, so `hosts` has one entry per Site pointing at
the same `ansible_host` with its own `var_file`. `ansible_host` is written as the
resolvable name rather than an ssh alias, so the inventory works whatever a
machine's `~/.ssh/config` says. (`ssh` on some machines complains about
`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that
is a local file-permission bug with the fix `sudo chown -R root:root
/etc/ssh/ssh_config.d`, not something this playbook works around.)
## The first run, and cutover
The first run targets a temporary vhost, so the live site keeps serving:
```sh
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \
-e tag=2026-09-22 -e vhost=menu-new.hopsngrains.com
```
`menu-new.hopsngrains.com` needs a DNS A record pointing at the same address as
the live site 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: the landing page, a Section page, the page
counter, tap-to-zoom, and signing into `/admin` to publish something.
Cutover is the same playbook with the real vhost and the two retire flags:
```sh
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains \
-e tag=2026-09-22 -e vhost=menu.hopsngrains.com \
-e retire_nginx_snippet=true -e retire_nginx_container=true
```
That repoints the real vhost at the app, removes the snippet the old `nginx-menu`
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 once the new site has been
trusted.
## The admin password
The app starts with the admin disabled when `/var/lib/menu-app.<site>/site.env`
is missing, and the playbook says so. Create it once per Site:
```sh
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
```
## Rolling back
```sh
ssh monoco docker start nginx-menu.hopsngrains.com
```
then point Caddy back at it — the cutover flags above with
`retire_nginx_snippet=false`.
+6
View File
@@ -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
+39
View File
@@ -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
+111
View File
@@ -0,0 +1,111 @@
---
- 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
+8
View File
@@ -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. Each Site's own file is named by the `var_file`
# in ansible/hosts, and its slug is its inventory name.
# 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: '{{ inventory_hostname }}'
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
}
+18
View File
@@ -0,0 +1,18 @@
---
# Hops n Grains. The name, icon, categories and theme live in the app repo
# (config/hopsngrains.site.json seeds the theme and `data/` holds the rest), so
# this file only says where to run it.
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
+25 -27
View File
@@ -1,47 +1,45 @@
#!/usr/bin/env bash
#
# Builds the image, runs the tests and pushes it to the private registry.
# Builds the image with podman, pushes it to the private registry, then deploys
# the Site with the playbook that lives in ansible/.
#
# ./deploy.sh 2026-09-22
# ./deploy.sh 2026-09-22 deploy the default Site
# SITE=greatbear ./deploy.sh 2026-09-22 deploy another Site
# SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 build and push only
# SKIP_CHECKS=1 ./deploy.sh 2026-09-22 skip format, typecheck and tests
#
# 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
echo "usage: $0 <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)"
parent_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
cd "$parent_path"
cd "$ROOT"
if ! command -v podman >/dev/null 2>&1; then
echo "podman is required to build and push the image" >&2
exit 1
fi
if [[ -z "${SKIP_TESTS:-}" ]]; then
echo "==> lint, format and tests"
npm run lint
if [[ -z "${SKIP_CHECKS:-}" ]]; then
echo "==> format, types and tests"
npm run format:check
npm run typecheck
npm test
fi
echo "==> building ${IMAGE}"
docker build -t "$IMAGE" .
echo "==> building and pushing registry.tanshu.com/menu-admin:$TAG"
make build-production TAG="$TAG"
echo "==> pushing ${IMAGE}"
docker push "$IMAGE"
if [[ -n "${SKIP_DEPLOY:-}" ]]; then
echo "SKIP_DEPLOY is set, so the playbook was not run."
exit 0
fi
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
SITE="${SITE:-hopsngrains}"
echo "==> deploying $SITE"
cd ansible
ansible-playbook --inventory=hosts playbook.yml --limit "$SITE" -e "tag=$TAG"
-56
View File
@@ -1,56 +0,0 @@
// ESLint, flat config. Lints the JavaScript: the two browser scripts the menus
// and the admin actually run, the build and check scripts, and this file.
//
// TypeScript is deliberately not linted here. The project's TypeScript is the
// native port (typescript@7), which ships no JavaScript compiler API, and every
// ESLint TypeScript parser needs one — typescript-eslint requires
// `typescript >=4.8.4 <6.1.0` and refuses to install. `npm run typecheck`
// (tsc --noEmit, strict, with noUnusedLocals, noUncheckedIndexedAccess,
// exactOptionalPropertyTypes and erasableSyntaxOnly) is the TypeScript gate.
// See README.md for the two ways to put ESLint on the .ts files as well.
import js from '@eslint/js';
import globals from 'globals';
export default [
{
ignores: [
'node_modules/**',
'.dev/**',
'.tmp/**',
'.npm-cache/**',
'public/fonts/**',
'public/prototype/**',
'docs/design/**',
],
},
js.configs.recommended,
{
// The browser scripts: old-fashioned IIFEs loaded with a plain <script>.
files: ['public/**/*.js'],
languageOptions: {
sourceType: 'script',
globals: globals.browser,
},
rules: {
// Deliberately plain ES5-shaped IIFEs loaded with a <script> tag, so `var`
// stays allowed: rewriting it to let/const would be cosmetic, and a
// hoisting difference here would only show up in a browser.
'no-console': 'error',
eqeqeq: ['error', 'smart'],
'prefer-const': 'error',
'no-implicit-globals': 'error',
'no-shadow-restricted-names': 'error',
},
},
{
files: ['scripts/**/*.{js,mjs}', 'eslint.config.js'],
languageOptions: {
sourceType: 'module',
globals: globals.node,
},
rules: {
'no-console': 'off',
},
},
];
-947
View File
File diff suppressed because it is too large. Load diff
-5
View File
@@ -16,8 +16,6 @@
"verify": "node src/cli/verify.ts",
"repair": "node src/cli/repair.ts",
"typecheck": "tsc --noEmit",
"lint": "eslint .",
"lint:fix": "eslint . --fix",
"format": "prettier --write .",
"format:check": "prettier --check .",
"test": "node --test test/*.test.ts",
@@ -32,10 +30,7 @@
"sharp": "^0.35.4"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@types/node": "^26.6.2",
"eslint": "^10.11.0",
"globals": "^17.12.0",
"prettier": "^3.9.8",
"typescript": "^7.0.2"
}