Match the deploy system to hops and greatbear
menu-admin was the odd one out: it built from the working directory with a date tag and a multi-arch manifest, deployed with `-e tag=`, and kept the Docker-era container name. The other two apps build from `git archive HEAD` and tag with the commit, so what runs on the host is exactly what git knows about; build only for the architecture emily actually runs; and use one slug for the container, its network and its data directory. The build is now their three lines: `podman build --platform linux/arm64` from `git archive HEAD`, tagged `:<commit>` and `:latest`, pushed with `podman push` rather than a manifest. `deploy.sh` pushes the commit first, then refuses a dirty tracked tree or a HEAD that is not origin/main — which is what makes the commit tag trustworthy — takes the same `--force` and `--no-push`, and ends in the same `-u tanshu ... -e image_tag=<commit>`. Its own lint/typecheck/test gate stays, but behind `--checks`, so a plain deploy behaves as theirs does. The registry credential moves into vars/default.yml and the role logs in, pulls and logs out around it, as their roles do. The image is only pulled at deploy time, so the unit keeps podman's default `Pull=missing` and a reboot never needs the registry. This is the same plaintext credential the other two repos carry. One real bug goes with it: the healthcheck gate ran before the handler that restarts a changed unit, so a run that only changed the unit checked the stale container. `meta: flush_handlers` before the gate is the fix the sibling repos already made. The container, network and data directory are all menu-<site> now. The dotted Docker-era name and the NetworkAlias it needed are gone, so `podman logs menu-<site>` and `systemctl --user status menu-<site>` are the same two commands across all three apps. The Dockerfile keeps its name for the same reason: podman reads it as readily as a Containerfile, and the siblings use that name too.
This commit is contained in:
1 parent
e68f8ab00d
commit
537a4f1e46
21 files changed
+400
-300
No files matched your search
+1
-1
@@ -8,7 +8,7 @@ public/fonts/OFL.txt
|
|||||||
docs
|
docs
|
||||||
test
|
test
|
||||||
compose.yaml
|
compose.yaml
|
||||||
Containerfile
|
Dockerfile
|
||||||
.containerignore
|
.containerignore
|
||||||
README.md
|
README.md
|
||||||
# design prototype, never committed to main and never shipped
|
# design prototype, never committed to main and never shipped
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
# Built with podman. `podman build` reads a Containerfile as readily as a
|
# Built with Podman/Buildah, not Docker — see the Makefile. The file keeps the
|
||||||
# Dockerfile, and the name says which toolchain the pipeline actually uses.
|
# Dockerfile name because podman reads it as readily as a Containerfile, and the
|
||||||
|
# sibling apps use the same name.
|
||||||
#
|
#
|
||||||
# The app runs TypeScript directly on Node 24, so there is no build step and no
|
# The app runs TypeScript directly on Node 24, so there is no build step and no
|
||||||
# compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt
|
# compiler in the image. Node 24 on Alpine is musl, which sharp ships prebuilt
|
||||||
@@ -1,23 +1,42 @@
|
|||||||
|
# menu-admin — container image builds.
|
||||||
|
#
|
||||||
|
# Built with Podman/Buildah, not Docker, from the committed tree
|
||||||
|
# (`git archive HEAD`) and tagged with that commit, so a deploy ships exactly
|
||||||
|
# what git knows about. deploy.sh pushes first and refuses a dirty tree.
|
||||||
|
|
||||||
|
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
|
# The deploy host (emily) is aarch64, so that is the default target: the image
|
||||||
|
# is built for the machine it runs on, not the one running make. Podman carries
|
||||||
|
# no built-in QEMU (unlike docker buildx), so a foreign platform needs binfmt
|
||||||
|
# handlers on this host — qemu-user-binfmt is installed here. Override with e.g.
|
||||||
|
# `make build-production PLATFORM=linux/amd64`.
|
||||||
|
PLATFORM ?= linux/arm64
|
||||||
|
|
||||||
IMAGE ?= registry.tanshu.com/menu-admin
|
IMAGE ?= registry.tanshu.com/menu-admin
|
||||||
PLATFORMS ?= linux/amd64,linux/arm64
|
|
||||||
|
|
||||||
.PHONY: build-production build-check build-check-local
|
.PHONY: build-production
|
||||||
|
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push
|
||||||
|
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest"
|
||||||
|
@git archive --format=tar HEAD | podman build \
|
||||||
|
--platform $(PLATFORM) \
|
||||||
|
--tag $(IMAGE):$(COMMIT) \
|
||||||
|
--tag $(IMAGE):latest \
|
||||||
|
--pull=always \
|
||||||
|
- || { \
|
||||||
|
echo "✗ podman build failed."; \
|
||||||
|
echo " Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \
|
||||||
|
echo " sudo apt install qemu-user-binfmt # or qemu-user-static"; \
|
||||||
|
exit 1; \
|
||||||
|
}
|
||||||
|
podman push $(IMAGE):$(COMMIT)
|
||||||
|
podman push $(IMAGE):latest
|
||||||
|
|
||||||
# Mirrors the other apps on this machine: a podman manifest build, so one tag
|
.PHONY: build-check
|
||||||
# carries both architectures, pushed to the private registry.
|
build-check: ## Compile check from HEAD with podman, nothing pushed
|
||||||
build-production: ## Build and push the production image. Usage: make build-production TAG=2026-09-22
|
@echo "==> podman build --platform $(PLATFORM) (no push)"
|
||||||
@test -n "$(TAG)" || { echo "Usage: make build-production TAG=2026-09-22" >&2; exit 1; }
|
git archive --format=tar HEAD | podman build \
|
||||||
@echo "Building image..."
|
--platform $(PLATFORM) \
|
||||||
@podman manifest rm $(IMAGE):latest 2>/dev/null || true
|
--tag localhost/menu-admin:check \
|
||||||
@podman build --platform $(PLATFORMS) --manifest $(IMAGE):latest --pull .
|
--pull=always \
|
||||||
@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,17 +17,24 @@ and moving DNS, which needs a human present.
|
|||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./deploy.sh 2026-09-22 # format, types, tests, podman build+push, deploy
|
./deploy.sh # push, build <commit>, deploy every Site to emily
|
||||||
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
|
./deploy.sh --no-push # skip the push (you already pushed)
|
||||||
|
./deploy.sh --force # skip the guards
|
||||||
|
./deploy.sh --checks # lint, format, typecheck and test first
|
||||||
|
SITE=greatbear ./deploy.sh # one Site instead of every Site
|
||||||
```
|
```
|
||||||
|
|
||||||
The image is built with **podman** as a multi-arch manifest and pushed to
|
The image is built with **Podman/Buildah** (not Docker) from the committed tree
|
||||||
`registry.tanshu.com/menu-admin` (`Makefile`, mirrored from the other apps on
|
(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host
|
||||||
this machine). The Ansible deployment lives in `ansible/` in this repo, with one
|
is exactly what git knows about; `deploy.sh` refuses a dirty tracked tree and
|
||||||
inventory entry per Site. It writes a rootless Podman **Quadlet** unit per Site
|
requires `HEAD == origin/main`. It is built for **linux/arm64** because `emily`
|
||||||
and appends the Site's block to the shared Caddyfile, exactly as the sibling
|
is aarch64, and pushed to `registry.tanshu.com/menu-admin`.
|
||||||
services in `ansible-playbooks` do; see `ansible/readme.md` for how a deploy runs
|
|
||||||
and how to roll one back.
|
The Ansible deployment lives in `ansible/` in this repo, with one inventory entry
|
||||||
|
per Site. It writes a rootless Podman **Quadlet** unit per Site and appends the
|
||||||
|
Site's block to the shared Caddyfile, exactly as the sibling `hops` and
|
||||||
|
`greatbear` apps do; see `ansible/readme.md` for how a deploy runs and how to
|
||||||
|
roll one back.
|
||||||
|
|
||||||
## Layout
|
## Layout
|
||||||
|
|
||||||
@@ -50,9 +57,9 @@ test/ node:test suites
|
|||||||
docs/plan.md The agreed plan
|
docs/plan.md The agreed plan
|
||||||
docs/operating.md Publishing, fixing and rolling back, for whoever operates it
|
docs/operating.md Publishing, fixing and rolling back, for whoever operates it
|
||||||
docs/adr/ Decisions worth remembering
|
docs/adr/ Decisions worth remembering
|
||||||
Containerfile The image, built by podman
|
Dockerfile The image, built by podman
|
||||||
compose.yaml Local dev: podman compose
|
compose.yaml Local dev: podman compose
|
||||||
deploy.sh Tests, build, push, deploy
|
deploy.sh Build HEAD, push it, deploy it
|
||||||
ansible/ The deployment: one rootless Quadlet unit per Site
|
ansible/ The deployment: one rootless Quadlet unit per Site
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+14
-13
@@ -1,23 +1,24 @@
|
|||||||
---
|
---
|
||||||
# Deploys the menu app for one or more Sites as rootless Podman Quadlet units on
|
# Deploys the menu app for one or more Sites as rootless Podman Quadlet units on
|
||||||
# emily, behind the shared Caddy unit that ansible-playbooks/caddy owns. It lives
|
# emily, behind the fleet's shared Caddy unit (caddy/playbook.yml in the
|
||||||
# here, beside the app it deploys, and is run from this directory:
|
# ansible-playbooks repo). It lives here, beside the app it deploys.
|
||||||
#
|
|
||||||
# ./deploy.sh <tag> (repo root)
|
|
||||||
# ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=<tag>
|
|
||||||
#
|
#
|
||||||
# Connects as the container user and runs everything as that user: the Quadlet
|
# Connects as the container user and runs everything as that user: the Quadlet
|
||||||
# units belong to it, so the connection's PAM session is what makes
|
# units belong to it, so the connection's PAM session is what makes
|
||||||
# `systemctl --user` work without any environment juggling.
|
# `systemctl --user` and `podman exec` work without any environment juggling.
|
||||||
|
# There is deliberately no `become`: root-only prerequisites (Podman,
|
||||||
|
# `loginctl enable-linger`, the low-port sysctl Caddy needs) belong to
|
||||||
|
# caddy/playbook.yml, which must have run on this host first.
|
||||||
#
|
#
|
||||||
# gather_facts is required: podman_user and the home directory come from the
|
# gather_facts is required: podman_user, podman_data_dir and quadlet_dir are
|
||||||
# user_id / user_dir facts.
|
# derived from the user_id / user_dir facts.
|
||||||
#
|
#
|
||||||
# The rootless-Podman prerequisites (installing podman, enabling lingering, the
|
# The inventory is the one difference from the sibling apps, which deploy a
|
||||||
# low-port sysctl, creating the Quadlet directory) are deliberately NOT applied
|
# single app: this play carries three Sites and a var_file per Site.
|
||||||
# here. They are the shared task file at
|
#
|
||||||
# ansible-playbooks/caddy/roles/podman/tasks/main.yaml, and a host that has
|
# ./deploy.sh (repo root)
|
||||||
# already run ansible-playbooks/caddy/playbook.yml has them.
|
# SITE=greatbear ./deploy.sh one Site
|
||||||
|
# cd ansible && ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=<commit>
|
||||||
- name: Deploy the menu app as rootless Podman Quadlets
|
- name: Deploy the menu app as rootless Podman Quadlets
|
||||||
hosts: menus
|
hosts: menus
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
|
|||||||
+34
-32
@@ -4,8 +4,8 @@ Lives here, beside the app it deploys. One run deploys a Site: it starts the
|
|||||||
app's container as a **rootless Podman Quadlet unit** on `emily` and points the
|
app's container as a **rootless Podman Quadlet unit** on `emily` and points the
|
||||||
Site's web address at it through the shared **Caddy** unit that
|
Site's web address at it through the shared **Caddy** unit that
|
||||||
`ansible-playbooks/caddy` owns. There is no older system to keep serving, so
|
`ansible-playbooks/caddy` owns. There is no older system to keep serving, so
|
||||||
there is no staged rollout and nothing to retire — deploying an earlier tag is
|
there is no staged rollout and nothing to retire — deploying the image built from
|
||||||
the rollback.
|
an earlier commit is the rollback.
|
||||||
|
|
||||||
This is the same shape as the sibling services in `ansible-playbooks` (gitea,
|
This is the same shape as the sibling services in `ansible-playbooks` (gitea,
|
||||||
nextcloud, tandoor, bitwarden, valkey, postgres, trillium): rootless Podman, one
|
nextcloud, tandoor, bitwarden, valkey, postgres, trillium): rootless Podman, one
|
||||||
@@ -49,15 +49,15 @@ the same mapping for the same reason.
|
|||||||
| Theme colours | `config/<site>.site.json` in the repo, baked into the image |
|
| Theme colours | `config/<site>.site.json` in the repo, baked into the image |
|
||||||
| The unit that runs | `~/.config/containers/systemd/menu-<site>.container` |
|
| The unit that runs | `~/.config/containers/systemd/menu-<site>.container` |
|
||||||
| The network and Caddy drop-in | `menu-<site>.network`, `caddy.container.d/50-menu-<site>.conf` |
|
| The network and Caddy drop-in | `menu-<site>.network`, `caddy.container.d/50-menu-<site>.conf` |
|
||||||
| Which image tag runs | `-e tag=<tag>` |
|
| Which image tag runs | `-e image_tag=<commit>` |
|
||||||
| Which Site is deployed | the inventory entry in `hosts` |
|
| Which Site is deployed | the inventory entry in `hosts` |
|
||||||
| The web address it serves | `host:` in `vars/<site>.yml` |
|
| The web address it serves | `host:` in `vars/<site>.yml` |
|
||||||
|
|
||||||
`<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`). The
|
`<site>` is the inventory name (`hopsngrains`, `greatbear`, `hngmohali`), and it
|
||||||
container itself keeps the Docker-era name `menu-app.<domain>`, because
|
is the single slug for everything: the container, its Podman network and its data
|
||||||
`provision-site-env.sh` and muscle memory look it up that way; the Caddyfile
|
directory are all `menu-<site>`. That is how the sibling `hops` and `greatbear`
|
||||||
proxies to the dotless `menu-<site>` alias, because a container name with dots in
|
apps are deployed, and it keeps `podman logs menu-<site>` and
|
||||||
it is a liability in DNS.
|
`systemctl --user status menu-<site>` the same two commands across all three.
|
||||||
|
|
||||||
## What the host needs
|
## What the host needs
|
||||||
|
|
||||||
@@ -69,17 +69,11 @@ Those prerequisites deliberately are **not** duplicated here, so a host that has
|
|||||||
not run that playbook will fail at the first `systemctl --user` — see
|
not run that playbook will fail at the first `systemctl --user` — see
|
||||||
`ansible-playbooks/caddy/readme.md`.
|
`ansible-playbooks/caddy/readme.md`.
|
||||||
|
|
||||||
The image is in the private registry, so log in once as `tanshu` on `emily`,
|
The registry is private, but nothing here needs a manual login: the play logs in
|
||||||
before the first deploy:
|
from `registry_username` / `registry_password` in `vars/default.yml`, pulls the
|
||||||
|
image, and logs out again, so the credential is never left in the target user's
|
||||||
```sh
|
auth file. The unit keeps podman's default `Pull=missing` on purpose, so a
|
||||||
podman login registry.tanshu.com
|
restart or a reboot never depends on the registry being reachable.
|
||||||
```
|
|
||||||
|
|
||||||
The play pulls the image itself, so a missing login fails there with that
|
|
||||||
instruction rather than as a container that will not start. The unit keeps
|
|
||||||
podman's default `Pull=missing` on purpose, so a restart or a reboot never
|
|
||||||
depends on the registry being reachable.
|
|
||||||
|
|
||||||
`ssh` on some machines complains about
|
`ssh` on some machines complains about
|
||||||
`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that is
|
`/etc/ssh/ssh_config.d/20-systemd-ssh-proxy.conf` being owned by `nobody`; that is
|
||||||
@@ -91,21 +85,27 @@ a local file-permission bug whose fix is `sudo chown -R root:root
|
|||||||
From the repo root:
|
From the repo root:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./deploy.sh 2026-09-22 # checks, podman build+push, then deploys
|
./deploy.sh # push HEAD, build <commit>, deploy every Site
|
||||||
SKIP_DEPLOY=1 ./deploy.sh 2026-09-22 # build and push only
|
./deploy.sh --no-push # skip the push (you already pushed)
|
||||||
|
./deploy.sh --force # skip the guards
|
||||||
|
./deploy.sh --checks # lint, format, typecheck and test first
|
||||||
|
SITE=greatbear ./deploy.sh # one Site instead of every Site
|
||||||
```
|
```
|
||||||
|
|
||||||
`deploy.sh` runs `make build-production TAG=<tag>`, which builds a multi-arch
|
`deploy.sh` requires a clean tracked tree and `HEAD == origin/main`, tags the
|
||||||
podman manifest (`linux/amd64,linux/arm64`) and pushes `latest` and the tag to
|
build with the commit it came from, and refuses a dirty tree for a reason: the
|
||||||
`registry.tanshu.com/menu-admin`. Then it runs this playbook against every Site,
|
image is built from `git archive HEAD`, so what runs on the host is exactly what
|
||||||
or the one named by `SITE`.
|
git knows about. It runs `make build-production COMMIT=<commit>`, which builds
|
||||||
|
`linux/arm64` — the architecture `emily` runs — and pushes both the commit tag
|
||||||
|
and `latest` to `registry.tanshu.com/menu-admin`.
|
||||||
|
|
||||||
By hand:
|
By hand:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make build-production TAG=2026-09-22
|
git push
|
||||||
|
make build-production COMMIT=$(git rev-parse --short HEAD)
|
||||||
cd ansible
|
cd ansible
|
||||||
ansible-playbook --inventory=hosts playbook.yml --limit hopsngrains -e tag=2026-09-22
|
ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=$(git rev-parse --short HEAD)
|
||||||
```
|
```
|
||||||
|
|
||||||
Every Site runs on the same box, so `hosts` has one entry per Site pointing at
|
Every Site runs on the same box, so `hosts` has one entry per Site pointing at
|
||||||
@@ -115,8 +115,8 @@ what gives us a real PAM session, which is what makes `systemctl --user` work.
|
|||||||
Connecting as anyone else would write units into the wrong home.
|
Connecting as anyone else would write units into the wrong home.
|
||||||
|
|
||||||
Adding a Site is one line in `hosts`, a copy of `vars/hopsngrains.yml`, and a
|
Adding a Site is one line in `hosts`, a copy of `vars/hopsngrains.yml`, and a
|
||||||
`config/<site>.site.json`. The image is shared, so the config has to be in it: a
|
`config/<site>.site.json`. The image is shared and carries every Site's config,
|
||||||
new Site needs a new tag.
|
so a new Site needs a new build — which the commit tag gives it.
|
||||||
|
|
||||||
## Migrating a Site's data from `monoco`
|
## Migrating a Site's data from `monoco`
|
||||||
|
|
||||||
@@ -205,13 +205,15 @@ something.
|
|||||||
|
|
||||||
## Rolling back
|
## Rolling back
|
||||||
|
|
||||||
Deploy the previous tag:
|
Deploy the image built from an earlier commit — the commit tag is what makes
|
||||||
|
this a one-liner, since every commit ever pushed to the registry is still there:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
./deploy.sh <previous-tag>
|
cd ansible
|
||||||
|
ansible-playbook -u tanshu --inventory=hosts playbook.yml -e image_tag=<older-commit>
|
||||||
```
|
```
|
||||||
|
|
||||||
`podman logs menu-app.<site>` and `journalctl --user -u menu-<site>` are where a
|
`podman logs menu-<site>` and `journalctl --user -u menu-<site>` are where a
|
||||||
container that will not start says why. To go all the way back to the Docker
|
container that will not start says why. To go all the way back to the Docker
|
||||||
deployment, start the container that was left on `monoco` and point Caddy at it
|
deployment, start the container that was left on `monoco` and point Caddy at it
|
||||||
again.
|
again.
|
||||||
@@ -1,50 +1,25 @@
|
|||||||
---
|
---
|
||||||
# Append this Site's block to the shared Caddyfile.
|
# Append this Site's block to the shared Caddyfile.
|
||||||
#
|
#
|
||||||
# Caddy is its own playbook (ansible-playbooks/caddy/playbook.yml) and a rootless
|
# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the
|
||||||
# Quadlet unit there, so this role does not manage the container — it only points
|
# change as soon as the file is written; the handler only asks Caddy to re-read
|
||||||
# it at the app. The Caddyfile is a bind mount into that container, so Caddy sees
|
# it. That is a reload, not a restart, so the other hosted sites are untouched.
|
||||||
# a written change as soon as the file lands; the handler only asks Caddy to
|
|
||||||
# re-read it. That is a reload, not a restart, so the other hosted sites are
|
|
||||||
# untouched.
|
|
||||||
#
|
#
|
||||||
# blockinfile is idempotent on its own via the marker, so there is no need to
|
# blockinfile is idempotent on its own via the marker, so there is no need to
|
||||||
# slurp the file and compare first — which also matters because slurp *fails*
|
# slurp the file and compare first — which also matters because slurp *fails*
|
||||||
# when the Caddyfile does not exist yet, on a host where the Caddy playbook has
|
# when the Caddyfile does not exist yet, on a host where caddy/playbook.yml has
|
||||||
# not run.
|
# not run.
|
||||||
|
#
|
||||||
|
# The marker is the same one the retired nginx-menu playbook wrote per hostname,
|
||||||
|
# so a Site cut over from it has its block replaced in place rather than a second
|
||||||
|
# block for one address appended, which would make Caddy refuse the whole config.
|
||||||
|
|
||||||
- name: Check the Caddyfile exists
|
- name: 'Add the {{ host }} site block to the Caddyfile'
|
||||||
ansible.builtin.stat:
|
|
||||||
path: '{{ caddyfile_path }}'
|
|
||||||
register: menu_caddyfile
|
|
||||||
|
|
||||||
- name: Fail when Caddy is not where we expect it
|
|
||||||
ansible.builtin.fail:
|
|
||||||
msg: >-
|
|
||||||
{{ caddyfile_path }} is missing. Deploy the Caddy playbook
|
|
||||||
(ansible-playbooks/caddy/playbook.yml), which owns the Caddy unit and its
|
|
||||||
config, before this one.
|
|
||||||
when: not menu_caddyfile.stat.exists
|
|
||||||
|
|
||||||
# The Site's web address may still be claimed by an older snippet: the retired
|
|
||||||
# nginx-menu playbook wrote one per hostname. Two blocks for one address make
|
|
||||||
# Caddy refuse the whole config, and a refused reload leaves the old proxy in
|
|
||||||
# place, which is a 502 once the old container is gone.
|
|
||||||
- name: Drop any older snippet claiming this web address
|
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: '{{ caddyfile_path }}'
|
path: '{{ caddyfile_path }}'
|
||||||
marker: '# {mark} Ansible managed Caddy snippet for {{ host }}'
|
marker: '# {mark} Ansible managed Caddy snippet for {{ host }}'
|
||||||
state: absent
|
block: '{{ snippet_block }}'
|
||||||
notify: Reload Caddy
|
create: true
|
||||||
|
|
||||||
# blockinfile replaces whatever sits between its markers, so redeploying updates
|
|
||||||
# the snippet in place rather than appending a second one.
|
|
||||||
- name: Point the Site's web address at the app
|
|
||||||
ansible.builtin.blockinfile:
|
|
||||||
path: '{{ caddyfile_path }}'
|
|
||||||
marker: '# {mark} ANSIBLE MANAGED: menu app ({{ site }})'
|
|
||||||
block: '{{ caddy_snippet }}'
|
|
||||||
create: false
|
|
||||||
owner: '{{ podman_user }}'
|
owner: '{{ podman_user }}'
|
||||||
group: '{{ podman_user }}'
|
group: '{{ podman_user }}'
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
|
|||||||
@@ -2,29 +2,28 @@
|
|||||||
# The menu app as a rootless Podman Quadlet unit.
|
# The menu app as a rootless Podman Quadlet unit.
|
||||||
#
|
#
|
||||||
# Everything here runs as {{ podman_user }} — the connection user. No become, no
|
# Everything here runs as {{ podman_user }} — the connection user. No become, no
|
||||||
# XDG_RUNTIME_DIR: the SSH connection gave us a real PAM session, so systemd's
|
# XDG_RUNTIME_DIR: the SSH connection gives a real PAM session, so systemd's
|
||||||
# user manager is reachable through the standard environment.
|
# user manager is reachable through the standard environment.
|
||||||
|
|
||||||
# The pull is explicit, rather than left to the Quadlet unit, for two reasons.
|
# The registry is private, so the pull below has to be authenticated. Podman
|
||||||
# The image lives in a private registry, so a failure here is where a missing
|
# keeps the credential in the user's own auth file; it is removed again at the
|
||||||
# `podman login` should show up — not two tasks later as "unit not found". And
|
# end of this role, so nothing but the image is left behind. Skipped when no
|
||||||
# the unit keeps podman's default `Pull=missing`, so it never reaches for the
|
# credentials are configured.
|
||||||
# registry on a restart or after a reboot: that is what lets a box come back up
|
- name: Log in to the private registry
|
||||||
# when the registry is down. Pulling here is what keeps a redeployed tag current.
|
ansible.builtin.command:
|
||||||
- name: Pull the app image
|
cmd: 'podman login --username {{ registry_username }} --password-stdin {{ registry }}'
|
||||||
ansible.builtin.command: 'podman pull {{ image_name }}:{{ image_tag }}'
|
stdin: '{{ registry_password }}'
|
||||||
register: menu_pull
|
no_log: true
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
when:
|
||||||
|
- registry_username | length > 0
|
||||||
|
- registry_password | length > 0
|
||||||
|
|
||||||
- name: Fail when the image cannot be pulled
|
# Pulled here rather than left to the unit: Quadlet only pulls when the image is
|
||||||
ansible.builtin.fail:
|
# missing, and at that point the service has no registry credential to offer.
|
||||||
msg: >-
|
- name: Pull the menu app image
|
||||||
podman could not pull {{ image_name }}:{{ image_tag }}.
|
ansible.builtin.command: 'podman pull {{ image }}'
|
||||||
{{ menu_pull.stderr | default('') | trim }}
|
changed_when: false
|
||||||
The registry is private: run `podman login registry.tanshu.com` once as
|
|
||||||
{{ podman_user }} on this host.
|
|
||||||
when: menu_pull.rc != 0
|
|
||||||
|
|
||||||
# The data directory is created here rather than left to the image. Under
|
# The data directory is created here rather than left to the image. Under
|
||||||
# keep-id the image's own root phase maps to a subuid, so anything it tries to
|
# keep-id the image's own root phase maps to a subuid, so anything it tries to
|
||||||
@@ -32,7 +31,7 @@
|
|||||||
# owned by {{ podman_user }}.
|
# owned by {{ podman_user }}.
|
||||||
- name: Ensure the app root exists
|
- name: Ensure the app root exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: '{{ app_root }}'
|
path: '{{ host_directory }}'
|
||||||
state: directory
|
state: directory
|
||||||
owner: '{{ podman_user }}'
|
owner: '{{ podman_user }}'
|
||||||
group: '{{ podman_user }}'
|
group: '{{ podman_user }}'
|
||||||
@@ -42,7 +41,7 @@
|
|||||||
# data/ (originals/, drafts/, published/, site.json, sections.json).
|
# data/ (originals/, drafts/, published/, site.json, sections.json).
|
||||||
- name: Ensure the data directory exists
|
- name: Ensure the data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: '{{ app_root }}/data'
|
path: '{{ host_directory }}/data'
|
||||||
state: directory
|
state: directory
|
||||||
owner: '{{ podman_user }}'
|
owner: '{{ podman_user }}'
|
||||||
group: '{{ podman_user }}'
|
group: '{{ podman_user }}'
|
||||||
@@ -50,21 +49,29 @@
|
|||||||
|
|
||||||
- name: Look for the untracked site.env
|
- name: Look for the untracked site.env
|
||||||
ansible.builtin.stat:
|
ansible.builtin.stat:
|
||||||
path: '{{ app_root }}/site.env'
|
path: '{{ host_directory }}/site.env'
|
||||||
register: menu_site_env
|
register: menu_site_env
|
||||||
|
|
||||||
- name: Warn when the admin has no password yet
|
- name: Warn when the admin has no password yet
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: >-
|
msg: >-
|
||||||
{{ app_root }}/site.env is missing, so the admin is disabled: the public
|
{{ host_directory }}/site.env is missing, so the admin is disabled: the
|
||||||
menu still serves. Run scripts/provision-site-env.sh {{ app_root }} on this
|
public menu still serves. Run scripts/provision-site-env.sh
|
||||||
host, then re-run this playbook.
|
{{ host_directory }} on this host, then re-run this playbook.
|
||||||
when: not menu_site_env.stat.exists
|
when: not menu_site_env.stat.exists
|
||||||
|
|
||||||
|
- name: Keep site.env readable only by the app user
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: '{{ host_directory }}/site.env'
|
||||||
|
owner: '{{ podman_user }}'
|
||||||
|
group: '{{ podman_user }}'
|
||||||
|
mode: '0600'
|
||||||
|
when: menu_site_env.stat.exists
|
||||||
|
|
||||||
- name: Install the menu app Quadlet unit
|
- name: Install the menu app Quadlet unit
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: menu-app.container.j2
|
src: menu-app.container.j2
|
||||||
dest: '{{ quadlet_dir }}/{{ network_name }}.container'
|
dest: '{{ quadlet_dir }}/{{ container_name }}.container'
|
||||||
owner: '{{ podman_user }}'
|
owner: '{{ podman_user }}'
|
||||||
group: '{{ podman_user }}'
|
group: '{{ podman_user }}'
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
@@ -82,14 +89,14 @@
|
|||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd reports "control process exited with error code" and nothing else,
|
||||||
# which is useless for the usual failures (a missing network unit, an
|
# which is useless for the usual failures (a missing image, an unattached
|
||||||
# unwritable /data, a bad uid mapping). Pull the real reason out.
|
# network, an unwritable data directory). Pull the real reason out.
|
||||||
- name: Collect diagnostics
|
- name: Collect diagnostics
|
||||||
ansible.builtin.command: '{{ item }}'
|
ansible.builtin.command: '{{ item }}'
|
||||||
loop:
|
loop:
|
||||||
- 'systemctl --user status {{ unit_name }} --no-pager'
|
- 'systemctl --user status {{ unit_name }} --no-pager'
|
||||||
- 'journalctl --user -u {{ unit_name }} --no-pager -n 40'
|
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
|
||||||
- 'podman logs --tail 40 {{ container_name }}'
|
- 'podman logs --tail 60 {{ container_name }}'
|
||||||
register: menu_diagnostics
|
register: menu_diagnostics
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
@@ -102,9 +109,18 @@
|
|||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: 'The menu app did not start — see the diagnostics above.'
|
msg: 'The menu app did not start — see the diagnostics above.'
|
||||||
|
|
||||||
|
# The unit template notifies "Restart the menu app", but handlers otherwise run
|
||||||
|
# at the very end of the play — after the healthcheck gate below. Flush them
|
||||||
|
# here so the gate tests the container that has the current HealthCmd; without
|
||||||
|
# this, a run that only changes the unit would check the stale container and
|
||||||
|
# fail. On a run where nothing changed, no handler is pending and this is a
|
||||||
|
# no-op.
|
||||||
|
- name: Apply pending unit changes before verifying
|
||||||
|
ansible.builtin.meta: flush_handlers
|
||||||
|
|
||||||
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
# Quadlet honours [Install] WantedBy= when it generates the unit, so
|
||||||
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
# `systemctl --user enable` is neither needed nor possible on a generated unit.
|
||||||
- name: Verify the menu app is running
|
- name: Verify the menu app unit is active
|
||||||
ansible.builtin.command: 'systemctl --user is-active {{ unit_name }}'
|
ansible.builtin.command: 'systemctl --user is-active {{ unit_name }}'
|
||||||
register: menu_active
|
register: menu_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
@@ -112,13 +128,13 @@
|
|||||||
|
|
||||||
# `is-active` says the container is running; it does not say the app can serve.
|
# `is-active` says the container is running; it does not say the app can serve.
|
||||||
# Restart=always means even a crash-looping container is reported active — which
|
# Restart=always means even a crash-looping container is reported active — which
|
||||||
# is exactly what a fatal integrity problem in the data directory produces. The
|
# is what a fatal integrity problem in the data directory produces. The unit
|
||||||
# unit declares the health check, so run that once, synchronously.
|
# declares the health check, so run that once, synchronously.
|
||||||
- name: Wait for the app to pass its health check
|
- name: Wait for the container healthcheck to pass
|
||||||
ansible.builtin.command: 'podman healthcheck run {{ container_name }}'
|
ansible.builtin.command: 'podman healthcheck run {{ container_name }}'
|
||||||
register: menu_health
|
register: menu_health
|
||||||
until: menu_health.rc == 0
|
until: menu_health.rc == 0
|
||||||
retries: 24
|
retries: 36
|
||||||
delay: 5
|
delay: 5
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
@@ -130,8 +146,8 @@
|
|||||||
ansible.builtin.command: '{{ item }}'
|
ansible.builtin.command: '{{ item }}'
|
||||||
loop:
|
loop:
|
||||||
- 'systemctl --user status {{ unit_name }} --no-pager'
|
- 'systemctl --user status {{ unit_name }} --no-pager'
|
||||||
- 'journalctl --user -u {{ unit_name }} --no-pager -n 40'
|
- 'journalctl --user -u {{ unit_name }} --no-pager -n 60'
|
||||||
- 'podman logs --tail 40 {{ container_name }}'
|
- 'podman logs --tail 60 {{ container_name }}'
|
||||||
register: menu_health_diagnostics
|
register: menu_health_diagnostics
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
@@ -143,16 +159,8 @@
|
|||||||
- name: Fail
|
- name: Fail
|
||||||
ansible.builtin.fail:
|
ansible.builtin.fail:
|
||||||
msg: >-
|
msg: >-
|
||||||
{{ container_name }} did not pass its health check within two minutes.
|
{{ container_name }} did not pass its health check within three
|
||||||
Its logs are above, and: podman logs {{ container_name }}
|
minutes. Its logs are above, and: podman logs {{ container_name }}
|
||||||
|
|
||||||
- name: Keep site.env readable only by the app user
|
|
||||||
ansible.builtin.file:
|
|
||||||
path: '{{ app_root }}/site.env'
|
|
||||||
owner: '{{ podman_user }}'
|
|
||||||
group: '{{ podman_user }}'
|
|
||||||
mode: '0600'
|
|
||||||
when: menu_site_env.stat.exists
|
|
||||||
|
|
||||||
- name: Check the data directory for integrity problems
|
- name: Check the data directory for integrity problems
|
||||||
# Reads the same directory the container writes, as the same user, so a
|
# Reads the same directory the container writes, as the same user, so a
|
||||||
@@ -176,3 +184,27 @@
|
|||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: '{{ menu_verify.stdout_lines | default([]) }}'
|
msg: '{{ menu_verify.stdout_lines | default([]) }}'
|
||||||
when: menu_verify.rc != 0
|
when: menu_verify.rc != 0
|
||||||
|
|
||||||
|
- name: Show the networks the container is attached to
|
||||||
|
ansible.builtin.command: >-
|
||||||
|
podman inspect {{ container_name }}
|
||||||
|
--format '{% raw %}{{ range $k, $v := .NetworkSettings.Networks }}{{ $k }} {{ end }}{% endraw %}'
|
||||||
|
register: menu_networks_now
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
|
||||||
|
- name: Report the network
|
||||||
|
ansible.builtin.debug:
|
||||||
|
msg: >-
|
||||||
|
{{ container_name }} is on
|
||||||
|
{{ menu_networks_now.stdout | default('(container not running)') }}
|
||||||
|
|
||||||
|
# Removes the credential from the user's auth file. The image is already local
|
||||||
|
# and the unit only pulls when it is missing, so this does not break restarts.
|
||||||
|
- name: Log out of the private registry
|
||||||
|
ansible.builtin.command: 'podman logout {{ registry }}'
|
||||||
|
changed_when: false
|
||||||
|
failed_when: false
|
||||||
|
when:
|
||||||
|
- registry_username | length > 0
|
||||||
|
- registry_password | length > 0
|
||||||
@@ -1,49 +1,46 @@
|
|||||||
# {{ container_name }} — rootless Podman Quadlet unit.
|
# {{ container_name }} — rootless Podman Quadlet unit.
|
||||||
# Managed by Ansible (menu-admin/roles/menu-app). Do not edit by hand.
|
# Managed by Ansible (menu-admin/roles/menu-app). Do not edit by hand.
|
||||||
#
|
#
|
||||||
# Joins {{ network_name }}.network, written by roles/register — the same include
|
# This unit joins {{ network_name }}.network, which is created by
|
||||||
# that attaches Caddy to it. Caddy reaches the app by container name, so nothing
|
# roles/register — the same include that attaches the shared Caddy unit to it,
|
||||||
# is published on the host and the app is not reachable from outside the Podman
|
# so Caddy can resolve {{ container_name }} by name.
|
||||||
# network at all.
|
|
||||||
|
|
||||||
[Unit]
|
[Unit]
|
||||||
Description=Menu app for {{ host }}
|
Description=Menu app for {{ host }} ({{ site }})
|
||||||
After=network-online.target
|
After=network-online.target
|
||||||
Wants=network-online.target
|
Wants=network-online.target
|
||||||
|
|
||||||
[Container]
|
[Container]
|
||||||
ContainerName={{ container_name }}
|
ContainerName={{ container_name }}
|
||||||
Image={{ image_name }}:{{ image_tag }}
|
Image={{ image }}
|
||||||
|
|
||||||
# keep-id maps the image's own account — `node`, uid/gid {{ app_uid }} — onto
|
# keep-id maps the image's own account — `node`, uid/gid {{ app_uid }} — onto
|
||||||
# {{ podman_user }}, which is what keeps every file the app writes owned by
|
# {{ podman_user }}, which keeps every file the app writes owned by
|
||||||
# {{ podman_user }} on the host: plain tar/rsync backups, no subuids, no
|
# {{ podman_user }} on the host: plain tar/rsync backups, no subuids, no
|
||||||
# `podman unshare`. The same mapping is why scripts/provision-site-env.sh writes
|
# `podman unshare`. scripts/provision-site-env.sh writes site.env with the same
|
||||||
# site.env with --userns=keep-id.
|
# mapping rather than --user, which under rootless Podman would have produced a
|
||||||
|
# subuid-owned file the app could not read.
|
||||||
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
|
UserNS=keep-id:uid={{ app_uid }},gid={{ app_gid }}
|
||||||
|
|
||||||
# The alias is deliberately dotless. ContainerName keeps the Docker-era name so
|
|
||||||
# operators and scripts find it, but a name with dots in it is a liability in
|
|
||||||
# DNS, so {{ network_name }} is what the Caddyfile proxies to. Both resolve from
|
|
||||||
# inside the network.
|
|
||||||
NetworkAlias={{ network_name }}
|
|
||||||
|
|
||||||
Environment=MENU_ROOT=/data
|
Environment=MENU_ROOT=/data
|
||||||
Environment=MENU_SITE_CONFIG=/app/config/{{ site }}.site.json
|
Environment=MENU_SITE_CONFIG=/app/config/{{ site }}.site.json
|
||||||
Environment=SECURE_COOKIES=true
|
Environment=SECURE_COOKIES=true
|
||||||
Environment=LOG_LEVEL=info
|
Environment=LOG_LEVEL=info
|
||||||
Environment=PORT={{ container_port }}
|
Environment=PORT={{ site_port }}
|
||||||
|
|
||||||
# Everything the app writes: data/ (originals, drafts, published, site.json,
|
# Everything the app writes: data/ (originals, drafts, published, site.json,
|
||||||
# sections.json) and site.env. The Site's own config is baked into the image and
|
# sections.json) and site.env. The Site's own config is baked into the image and
|
||||||
# read from /app.
|
# read from /app.
|
||||||
Volume={{ app_root }}:/data
|
Volume={{ host_directory }}:/data
|
||||||
|
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
|
|
||||||
# podman builds an OCI image by default and drops the Containerfile's
|
# No PublishPort on purpose: Caddy reaches the app over {{ network_name }}_net,
|
||||||
# HEALTHCHECK instruction, so the same check is declared here and the play gates
|
# so nothing is exposed on the host.
|
||||||
# on it with `podman healthcheck run`. node is in the image.
|
|
||||||
|
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
|
||||||
|
# instruction, so the same check is declared here and the play gates on it with
|
||||||
|
# `podman healthcheck run`. node is in the image.
|
||||||
HealthCmd={{ health_cmd }}
|
HealthCmd={{ health_cmd }}
|
||||||
HealthInterval=30s
|
HealthInterval=30s
|
||||||
HealthTimeout=5s
|
HealthTimeout=5s
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
---
|
---
|
||||||
# Register this Site's logical network with Caddy.
|
# Register this Site's network with the shared Caddy unit.
|
||||||
#
|
#
|
||||||
# Copied from ansible-playbooks/caddy/roles/register/tasks/main.yaml: this repo
|
# Caddy is the base unit (caddy/playbook.yml in the ansible-playbooks repo) and
|
||||||
# carries its own deployment, so it cannot include that file by path. Keep the
|
# knows nothing about its upstreams. A service adds itself by writing two files
|
||||||
# two in step.
|
# into the Quadlet directory:
|
||||||
#
|
#
|
||||||
# Caddy is the *base* unit and knows nothing about its upstreams. A service adds
|
# 1. its own menu-<site>.network unit, and
|
||||||
# itself by writing two files into the Quadlet directory:
|
|
||||||
#
|
|
||||||
# 1. its own <name>.network unit, and
|
|
||||||
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
|
# 2. a drop-in next to caddy.container that pulls that network into Caddy.
|
||||||
#
|
#
|
||||||
# Caddy itself is owned by ansible-playbooks/caddy/playbook.yml; this play only
|
# This mirrors ansible-playbooks/caddy/roles/register/tasks/main.yaml, vendored
|
||||||
# registers with it. The including play defines a "Restart Caddy" handler.
|
# so this repo's deploy.sh stands on its own. Keep the bytes in sync with it.
|
||||||
|
#
|
||||||
|
# Included from playbook.yml's pre_tasks, before the app unit is generated. Runs
|
||||||
|
# as the connection user, which must be {{ podman_user }}.
|
||||||
|
|
||||||
|
- name: Ensure the Quadlet directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: '{{ quadlet_dir }}'
|
||||||
|
state: directory
|
||||||
|
owner: '{{ podman_user }}'
|
||||||
|
group: '{{ podman_user }}'
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
- name: 'Create the {{ network_name }} network unit'
|
- name: 'Create the {{ network_name }} network unit'
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
@@ -28,13 +36,21 @@
|
|||||||
# create "systemd-{{ network_name }}".
|
# create "systemd-{{ network_name }}".
|
||||||
#
|
#
|
||||||
# isolate blocks traffic between separate Podman networks, so a container
|
# isolate blocks traffic between separate Podman networks, so a container
|
||||||
# on this network cannot reach one that is only on another. Caddy and any
|
# on this network cannot reach one that is only on another. Caddy still
|
||||||
# shared service still reach it, because they are members of it.
|
# reaches it, because it is a member of it.
|
||||||
[Network]
|
[Network]
|
||||||
NetworkName={{ network_name }}_net
|
NetworkName={{ network_name }}_net
|
||||||
Options=isolate=true
|
Options=isolate=true
|
||||||
notify: Restart Caddy
|
notify: Restart Caddy
|
||||||
|
|
||||||
|
- name: Ensure the Caddy Quadlet drop-in directory exists
|
||||||
|
ansible.builtin.file:
|
||||||
|
path: '{{ quadlet_dir }}/{{ caddy_name }}.container.d'
|
||||||
|
state: directory
|
||||||
|
owner: '{{ podman_user }}'
|
||||||
|
group: '{{ podman_user }}'
|
||||||
|
mode: '0755'
|
||||||
|
|
||||||
- name: 'Register {{ network_name }} with Caddy'
|
- name: 'Register {{ network_name }} with Caddy'
|
||||||
ansible.builtin.copy:
|
ansible.builtin.copy:
|
||||||
dest: '{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf'
|
dest: '{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf'
|
||||||
@@ -42,7 +58,7 @@
|
|||||||
group: '{{ podman_user }}'
|
group: '{{ podman_user }}'
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# Added by the {{ network_name }} playbook. Do not edit by hand.
|
# Added by the menu-admin playbook. Do not edit by hand.
|
||||||
#
|
#
|
||||||
# Quadlet merges this into caddy.container. Deleting this file removes
|
# Quadlet merges this into caddy.container. Deleting this file removes
|
||||||
# the network from Caddy on the next daemon-reload.
|
# the network from Caddy on the next daemon-reload.
|
||||||
|
|||||||
+40
-31
@@ -5,55 +5,64 @@
|
|||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Rootless Podman / Quadlet
|
# Rootless Podman / Quadlet
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# podman_user is derived from the connection: this play carries no become, so the
|
# Derived from the connection: this play carries no become, so the Quadlet
|
||||||
# Quadlet units belong to whoever Ansible connects as. Requires gather_facts
|
# units belong to whoever Ansible connects as. Requires gather_facts (set in
|
||||||
# (set in playbook.yml).
|
# playbook.yml). A host that has already run caddy/playbook.yml from the
|
||||||
#
|
# ansible-playbooks repo has the rootless prerequisites.
|
||||||
# These three duplicate caddy/vars/default.yml in the ansible-playbooks repo,
|
|
||||||
# which owns the Caddy unit these Sites sit behind. Keep them in sync.
|
|
||||||
podman_user: "{{ ansible_facts['user_id'] }}"
|
podman_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# Which Site config inside the image to run, and which web address it serves.
|
# ---------------------------------------------------------------------------
|
||||||
|
# Image
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
registry: registry.tanshu.com
|
||||||
|
registry_username: ta-registry
|
||||||
|
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
|
||||||
|
|
||||||
|
image_name: '{{ registry }}/menu-admin'
|
||||||
|
tag: latest
|
||||||
|
# deploy.sh passes -e image_tag=<commit>; `tag` is the fallback when running the
|
||||||
|
# play by hand. Rollback = -e image_tag=<older-commit>.
|
||||||
|
image: '{{ image_name }}:{{ image_tag | default(tag) }}'
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The Site
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# One image serves every Site; this picks which config inside it to run. The
|
||||||
|
# inventory name is the slug, so the container, its network and its data
|
||||||
|
# directory are all menu-<site>.
|
||||||
site: '{{ inventory_hostname }}'
|
site: '{{ inventory_hostname }}'
|
||||||
|
app_name: 'menu-{{ site }}'
|
||||||
# ---------------------------------------------------------------------------
|
container_name: '{{ app_name }}'
|
||||||
# The app
|
unit_name: '{{ container_name }}.service'
|
||||||
# ---------------------------------------------------------------------------
|
network_name: '{{ app_name }}'
|
||||||
image_name: registry.tanshu.com/menu-admin
|
|
||||||
image_tag: "{{ tag | default('latest') }}"
|
|
||||||
|
|
||||||
# The unit file is menu-<site>.container and the Podman network is
|
|
||||||
# menu-<site>_net. The container keeps the Docker-era name, dots and all, because
|
|
||||||
# scripts/provision-site-env.sh and anything an operator types look it up that
|
|
||||||
# way — but a name with dots is a DNS liability, so the Caddyfile proxies to the
|
|
||||||
# dotless NetworkAlias the unit sets beside it.
|
|
||||||
network_name: 'menu-{{ site }}'
|
|
||||||
unit_name: '{{ network_name }}.service'
|
|
||||||
container_name: 'menu-app.{{ host }}'
|
|
||||||
|
|
||||||
# The image's own account (`node`), which keep-id maps onto podman_user.
|
# The image's own account (`node`), which keep-id maps onto podman_user.
|
||||||
app_uid: 1000
|
app_uid: 1000
|
||||||
app_gid: 1000
|
app_gid: 1000
|
||||||
|
|
||||||
container_port: 3000
|
# Port inside the container. Caddy proxies to it over menu-<site>_net; nothing
|
||||||
|
# is published on the host.
|
||||||
|
site_port: 3000
|
||||||
|
|
||||||
# podman builds an OCI image by default and drops the Containerfile's
|
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
|
||||||
# HEALTHCHECK instruction, so the unit declares the same check and the play gates
|
# instruction, so the same check is declared on the Quadlet unit and the play
|
||||||
# on it. node is in the image and the sh -c podman wraps it in handles the quotes.
|
# gates on `podman healthcheck run`. node is in the image, and the sh -c podman
|
||||||
|
# wraps it in handles the quotes.
|
||||||
health_cmd: >-
|
health_cmd: >-
|
||||||
node -e "fetch('http://127.0.0.1:{{ container_port }}/healthz').then(r =>
|
node -e "fetch('http://127.0.0.1:{{ site_port }}/healthz').then(r =>
|
||||||
process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# The shared Caddy unit and its Caddyfile, both owned by the caddy playbook in
|
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
|
||||||
# ansible-playbooks (caddy/playbook.yml).
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: '{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile'
|
caddyfile_path: '{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile'
|
||||||
|
|
||||||
caddy_snippet: |
|
# Appended to the shared Caddyfile by roles/caddy. The Site is served from the
|
||||||
|
# container name over menu-<site>_net; no host ports are involved.
|
||||||
|
snippet_block: |
|
||||||
{{ host }} {
|
{{ host }} {
|
||||||
reverse_proxy {{ network_name }}:{{ container_port }}
|
reverse_proxy {{ container_name }}:{{ site_port }}
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# The Great Bear. Its name, tagline, icon, categories and menus live on the
|
# The Great Bear. Its name, tagline, icon, categories and menus live on the
|
||||||
# server under app_root and are edited in the admin; config/greatbear.site.json
|
# server under host_directory and are edited in the admin;
|
||||||
# only seeds the theme and the first category list.
|
# config/greatbear.site.json only seeds the theme and the first category list.
|
||||||
title: greatbear.in
|
title: greatbear.in
|
||||||
host: 'menu.{{ title }}'
|
host: 'menu.{{ title }}'
|
||||||
|
|
||||||
# Everything the app writes — data/ (the content) and site.env (the admin
|
# Everything the app writes — data/ (the content) and site.env (the admin
|
||||||
# password hash and session secret). One directory per Site, each of which is
|
# password hash and session secret). One directory per Site, named for the
|
||||||
# also its own Podman network and its own Caddy upstream.
|
# container that mounts it.
|
||||||
app_root: '{{ podman_data_dir }}/{{ network_name }}'
|
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
---
|
---
|
||||||
# Hops n Grains Mohali. Its name, tagline, icon, categories and menus live on the
|
# Hops n Grains Mohali. Its name, tagline, icon, categories and menus live on the
|
||||||
# server under app_root and are edited in the admin; config/hngmohali.site.json
|
# server under host_directory and are edited in the admin;
|
||||||
# only seeds the theme and the first category list.
|
# config/hngmohali.site.json only seeds the theme and the first category list.
|
||||||
title: hngmohali.com
|
title: hngmohali.com
|
||||||
host: 'menu.{{ title }}'
|
host: 'menu.{{ title }}'
|
||||||
|
|
||||||
# Everything the app writes — data/ (the content) and site.env (the admin
|
# Everything the app writes — data/ (the content) and site.env (the admin
|
||||||
# password hash and session secret). One directory per Site, each of which is
|
# password hash and session secret). One directory per Site, named for the
|
||||||
# also its own Podman network and its own Caddy upstream.
|
# container that mounts it.
|
||||||
app_root: '{{ podman_data_dir }}/{{ network_name }}'
|
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
---
|
---
|
||||||
# Hops n Grains. Its name, tagline, icon, categories and menus live on the server
|
# Hops n Grains. Its name, tagline, icon, categories and menus live on the server
|
||||||
# under app_root and are edited in the admin; config/hopsngrains.site.json only
|
# under host_directory and are edited in the admin; config/hopsngrains.site.json
|
||||||
# seeds the theme. This file says where the Site runs and what it answers.
|
# only seeds the theme. This file says where the Site runs and what it answers.
|
||||||
#
|
#
|
||||||
# Every value that interpolates a template is quoted: YAML reads a leading `{`
|
# Every value that interpolates a template is quoted: YAML reads a leading `{`
|
||||||
# as a flow mapping, which is the parse error Ansible reports as "missing quotes
|
# as a flow mapping, which is the parse error Ansible reports as "missing quotes
|
||||||
@@ -10,6 +10,6 @@ title: hopsngrains.com
|
|||||||
host: 'menu.{{ title }}'
|
host: 'menu.{{ title }}'
|
||||||
|
|
||||||
# Everything the app writes — data/ (the content) and site.env (the admin
|
# Everything the app writes — data/ (the content) and site.env (the admin
|
||||||
# password hash and session secret). One directory per Site, each of which is
|
# password hash and session secret). One directory per Site, named for the
|
||||||
# also its own Podman network and its own Caddy upstream.
|
# container that mounts it.
|
||||||
app_root: '{{ podman_data_dir }}/{{ network_name }}'
|
host_directory: '{{ podman_data_dir }}/menu-{{ site }}'
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
# npm run seed && podman compose up --build
|
# npm run seed && podman compose up --build
|
||||||
#
|
#
|
||||||
# `podman compose` needs a provider on PATH — podman-compose or docker-compose.
|
# `podman compose` needs a provider on PATH — podman-compose or docker-compose.
|
||||||
# The image is built from the Containerfile beside this file.
|
# The image is built from the Dockerfile beside this file.
|
||||||
services:
|
services:
|
||||||
menu:
|
menu:
|
||||||
build: .
|
build: .
|
||||||
|
|||||||
@@ -1,37 +1,65 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
#
|
||||||
# Builds the image with podman, pushes it to the private registry, then deploys
|
# menu-admin deploy — push the current commit, build it with podman, and install
|
||||||
# the Site with the playbook that lives in ansible/. The playbook runs rootless
|
# the Quadlet units on emily with ansible.
|
||||||
# Podman Quadlet units on emily, behind the shared Caddy unit from
|
|
||||||
# ansible-playbooks/caddy.
|
|
||||||
#
|
#
|
||||||
# ./deploy.sh 2026-09-22 deploy the default Site
|
# The image is built from the committed tree (`git archive HEAD`) and tagged
|
||||||
# SITE=greatbear ./deploy.sh 2026-09-22 deploy another Site
|
# with its commit id, so what runs on the host is exactly what git knows about.
|
||||||
# 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
|
|
||||||
#
|
#
|
||||||
|
# ./deploy.sh deploy HEAD (tracked tree must be clean and pushed)
|
||||||
|
# ./deploy.sh --force skip the guards (know what you are doing)
|
||||||
|
# ./deploy.sh --no-push skip the push, if you already pushed
|
||||||
|
# ./deploy.sh --checks lint, format, typecheck and test first
|
||||||
|
#
|
||||||
|
# SITE=<name> deploys one Site instead of every Site.
|
||||||
|
#
|
||||||
|
# Log in to the registry once beforehand: podman login registry.tanshu.com
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
TAG="${1:-}"
|
BRANCH="${BRANCH:-main}"
|
||||||
if [[ -z "$TAG" ]]; then
|
|
||||||
echo "usage: $0 <tag> (e.g. $(date +%Y-%m-%d) or a git short sha)" >&2
|
FORCE=false
|
||||||
|
NO_PUSH=false
|
||||||
|
CHECKS=false
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
--force) FORCE=true ;;
|
||||||
|
--no-push) NO_PUSH=true ;;
|
||||||
|
--checks) CHECKS=true ;;
|
||||||
|
*)
|
||||||
|
echo "Usage: $0 [--force] [--no-push] [--checks]" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# The build takes HEAD, so push first: otherwise the registry collects an image
|
||||||
|
# for a commit that exists only on this machine.
|
||||||
|
if [ "$NO_PUSH" != true ]; then
|
||||||
|
echo "==> pushing"
|
||||||
|
git push
|
||||||
fi
|
fi
|
||||||
|
|
||||||
parent_path=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)
|
# Guards. Only tracked-file state matters — untracked and ignored files never
|
||||||
cd "$parent_path"
|
# enter a `git archive HEAD` build. HEAD must match origin/$BRANCH so the commit
|
||||||
|
# the image is tagged with is one the remote actually has.
|
||||||
if ! command -v podman >/dev/null 2>&1; then
|
if [ "$FORCE" != true ]; then
|
||||||
echo "podman is required to build and push the image" >&2
|
if [ -n "$(git status --porcelain --untracked-files=no)" ]; then
|
||||||
|
echo "x tracked files modified - commit or stash first (or --force)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
|
fi
|
||||||
|
git fetch origin --quiet
|
||||||
|
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then
|
||||||
|
echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "${SKIP_DEPLOY:-}" ]] && ! command -v ansible-playbook >/dev/null 2>&1; then
|
# Opt-in rather than part of the deploy path, so a deploy behaves the same here
|
||||||
echo "ansible-playbook is required to deploy; SKIP_DEPLOY=1 builds and pushes only" >&2
|
# as it does in the sibling apps. Run it before building when the change touches
|
||||||
exit 1
|
# the app rather than only the deployment.
|
||||||
fi
|
if [ "$CHECKS" = true ]; then
|
||||||
|
|
||||||
if [[ -z "${SKIP_CHECKS:-}" ]]; then
|
|
||||||
echo "==> lint, format, types and tests"
|
echo "==> lint, format, types and tests"
|
||||||
npm run lint
|
npm run lint
|
||||||
npm run format:check
|
npm run format:check
|
||||||
@@ -39,19 +67,28 @@ if [[ -z "${SKIP_CHECKS:-}" ]]; then
|
|||||||
npm test
|
npm test
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "==> building and pushing registry.tanshu.com/menu-admin:$TAG"
|
TAG=$(git rev-parse --short HEAD)
|
||||||
make build-production TAG="$TAG"
|
echo "==> deploying commit $TAG"
|
||||||
|
|
||||||
if [[ -n "${SKIP_DEPLOY:-}" ]]; then
|
echo "========================================================"
|
||||||
echo "SKIP_DEPLOY is set, so the playbook was not run."
|
echo " Building & Uploading Container Image (Podman)"
|
||||||
exit 0
|
echo "========================================================"
|
||||||
fi
|
make build-production COMMIT="$TAG"
|
||||||
|
|
||||||
cd ansible
|
echo ""
|
||||||
if [[ -n "${SITE:-}" ]]; then
|
echo "========================================================"
|
||||||
echo "==> deploying $SITE"
|
echo " Executing Ansible Deployment"
|
||||||
ansible-playbook --inventory=hosts playbook.yml --limit "$SITE" -e "tag=$TAG"
|
echo "========================================================"
|
||||||
|
# The play configures rootless Podman Quadlet units that belong to the
|
||||||
|
# connecting user, so it must connect as that user and must NOT become root.
|
||||||
|
# `-u` is the account that owns ~/.config/containers/systemd on the target.
|
||||||
|
deploy_user="${ANSIBLE_USER:-tanshu}"
|
||||||
|
cd ansible || exit
|
||||||
|
# image_tag pins the exact commit; rollback is -e image_tag=<older-commit>.
|
||||||
|
# The inventory is the one difference from the sibling apps, which deploy a
|
||||||
|
# single app: this play carries three Sites and a var_file per Site.
|
||||||
|
if [ -n "${SITE:-}" ]; then
|
||||||
|
ansible-playbook -u "$deploy_user" --inventory=hosts playbook.yml --limit "$SITE" -e "image_tag=$TAG"
|
||||||
else
|
else
|
||||||
echo "==> deploying every Site"
|
ansible-playbook -u "$deploy_user" --inventory=hosts playbook.yml -e "image_tag=$TAG"
|
||||||
ansible-playbook --inventory=hosts playbook.yml -e "tag=$TAG"
|
|
||||||
fi
|
fi
|
||||||
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Each Site is deployed as a **rootless Podman** container managed by a systemd
|
Each Site is deployed as a **rootless Podman** container managed by a systemd
|
||||||
user unit (Quadlet) on `emily`, reached through the one shared Caddy unit rather
|
user unit (Quadlet) on `emily`, reached through the one shared Caddy unit rather
|
||||||
than a long-lived rootful Docker container. The image is built by `podman` too,
|
than a long-lived rootful Docker container. The image is built by **Podman** too,
|
||||||
from a `Containerfile`. This matches how every other service on that host is
|
from the committed tree, the same way the sibling `hops` and `greatbear` apps are
|
||||||
|
built and deployed. This matches how every other service on that host is
|
||||||
deployed, and it takes the deployment off Docker entirely.
|
deployed, and it takes the deployment off Docker entirely.
|
||||||
|
|
||||||
The app owns its whole serving path, so it needed the smallest deployment of the
|
The app owns its whole serving path, so it needed the smallest deployment of the
|
||||||
@@ -19,13 +20,15 @@ and never has to be edited when a Site is added or removed.
|
|||||||
**Considered Options**: staying on rootful Docker on `monoco`, rejected because
|
**Considered Options**: staying on rootful Docker on `monoco`, rejected because
|
||||||
the fleet is moving and leaving one app behind means two runtimes and two Caddy
|
the fleet is moving and leaving one app behind means two runtimes and two Caddy
|
||||||
configs to reason about; running rootful Podman, rejected because it keeps the
|
configs to reason about; running rootful Podman, rejected because it keeps the
|
||||||
same root-owned data and loses the plain-backup property; and naming the Quadlet
|
same root-owned data and loses the plain-backup property; and keeping the
|
||||||
unit after the container (`menu-app.<domain>`), rejected because the dots make
|
Docker-era container name `menu-app.<domain>`, rejected because a name with dots
|
||||||
the unit name awkward and the name fragile in DNS — the unit is `menu-<site>`
|
in it is a liability in DNS and it would have meant a second name (a network
|
||||||
while the container keeps its old name for the scripts that look it up.
|
alias) for Caddy to proxy to. The inventory name is now the single slug for
|
||||||
|
everything — container, network and data directory are all `menu-<site>`, so the
|
||||||
|
two commands an operator needs are the same across all three menu apps.
|
||||||
|
|
||||||
**Consequences**: the app is not published on a host port at all — only Caddy,
|
**Consequences**: the app is not published on a host port at all — only Caddy, on
|
||||||
on the shared Podman network, can reach it — and deployment, logs and restarts
|
the shared Podman network, can reach it — and deployment, logs and restarts move
|
||||||
move from `docker` to `systemctl --user` / `podman`. The data root moves to
|
from `docker` to `systemctl --user` / `podman`. The data root moves to
|
||||||
`~/podman-data/menu-<site>`, so each Site's content has to be copied across from
|
`~/podman-data/menu-<site>`, so each Site's content has to be copied across from
|
||||||
`/var/lib/menu.<domain>` once, before DNS points at it.
|
`/var/lib/menu.<domain>` once, before DNS points at it.
|
||||||
+8
-8
@@ -12,7 +12,7 @@ this path.
|
|||||||
|
|
||||||
| Thing | Where |
|
| Thing | Where |
|
||||||
| --------------- | ----------------------------------------------------------------------------------------- |
|
| --------------- | ----------------------------------------------------------------------------------------- |
|
||||||
| The app | user unit `menu-<site>.service` on `emily`, container `menu-app.<site>`, image `registry.tanshu.com/menu-admin:<tag>` |
|
| The app | user unit `menu-<site>.service` on `emily`, container `menu-<site>`, image `registry.tanshu.com/menu-admin:<commit>` |
|
||||||
| Menu content | `~/podman-data/menu-<site>/data` — `originals/`, `drafts/`, `published/` |
|
| Menu content | `~/podman-data/menu-<site>/data` — `originals/`, `drafts/`, `published/` |
|
||||||
| Admin | `https://<vhost>/admin` |
|
| Admin | `https://<vhost>/admin` |
|
||||||
| The password | `~/podman-data/menu-<site>/site.env` (mode 600, never in git) |
|
| The password | `~/podman-data/menu-<site>/site.env` (mode 600, never in git) |
|
||||||
@@ -52,7 +52,7 @@ HASH=$(printf '%s' "$PW" | node -e "let s='';process.stdin.setEncoding('utf8');p
|
|||||||
SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")
|
SECRET=$(node -e "process.stdout.write(require('node:crypto').randomBytes(32).toString('base64url'))")
|
||||||
printf 'ADMIN_PASSWORD_HASH=%s
|
printf 'ADMIN_PASSWORD_HASH=%s
|
||||||
SESSION_SECRET=%s
|
SESSION_SECRET=%s
|
||||||
' "$HASH" "$SECRET" | ssh emily "podman run --rm -i --userns=keep-id:uid=1000,gid=1000 -v ~/podman-data/menu-hopsngrains:/data --entrypoint sh registry.tanshu.com/menu-admin:2026-09-22 -c 'umask 077 && cat > /data/site.env'"
|
' "$HASH" "$SECRET" | ssh emily "podman run --rm -i --userns=keep-id:uid=1000,gid=1000 -v ~/podman-data/menu-hopsngrains:/data --entrypoint sh registry.tanshu.com/menu-admin:<commit> -c 'umask 077 && cat > /data/site.env'"
|
||||||
ssh emily systemctl --user restart menu-hopsngrains
|
ssh emily systemctl --user restart menu-hopsngrains
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ the icon (under 256 KB; the upload replaces the bundled mark and "Remove the
|
|||||||
uploaded icon" brings it back). None of this changes your web addresses.
|
uploaded icon" brings it back). None of this changes your web addresses.
|
||||||
|
|
||||||
**Change the colours.** Those are still `config/<site>.site.json`, so they need a
|
**Change the colours.** Those are still `config/<site>.site.json`, so they need a
|
||||||
rebuild and push (`./deploy.sh <tag>`) and the playbook with the new tag.
|
new build and push (`./deploy.sh`).
|
||||||
|
|
||||||
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
**Fix a missing image.** If a Page's thumbnail or viewing image is lost while its
|
||||||
Original survives, the app can rebuild them:
|
Original survives, the app can rebuild them:
|
||||||
@@ -79,7 +79,7 @@ Original survives, the app can rebuild them:
|
|||||||
podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \
|
podman run --rm --userns=keep-id:uid=1000,gid=1000 -e MENU_ROOT=/data \
|
||||||
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
|
-e MENU_SITE_CONFIG=/app/config/hopsngrains.site.json \
|
||||||
-v ~/podman-data/menu-hopsngrains:/data \
|
-v ~/podman-data/menu-hopsngrains:/data \
|
||||||
--entrypoint node registry.tanshu.com/menu-admin:<tag> src/cli/repair.ts
|
--entrypoint node registry.tanshu.com/menu-admin:<commit> src/cli/repair.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
`src/cli/verify.ts` does the same inventory without changing anything, and is
|
`src/cli/verify.ts` does the same inventory without changing anything, and is
|
||||||
@@ -92,15 +92,15 @@ they would see the files as a different user and fail to write.
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Landing page says the menus are being updated | no Section has a Current Menu Set — publish one in the admin |
|
| Landing page says the menus are being updated | no Section has a Current Menu Set — publish one in the admin |
|
||||||
| A Section 404s | that Section has no Current set, or the URL is misspelled |
|
| A Section 404s | that Section has no Current set, or the URL is misspelled |
|
||||||
| Sign-in rejects the right password | `podman logs menu-app.<site>`, then check `site.env` exists and is owned by `tanshu` |
|
| Sign-in rejects the right password | `podman logs menu-<site>`, then check `site.env` exists and is owned by `tanshu` |
|
||||||
| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `systemctl --user restart menu-<site>` |
|
| Admin is missing entirely | `site.env` was unreadable at boot, so the admin is disabled: run the provision script and `systemctl --user restart menu-<site>` |
|
||||||
| A photo uploads but looks wrong | the browser shrinks to 3000px before upload; that is expected, and the Original is kept |
|
| A photo uploads but looks wrong | the browser shrinks to 3000px before upload; that is expected, and the Original is kept |
|
||||||
| The container will not start | `systemctl --user status menu-<site>` and `podman logs menu-app.<site>` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
|
| The container will not start | `systemctl --user status menu-<site>` and `podman logs menu-<site>` — a fatal integrity problem makes it refuse to serve a data directory it cannot trust |
|
||||||
| The unit is "not found" | its `.network` file is missing, so Quadlet refused to generate it: check `~/.config/containers/systemd/` |
|
| The unit is "not found" | its `.network` file is missing, so Quadlet refused to generate it: check `~/.config/containers/systemd/` |
|
||||||
| Caddy reload failed during a deploy | nothing is down: Caddy validates before applying. Read the error in the playbook output |
|
| Caddy reload failed during a deploy | nothing is down: Caddy validates before applying. Read the error in the playbook output |
|
||||||
| Everything is slow | check the tag is current; a Viewing image is ~1600px, so a Section page should not be megabytes |
|
| Everything is slow | check the tag is current; a Viewing image is ~1600px, so a Section page should not be megabytes |
|
||||||
|
|
||||||
**Logs.** `podman logs -f menu-app.<site>`, or `journalctl --user -u menu-<site>
|
**Logs.** `podman logs -f menu-<site>`, or `journalctl --user -u menu-<site>
|
||||||
-f` for the unit. The app logs JSON lines, one per event, including every upload,
|
-f` for the unit. The app logs JSON lines, one per event, including every upload,
|
||||||
publish, restore and failed sign-in.
|
publish, restore and failed sign-in.
|
||||||
|
|
||||||
@@ -129,7 +129,7 @@ to trip over and no `podman unshare` needed.
|
|||||||
## Rolling back
|
## Rolling back
|
||||||
|
|
||||||
Deploy the previous tag — `./deploy.sh <previous-tag>` — which rebuilds the Site
|
Deploy the previous tag — `./deploy.sh <previous-tag>` — which rebuilds the Site
|
||||||
at that version. `podman logs menu-app.<site>` is where a container that will not
|
at that version. `podman logs menu-<site>` is where a container that will not
|
||||||
start says why.
|
start says why.
|
||||||
|
|
||||||
The Docker-era container was left on `monoco`, stopped and not removed, so the
|
The Docker-era container was left on `monoco`, stopped and not removed, so the
|
||||||
|
|||||||
@@ -136,5 +136,6 @@ echo
|
|||||||
echo "ok ${dst_sum:0:12} $(ssh -n "$EMILY" "du -sb '$DEST'" | awk '{print $1}') bytes, all owned by $user"
|
echo "ok ${dst_sum:0:12} $(ssh -n "$EMILY" "du -sb '$DEST'" | awk '{print $1}') bytes, all owned by $user"
|
||||||
echo
|
echo
|
||||||
echo "next:"
|
echo "next:"
|
||||||
echo " cd ansible && ansible-playbook --inventory=hosts playbook.yml --limit $SITE -e tag=<tag>"
|
echo " ./deploy.sh # builds HEAD and deploys every Site"
|
||||||
|
echo " SITE=$SITE ./deploy.sh # or just this one"
|
||||||
echo " then check the Site on a Host: override before moving DNS to $EMILY"
|
echo " then check the Site on a Host: override before moving DNS to $EMILY"
|
||||||
@@ -50,16 +50,16 @@ if ! command -v podman >/dev/null 2>&1; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# The image is only needed to borrow node for hashing, so take the one an app
|
# The image is only needed to borrow node for hashing, so take the one an app
|
||||||
# container is already running rather than guessing a tag. The playbook names
|
# container is already running rather than guessing a commit tag. The playbook
|
||||||
# every Site's container menu-app.<domain>. All Sites share the image, so which
|
# names every Site's container and unit menu-<site>. All Sites share the image,
|
||||||
# one answers does not matter.
|
# so which one answers does not matter.
|
||||||
if [[ -z "$IMAGE" ]]; then
|
if [[ -z "$IMAGE" ]]; then
|
||||||
IMAGE="$(podman ps --filter 'name=^menu-app\.' --format '{{.Image}}' | head -n 1 || true)"
|
IMAGE="$(podman ps --filter 'name=^menu-' --format '{{.Image}}' | head -n 1 || true)"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ -z "$IMAGE" ]]; then
|
if [[ -z "$IMAGE" ]]; then
|
||||||
echo "could not tell which image to use: no running app container to borrow one from." >&2
|
echo "could not tell which image to use: no running app container to borrow one from." >&2
|
||||||
echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<tag>" >&2
|
echo "Pass it explicitly: $0 $DATA_ROOT registry.tanshu.com/menu-admin:<commit>" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe('every Site in the inventory is complete', () => {
|
|||||||
// quoted, which is what a first deploy got wrong.
|
// quoted, which is what a first deploy got wrong.
|
||||||
assert.match(varsRaw, /^title: /m, `${name}: ${varFile} has no title`);
|
assert.match(varsRaw, /^title: /m, `${name}: ${varFile} has no title`);
|
||||||
assert.match(varsRaw, /^host: 'menu\.\{\{ title \}\}'/m, `${name}: host is not quoted`);
|
assert.match(varsRaw, /^host: 'menu\.\{\{ title \}\}'/m, `${name}: host is not quoted`);
|
||||||
assert.match(varsRaw, /^app_root: /m, `${name}: no app_root`);
|
assert.match(varsRaw, /^host_directory: /m, `${name}: no host_directory`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user