diff --git a/Makefile b/Makefile index 6c06ba3..5c79324 100644 --- a/Makefile +++ b/Makefile @@ -1,7 +1,14 @@ COMMIT ?= $(shell git rev-parse --short HEAD) +# Both deployment hosts are aarch64, so that is the default target. A foreign +# platform needs the *host's* binfmt handlers — podman, unlike docker buildx, +# does not carry its own QEMU — hence the pointer in the failure path. +# Override with e.g. `make build-production PLATFORMS=linux/amd64`. +PLATFORMS ?= linux/arm64 +IMAGE ?= registry.tanshu.com/mozimo + # Analytics ids baked into the client bundle — read from the local .env -# (gitignored) and passed to docker build. Empty values disable the tool. +# (gitignored) and passed to podman build. Empty values disable the tool. VITE_ENV_KEYS := VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN BUILD_ARGS := $(shell for k in VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VITE_SENTRY_DSN; do \ v=$$(grep -E "^$$k=" .env 2>/dev/null | head -1 | cut -d= -f2-); \ @@ -10,21 +17,27 @@ BUILD_ARGS := $(shell for k in VITE_GA4_MEASUREMENT_ID VITE_PLAUSIBLE_DOMAIN VIT BUILD_ARGS += --build-arg VITE_SENTRY_RELEASE=$(COMMIT) --build-arg SENTRY_RELEASE=$(COMMIT) .PHONY: build-production -build-production: ## Multi-arch build from HEAD; tags registry.tanshu.com/mozimo: + :main - @git archive --format=tar HEAD | docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --tag registry.tanshu.com/mozimo:$(COMMIT) \ - --tag registry.tanshu.com/mozimo:main \ +build-production: ## Build HEAD with podman for $(PLATFORMS); tag + push :$(COMMIT) and :main + @echo "▶ podman build --platform $(PLATFORMS) → $(IMAGE):$(COMMIT) + :main" + @git archive --format=tar HEAD | podman build \ + --platform $(PLATFORMS) \ + --tag $(IMAGE):$(COMMIT) \ + --tag $(IMAGE):main \ $(BUILD_ARGS) \ - --push \ - - + - || { \ + 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-static # on an x86_64 host, for $(PLATFORMS)"; \ + exit 1; \ + } + podman push $(IMAGE):$(COMMIT) + podman push $(IMAGE):main .PHONY: build-check -build-check: ## Multi-arch compile check from HEAD, nothing pushed - @git archive --format=tar HEAD | docker buildx build \ - --platform linux/amd64,linux/arm64 \ +build-check: ## Compile check from HEAD with podman, nothing pushed + @git archive --format=tar HEAD | podman build \ + --platform $(PLATFORMS) \ --tag mozimo:check \ --pull \ $(BUILD_ARGS) \ - --progress=plain \ - diff --git a/README.md b/README.md index 14769ac..63ef87c 100644 --- a/README.md +++ b/README.md @@ -69,13 +69,21 @@ npm run lint # eslint (currently broken: leftover Next.js config) Copy `.env.example` to `.env` and fill in the Shopify Storefront token and PocketBase superuser credentials (`.env*` is gitignored). -## Docker +## Containers + +The image is built with **podman** (`podman build` reads the same `Dockerfile`): ```bash -docker build -t mozimo-web . -docker run -p 3000:3000 -p 8090:8090 -v mozimo_pb_data:/app/pb_data mozimo-web +podman build -t mozimo-web . +podman run -p 3000:3000 -p 8090:8090 -v mozimo_pb_data:/app/pb_data mozimo-web ``` +Production is **rootless podman + Quadlet** on the host `emily`: systemd user +services generated from `.container` units, fronted by that host's shared +Caddy. `./deploy.sh` builds the pushed commit with `make build-production` and +runs `ansible/playbook.yml`. See [`ansible/README.md`](ansible/README.md) for +the unit layout and the monoco → emily migration. + Three-stage build (`deps → builder → runner`) on pinned `node:26-trixie-slim`: dependencies install with `npm ci` and BuildKit cache mounts, the final image runs as the unprivileged `node` user, ships production dependencies, the build @@ -83,4 +91,5 @@ output, PocketBase v0.40.2 (multi-arch) and `entrypoint.sh` which starts both processes, ensures the PocketBase superuser matches `PB_SUPERUSER_EMAIL` / `PB_SUPERUSER_PASSWORD` on every boot, and seeds the CMS schema + defaults. Volumes: `/app/pb_data` holds the CMS database — back it up. Ports: `3000` -web, `8090` PocketBase (`cms.mozimo.in` → 8090 via Caddy, see `ansible/`). +web, `8090` PocketBase (both reached by Caddy over the podman network, not +published on the host; see [`ansible/`](ansible/)). diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..c90c926 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,157 @@ +# Deploying mozimo + +Rootless **podman + Quadlet** on the host `emily` (aarch64, Ubuntu 26.04), +fronted by that host's **shared Caddy**. Nothing here runs as root. + +```bash +./deploy.sh # build the pushed commit + deploy +ansible-playbook playbook.yml -e image_tag= [--check --diff] +``` + +## How it fits together + +| Piece | Where | +| --- | --- | +| Quadlet units | `~/.config/containers/systemd/{mozimo,vince}.container`, `mozimo.network` | +| Service data | `~/podman-data/mozimo/` (PocketBase db + files, `.env`), `~/podman-data/vince/` | +| Shared Caddy config | `~/podman-data/caddy/conf/Caddyfile` — one marked block per service | +| Caddy's network membership | `~/.config/containers/systemd/caddy.container.d/50-mozimo.conf` | + +Quadlet units are not read by systemd directly: the podman user generator turns +them into *user services* on `daemon-reload`, and `[Install] WantedBy=default.target` +brings them up at boot. `systemctl --user is-enabled mozimo` reports `generated` +— that is normal, there is no unit to enable. + +**Nothing is published on the host.** Caddy joins `mozimo_net` (through the +drop-in) and proxies to the containers by name: + +``` +mozimo.in → mozimo:3000 (plus /_/* and /api/* → mozimo:8090) +www.mozimo.in → 301 mozimo.in +cms.mozimo.in → mozimo:8090 +stats.mozimo.in → vince:8000 +mozimo.in/stats/* → vince:8000 (prefix stripped) +``` + +The user id mapping matters: the app image runs as uid 1000 (`node`) and Vince +as root, so both units use `UserNS=keep-id:` to map that uid onto `tanshu`. +Without it, PocketBase and Vince would write files owned by a subuid, and +backups, rsync and tar would all trip over them. + +## Services register themselves + +This host's convention (also used by its `tandoor`, `postgres` and `caddy` +playbooks) is that a service owns its own registration rather than editing a +central list: + +1. it ships a `.network` Quadlet with `NetworkName=_net`; +2. it drops `caddy.container.d/50-.conf` containing + `[Container] Network=.network`, which Quadlet merges into Caddy; +3. it appends a marked block to Caddy's Caddyfile and reloads it. + +Removing a service is the reverse: delete the drop-in, delete the Caddyfile +block, `systemctl --user daemon-reload`, and the network is no longer Caddy's. + +## Migrating monoco → emily + +monoco is the current host: rootful Docker, data in `/var/lib/mozimo`, its own +Caddy, DNS for `mozimo.in` / `www` / `cms` / `stats` all pointing at +`13.202.6.2`. emily is `35.154.14.59`. + +**Certificates are not copied.** emily's Caddy is shared with other sites, and +its ACME storage already holds their certificates — overwriting it would break +them. After the DNS switch emily issues fresh certificates for `mozimo.in`; +until the DNS switch it retries, which is why the Caddy role can be skipped +first (`--skip-tags caddy`). + +### 0. Compare the deployed environment with the repo's copy + +The playbook uploads `ansible/files/.env` (gitignored) over whatever is on the +host, so anything that has drifted on monoco would be silently dropped: + +```bash +ssh monoco 'sudo -n cat /var/lib/mozimo/.env' > /tmp/monoco.env +diff /tmp/monoco.env ansible/files/.env +``` + +### 1. Rehearse against emily without touching DNS + +```bash +cd ansible +ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD) --check --diff # what would change +ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD) --skip-tags caddy +``` + +This creates the network, the units and a **fresh, empty** PocketBase database +on emily. Nothing public changes yet. + +### 2. Copy the data (the cutover window) + +Stop both sides first. PocketBase keeps its database in SQLite, so a copy taken +underneath a running writer can be inconsistent — and on emily the rehearsal +left an empty database that this snapshot replaces: + +```bash +# emily: stop its copy and drop the empty database the rehearsal created +ssh emily 'systemctl --user stop mozimo vince; rm -rf ~/podman-data/mozimo/pb_data ~/podman-data/vince' + +# monoco: the site is down from here until DNS + emily are live +ssh monoco 'docker stop mozimo vince' + +# PocketBase database + files (23 MB) +ssh monoco 'sudo -n tar -C /var/lib/mozimo -cf - pb_data' \ + | ssh emily 'mkdir -p ~/podman-data/mozimo && tar -C ~/podman-data/mozimo -xf -' + +# Vince analytics history (119 MB), contents dropped directly into its data dir +ssh monoco 'sudo -n tar -C /var/lib/mozimo -cf - vince_data' \ + | ssh emily 'mkdir -p ~/podman-data/vince && tar -C ~/podman-data/vince -xf - --strip-components=1' +``` + +The extraction runs as `tanshu`, so everything lands owned by `tanshu` — which +is exactly what the `keep-id` mapping expects, whatever it was owned by on +monoco. Resumable equivalents, if the link is unreliable: + +```bash +rsync -a --info=progress2 --rsync-path="sudo -n rsync" \ + monoco:/var/lib/mozimo/pb_data/ emily:podman-data/mozimo/pb_data/ +rsync -a --info=progress2 --rsync-path="sudo -n rsync" \ + monoco:/var/lib/mozimo/vince_data/ emily:podman-data/vince/ +``` + +### 3. Point DNS at emily and deploy the Caddy block + +`mozimo.in`, `www.mozimo.in`, `cms.mozimo.in`, `stats.mozimo.in` → +`35.154.14.59`. Then: + +```bash +cd ansible +ansible-playbook playbook.yml -e image_tag=$(git rev-parse --short HEAD) + +# the play restarts a service whose unit or .env changed; starting is explicit +# here because step 2 stopped both, and `start` is a no-op if already running +ssh emily 'systemctl --user start mozimo vince' +``` + +The first request after the reload makes Caddy issue certificates for the four +names. + +### 4. Verify, then retire monoco + +```bash +ssh emily 'systemctl --user status mozimo vince caddy --no-pager | head -30' +ssh emily 'podman ps --format "{{.Names}} {{.Status}}"' +curl -sI https://mozimo.in | head -3 +curl -s https://cms.mozimo.in/api/health | head -3 # PocketBase +curl -sI https://stats.mozimo.in | head -3 +``` + +Check a product page renders live prices and that the CMS admin login works +with the same superuser credentials. Only then: + +```bash +ssh monoco 'docker rm -f mozimo vince' # or leave stopped for a few days +``` + +**Rollback** at any point before that: point DNS back at `13.202.6.2` and +`ssh monoco 'docker start mozimo vince'`. On emily, `systemctl --user stop +mozimo vince` and delete the `mozimo.in` block from its Caddyfile. diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 index 062e475..45bc124 100644 --- a/ansible/files/Caddyfile.j2 +++ b/ansible/files/Caddyfile.j2 @@ -1,14 +1,17 @@ # Canonical origin. Everything the site emits — og:url, robots.txt, the # sitemap, the GA4 property, vince_site_domain — already says mozimo.in, so # the apex serves and www is permanently redirected here (below). +# +# Upstreams are container names on {{ network_quadlet }}.network: Caddy +# resolves them through podman's DNS because it has joined that network. {{ apex_host }} { # PocketBase Admin UI and REST API (files included) redir /_ /_/ handle /_/* { - reverse_proxy {{ docker_container }}:{{ cms_port }} + reverse_proxy {{ app_container }}:{{ cms_port }} } handle /api/* { - reverse_proxy {{ docker_container }}:{{ cms_port }} + reverse_proxy {{ app_container }}:{{ cms_port }} } # First-party analytics (Vince) — the tracker script and event endpoint @@ -21,7 +24,7 @@ # TanStack Start frontend handle { - reverse_proxy {{ docker_container }}:{{ www_port }} + reverse_proxy {{ app_container }}:{{ www_port }} } } @@ -33,7 +36,7 @@ {{ cms_host }} { redir / /_/ - reverse_proxy {{ docker_container }}:{{ cms_port }} + reverse_proxy {{ app_container }}:{{ cms_port }} } # Vince analytics dashboard (login-protected). DNS for this host must point diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 99b21e7..a8e981b 100755 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -1,15 +1,34 @@ ################################################# -# DO Community Playbooks: Docker +# mozimo — rootless Podman + Quadlet deployment ################################################# --- -- hosts: monoco - become: true +# Everything runs unprivileged as the ssh user: the containers are systemd +# *user* services generated from Quadlet units, and Caddy is the host's shared +# reverse proxy, which each service registers itself with (a network drop-in +# plus a block in its Caddyfile). +# +# ansible-playbook playbook.yml -e image_tag= +# +# Before DNS points at this host, skip the Caddy role — appending the site +# block makes Caddy try to issue certificates for a name that still resolves +# elsewhere (it retries, but the log is noisy): +# +# ansible-playbook playbook.yml -e image_tag= --skip-tags caddy +- hosts: emily + become: false + gather_facts: true vars_files: - vars/default.yml + environment: + # rootless podman and `systemctl --user` both need the user's runtime + # directory, and an ansible ssh session has no login session to set it. + XDG_RUNTIME_DIR: "{{ xdg_runtime_dir }}" + roles: - upload - network - - analytics - mozimo - - caddy + - analytics + - role: caddy + tags: [caddy] diff --git a/ansible/roles/analytics/handlers/main.yaml b/ansible/roles/analytics/handlers/main.yaml new file mode 100644 index 0000000..8dd7f56 --- /dev/null +++ b/ansible/roles/analytics/handlers/main.yaml @@ -0,0 +1,9 @@ +--- +# Handlers for the analytics role. +- name: Restart Vince + ansible.builtin.systemd_service: + name: "{{ vince_container }}.service" + state: restarted + scope: user + when: not ansible_check_mode + listen: "Restart Vince" diff --git a/ansible/roles/analytics/tasks/main.yaml b/ansible/roles/analytics/tasks/main.yaml index 24431ba..610a387 100644 --- a/ansible/roles/analytics/tasks/main.yaml +++ b/ansible/roles/analytics/tasks/main.yaml @@ -1,44 +1,24 @@ --- # Self-hosted first-party analytics (Vince — Plausible-protocol, single -# binary). Only reachable on the docker network: -# - Caddy proxies /stats/* on the www host here (prefix stripped), serving +# binary). Reachable only on mozimo's network: +# - Caddy proxies /stats/* on the apex host here (prefix stripped), serving # the tracker script and event API first-party; # - Caddy publishes the dashboard on {{ stats_host }}. # -# The image's ENTRYPOINT is the bare /vince binary (prints help and exits -# with no args), so the container MUST be given the `serve` command. -- name: Pull Vince image - docker_image: - name: "{{ vince_image }}" - source: pull - state: present - force_source: "{{ vince_image_force_update | default(false) }}" +# Analytics history lives in {{ vince_directory }} and is migrated separately +# from monoco — see ansible/README.md. +- name: Pull the Vince image + ansible.builtin.command: "podman pull {{ vince_image }}" + changed_when: false -- name: Ensure Vince data directory exists - file: - path: "{{ host_directory }}/vince_data" - state: directory - owner: "1000" - group: "1000" +- name: Install the Vince Quadlet unit + ansible.builtin.template: + src: vince.container.j2 + dest: "{{ quadlet_dir }}/{{ vince_container }}.container" + mode: "0644" + notify: Restart Vince -- name: Create Vince container - docker_container: - name: "{{ vince_container }}" - image: "{{ vince_image }}" - command: serve - state: started - restart_policy: "unless-stopped" - env: - VINCE_DATA: /data - VINCE_LISTEN: "0.0.0.0:{{ vince_port }}" - # serve (re)creates the admin account from these on every boot — - # the env file is the source of truth, like the PocketBase superuser. - VINCE_ADMIN_NAME: "{{ vince_admin_name }}" - VINCE_ADMIN_PASSWORD: "{{ vince_admin_password }}" - # create the tracked site on startup (CSV list) - VINCE_DOMAINS: "{{ vince_site_domain }}" - VINCE_URL: "https://{{ stats_host }}" - volumes: - - "{{ host_directory }}/vince_data:/data" - networks: - - name: "{{ docker_network }}" +- name: Generate the user service from the Quadlet unit + ansible.builtin.systemd_service: + daemon_reload: true + scope: user diff --git a/ansible/roles/analytics/templates/vince.container.j2 b/ansible/roles/analytics/templates/vince.container.j2 new file mode 100644 index 0000000..2d52855 --- /dev/null +++ b/ansible/roles/analytics/templates/vince.container.j2 @@ -0,0 +1,42 @@ +# Vince (first-party analytics, Plausible protocol) — rootless Podman Quadlet. +# Managed by Ansible. Do not edit by hand. +# +# The image's ENTRYPOINT is the bare /vince binary (prints help and exits with +# no args), so the `serve` subcommand is required. +# +# Reachable only on {{ network_quadlet }}.network: Caddy proxies /stats/* on +# the apex host here (prefix stripped) and publishes the dashboard on +# {{ stats_host }}. There is no published port. + +[Unit] +Description=Vince analytics +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ vince_container }} +Image={{ vince_image }} + +# Vince runs as root in the image and writes /data; mapping container root +# onto {{ podman_user }} keeps {{ vince_directory }} host-owned. +UserNS=keep-id:uid=0,gid=0 + +Exec=serve +Environment=VINCE_DATA=/data +Environment=VINCE_LISTEN=0.0.0.0:{{ vince_port }} +# serve (re)creates the admin account from these on every boot — the playbook +# vars are the source of truth, like the PocketBase superuser. +Environment=VINCE_ADMIN_NAME={{ vince_admin_name }} +Environment=VINCE_ADMIN_PASSWORD={{ vince_admin_password }} +# create the tracked site on startup (CSV list) +Environment=VINCE_DOMAINS={{ vince_site_domain }} +Environment=VINCE_URL=https://{{ stats_host }} + +Volume={{ vince_directory }}:/data +Network={{ network_quadlet }}.network + +[Service] +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/ansible/roles/caddy/handlers/main.yaml b/ansible/roles/caddy/handlers/main.yaml index 294bf14..e7f1924 100644 --- a/ansible/roles/caddy/handlers/main.yaml +++ b/ansible/roles/caddy/handlers/main.yaml @@ -1,5 +1,10 @@ --- -# Handlers for Caddy role +# Handlers for the Caddy role. +# +# A reload, not a restart: only the site blocks changed, and Caddy reloads its +# config in place without dropping the other services' connections. Network +# membership changes are a restart and are handled by the network role. - name: Reload Caddy configuration - ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" - listen: "Reload Caddy" \ No newline at end of file + ansible.builtin.command: "podman exec -w /etc/caddy {{ caddy_container }} caddy reload" + changed_when: true + listen: "Reload Caddy" diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml index 93debed..0e94675 100644 --- a/ansible/roles/caddy/tasks/main.yaml +++ b/ansible/roles/caddy/tasks/main.yaml @@ -1,12 +1,17 @@ --- +# mozimo's block in the host's shared Caddyfile. +# +# This host's Caddy is shared and its Caddyfile is not templated: every service +# owns a marked block in it, appended here, so a service can be removed without +# touching anyone else's routing. - name: Read snippet from template ansible.builtin.set_fact: caddy_snippet_block: "{{ lookup('ansible.builtin.template', 'files/Caddyfile.j2') }}" -- name: Install mozimo Caddy snippet (web + cms) +- name: Install the mozimo Caddy snippet (web + cms + stats) ansible.builtin.blockinfile: path: "{{ caddyfile_path }}" - marker: "# {mark} Ansible managed Caddy snippet (mozimo web + cms)" + marker: "# {mark} Ansible managed Caddy snippet for {{ apex_host }}" block: "{{ caddy_snippet_block }}" insertafter: EOF notify: "Reload Caddy" diff --git a/ansible/roles/mozimo/handlers/main.yaml b/ansible/roles/mozimo/handlers/main.yaml new file mode 100644 index 0000000..e5e1846 --- /dev/null +++ b/ansible/roles/mozimo/handlers/main.yaml @@ -0,0 +1,17 @@ +--- +# Handlers for the mozimo role. +# +# Quadlet units are not read by systemd directly: the podman user generator +# turns them into services on daemon-reload, which the role does explicitly +# after installing the unit. The service is "generated" (is-enabled reports +# generated, not enabled) and is pulled in by default.target through the unit's +# [Install] section, so there is nothing to enable. +- name: Restart Mozimo + ansible.builtin.systemd_service: + name: "{{ title }}.service" + state: restarted + scope: user + # check mode cannot generate the unit from a file it did not write, so a + # rehearsal reports the change instead of failing on a missing service + when: not ansible_check_mode + listen: "Restart Mozimo" diff --git a/ansible/roles/mozimo/tasks/main.yaml b/ansible/roles/mozimo/tasks/main.yaml index f2d0aa9..225a75a 100644 --- a/ansible/roles/mozimo/tasks/main.yaml +++ b/ansible/roles/mozimo/tasks/main.yaml @@ -1,40 +1,43 @@ --- -# Tasks for docker role -- name: Log in to Docker registry - docker_login: - registry: "{{ registry }}" - username: "{{ username }}" - password: "{{ password }}" +# The mozimo service itself: registry credentials, the pinned image, and the +# Quadlet unit that systemd turns into a user service. +# +# There is one unit, not two: the image's entrypoint runs the SSR server and +# PocketBase side by side, so the pair restart together. +- name: Check the rootless podman installation + ansible.builtin.command: podman --version + changed_when: false -- name: Pull Mozimo image - docker_image: - name: "{{ docker_image }}" - source: pull - state: present - force_source: yes +- name: Log in to the registry as the podman user + ansible.builtin.command: + cmd: "podman login --username {{ username }} --password-stdin {{ registry }}" + stdin: "{{ password }}" + no_log: true + changed_when: false -- name: Ensure PocketBase data directory exists - file: - path: "{{ host_directory }}/pb_data" - state: directory - owner: "1000" - group: "1000" +- name: Pull the image for this revision + ansible.builtin.command: "podman pull {{ image }}" + # the pull is a means to an end; the restart handler reports the real change + changed_when: false -- name: Create Mozimo container - docker_container: - name: "{{ docker_container }}" - image: "{{ docker_image }}" - state: started - restart_policy: "unless-stopped" - env_file: "{{ host_directory }}/.env" - volumes: - - "{{ host_directory }}/pb_data:/app/pb_data" - networks: - - name: "{{ docker_network }}" +- name: Install the mozimo Quadlet unit + ansible.builtin.template: + src: mozimo.container.j2 + dest: "{{ quadlet_dir }}/{{ title }}.container" + mode: "0644" + notify: Restart Mozimo + +# Explicit, and before the handlers run: the unit does not exist as a systemd +# service until the podman user generator has seen it, so generating it here +# means the restart handler has something to restart on a first deploy. +- name: Generate the user service from the Quadlet unit + ansible.builtin.systemd_service: + daemon_reload: true + scope: user - name: Prune old mozimo images, keep newest 3 (opt-in via ./deploy.sh --prune) ansible.builtin.shell: > - docker images "registry.tanshu.com/mozimo" --format "{{.ID}}" | tail -n +4 | xargs -r docker rmi + podman images -q {{ registry }}/{{ title }} | tail -n +4 | xargs -r podman rmi when: mozimo_image_prune | default(false) | bool failed_when: false changed_when: true diff --git a/ansible/roles/mozimo/templates/mozimo.container.j2 b/ansible/roles/mozimo/templates/mozimo.container.j2 new file mode 100644 index 0000000..41d9687 --- /dev/null +++ b/ansible/roles/mozimo/templates/mozimo.container.j2 @@ -0,0 +1,38 @@ +# Mozimo — rootless Podman Quadlet unit. +# Managed by Ansible. Do not edit by hand. +# +# One unit runs the whole service: the image's entrypoint starts the TanStack +# Start SSR server and PocketBase side by side, so they restart together. +# +# Nothing is published on the host. The shared Caddy joins +# {{ network_quadlet }}.network and proxies to {{ app_container }}:{{ www_port }} +# and :{{ cms_port }} by container name. +# +# Data lives at {{ host_directory }}/pb_data and survives redeploys: the +# PocketBase database and files, the minted Shopify token, and the last-good +# catalogue cache all sit there. + +[Unit] +Description=Mozimo commerce (TanStack Start + PocketBase) +Documentation=https://git.tanshu.com/tanshu/mozimo.in +After=network-online.target +Wants=network-online.target + +[Container] +ContainerName={{ app_container }} +Image={{ image }} + +# The image's process runs as uid 1000 (node). Mapping that uid onto the host +# user keeps pb_data owned by {{ podman_user }} rather than by a subuid, so +# rsync, tar and backups stay simple. +UserNS=keep-id:uid=1000,gid=1000 + +EnvironmentFile={{ host_directory }}/.env +Volume={{ host_directory }}/pb_data:/app/pb_data +Network={{ network_quadlet }}.network + +[Service] +Restart=on-failure + +[Install] +WantedBy=default.target diff --git a/ansible/roles/network/handlers/main.yaml b/ansible/roles/network/handlers/main.yaml new file mode 100644 index 0000000..091e3f1 --- /dev/null +++ b/ansible/roles/network/handlers/main.yaml @@ -0,0 +1,12 @@ +--- +# Handlers for the network role. +# +# A restart, not a reload: joining a network changes the container's network +# namespace, which Caddy cannot pick up from a config reload. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_service }}.service" + state: restarted + daemon_reload: true + scope: user + listen: "Restart Caddy" diff --git a/ansible/roles/network/tasks/main.yaml b/ansible/roles/network/tasks/main.yaml index 806c4c8..7d5f7c7 100644 --- a/ansible/roles/network/tasks/main.yaml +++ b/ansible/roles/network/tasks/main.yaml @@ -1,8 +1,29 @@ --- -- name: Ensure 'mozimo' Docker network exists - docker_network: - name: "{{ docker_network }}" - state: present - connected: - - name: caddy - appends: yes +# mozimo's own podman network, and its registration with the shared Caddy. +# +# This is how services register themselves on this host: the network is a +# Quadlet unit, and Caddy joins it through a drop-in file rather than through +# a global list of upstream networks. Deleting the drop-in removes Caddy from +# the network on the next daemon-reload — the reverse of deployment. +- name: Install the mozimo network Quadlet + ansible.builtin.template: + src: mozimo.network.j2 + dest: "{{ quadlet_dir }}/{{ network_quadlet }}.network" + mode: "0644" + register: mozimo_network_unit + notify: Restart Caddy + +- name: Register the network with Caddy + ansible.builtin.template: + src: caddy-network.conf.j2 + dest: "{{ caddy_dropin_dir }}/{{ caddy_dropin_file }}" + mode: "0644" + register: mozimo_caddy_dropin + notify: Restart Caddy + +# Caddy has to be on the network before the site block starts proxying to it, +# otherwise the first requests after a reload are 502s. Restarting here also +# flushes before the mozimo unit comes up. +- name: Restart Caddy now if its network membership changed + ansible.builtin.meta: flush_handlers + when: mozimo_network_unit.changed or mozimo_caddy_dropin.changed diff --git a/ansible/roles/network/templates/caddy-network.conf.j2 b/ansible/roles/network/templates/caddy-network.conf.j2 new file mode 100644 index 0000000..3374623 --- /dev/null +++ b/ansible/roles/network/templates/caddy-network.conf.j2 @@ -0,0 +1,6 @@ +# Added by the mozimo playbook. Do not edit by hand. +# +# Quadlet merges this into caddy.container. Deleting this file removes +# {{ network_name }} from Caddy on the next daemon-reload. +[Container] +Network={{ network_quadlet }}.network diff --git a/ansible/roles/network/templates/mozimo.network.j2 b/ansible/roles/network/templates/mozimo.network.j2 new file mode 100644 index 0000000..ad951f7 --- /dev/null +++ b/ansible/roles/network/templates/mozimo.network.j2 @@ -0,0 +1,13 @@ +# mozimo — logical app network. +# Managed by Ansible. Do not edit by hand. +# +# NetworkName pins the Podman network name; without it Quadlet would name it +# "systemd-mozimo". +# +# isolate blocks traffic between separate Podman networks, so a container on +# this network cannot reach one that is only on another. Caddy still reaches +# it, because Caddy is a member of it. + +[Network] +NetworkName={{ network_name }} +Options=isolate=true diff --git a/ansible/roles/upload/tasks/main.yaml b/ansible/roles/upload/tasks/main.yaml index 5ef9baf..f9fdf15 100644 --- a/ansible/roles/upload/tasks/main.yaml +++ b/ansible/roles/upload/tasks/main.yaml @@ -1,11 +1,34 @@ --- -# Tasks for upload role -- name: Ensure Host Directory exists - file: - path: "{{ host_directory }}" - state: directory +# The service's directories and its runtime environment file. +# +# The .env is the source of truth for the container's secrets (PocketBase +# superuser, Shopify credentials, Sentry) and is read by systemd through the +# unit's EnvironmentFile, not by Ansible. +- name: Confirm the podman user's uid still matches the pinned one + ansible.builtin.assert: + that: ansible_user_uid | int == podman_uid | int + fail_msg: >- + {{ podman_user }} is uid {{ ansible_user_uid }} on this host, but + vars/default.yml pins podman_uid={{ podman_uid }} (used to build the + play-level XDG_RUNTIME_DIR). Update the pin. + quiet: true -- name: Upload the .env file - template: +- name: Ensure the mozimo data, unit and analytics directories exist + ansible.builtin.file: + path: "{{ item }}" + state: directory + mode: "0755" + loop: + - "{{ podman_data }}" + - "{{ host_directory }}" + - "{{ host_directory }}/pb_data" + - "{{ vince_directory }}" + - "{{ quadlet_dir }}" + - "{{ caddy_dropin_dir }}" + +- name: Upload the runtime environment file + ansible.builtin.template: src: "files/.env" dest: "{{ host_directory }}/.env" + mode: "0600" + notify: Restart Mozimo diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index 1fb0224..99f8ee5 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -1,4 +1,17 @@ --- +# mozimo deployment — rootless Podman + Quadlet, on the host named emily. +# +# The layout follows the convention already in place on that host (see the +# caddy / tandoor / postgres playbooks that manage its other services): +# +# ~/.config/containers/systemd/ Quadlet units, generated into user services +# ~/podman-data// each service's data +# ~/podman-data/caddy/conf/Caddyfile the shared Caddy config; every service +# appends its own block to it +# +# Nothing here needs root: podman runs as the ssh user, the units are user +# services, and the shared Caddy is already a user service on that host. + registry: registry.tanshu.com username: ta-registry password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f @@ -6,7 +19,25 @@ password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f title: mozimo tag: latest -host_directory: "/var/lib/{{ title }}" +# The unprivileged user that owns the containers. `ansible_user` on emily is +# the same account, so no become is involved anywhere in this play. +podman_user: tanshu +# `id -u tanshu` on emily. Pinned rather than read from facts because the +# play-level environment (XDG_RUNTIME_DIR) is templated before facts exist; +# the upload role asserts the two still agree. +podman_uid: 1001 +xdg_runtime_dir: "/run/user/{{ podman_uid }}" +podman_data: "/home/{{ podman_user }}/podman-data" +quadlet_dir: "/home/{{ podman_user }}/.config/containers/systemd" + +# mozimo's data root: PocketBase database and files, the Shopify token cache, +# and the runtime .env that the unit reads. +host_directory: "{{ podman_data }}/{{ title }}" + +# image tag: deploy.sh passes -e image_tag=; "tag" is the fallback +# when running the playbook by hand — rollback = -e image_tag= +image: "{{ registry }}/{{ title }}:{{ image_tag | default(tag) }}" +app_container: "{{ title }}" # apex is the canonical origin and serves the app; www 301-redirects to it apex_host: "mozimo.in" @@ -15,25 +46,26 @@ www_port: 3000 cms_host: "cms.mozimo.in" cms_port: 8090 -docker_network: "{{ title }}_net" +# mozimo gets its own podman network, and registers itself with the shared +# Caddy through a drop-in rather than by editing Caddy's unit. +network_name: "{{ title }}_net" +network_quadlet: "{{ title }}" -# image tag: deploy.sh passes -e image_tag=; "tag" is the fallback -# when running the playbook by hand — rollback = -e image_tag= -docker_image: "{{ registry }}/{{ title }}:{{ image_tag | default(tag) }}" -docker_container: "{{ title }}" +# The shared Caddy on emily. Each service owns a block in its Caddyfile +# (blockinfile markers) and a drop-in that adds its network to Caddy. +caddy_container: caddy +caddy_service: caddy +caddyfile_path: "{{ podman_data }}/caddy/conf/Caddyfile" +caddy_dropin_dir: "{{ quadlet_dir }}/caddy.container.d" +caddy_dropin_file: "50-{{ title }}.conf" # self-hosted first-party analytics (Vince) — script/events proxied at -# /stats/* on the www host, dashboard published on stats_host +# /stats/* on the apex host, dashboard published on stats_host vince_container: vince vince_port: 8000 vince_admin_name: admin # change this before first deploy — it (re)sets the dashboard login vince_admin_password: "ChangeMe-Vince-2025!" vince_site_domain: mozimo.in +vince_directory: "{{ podman_data }}/vince" stats_host: stats.mozimo.in - -# caddy publishes the apex site (SSR + /api/* + /_/* -> pocketbase), redirects -# www to it, and publishes the cms admin host - -caddy_container: caddy -caddyfile_path: /var/lib/caddy/conf/Caddyfile diff --git a/deploy.sh b/deploy.sh index 51cbdbe..bd04136 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash -# Mozimo deploy — build the exact commit at origin/main, tag it with its -# commit id, and hand that tag to ansible. Guards make it impossible to +# Mozimo deploy — build the exact commit at origin/main with podman, tag it +# with its commit id, and hand that tag to ansible (which installs the Quadlet +# unit on emily and restarts the user service). Guards make it impossible to # deploy anything other than what git knows about. # # ./deploy.sh deploy HEAD (must be pushed, tree must be clean) @@ -41,7 +42,7 @@ fi git pull --ff-only TAG=$(git rev-parse --short HEAD) echo "▶ deploying commit $TAG" -make build-production TAG="$TAG" +make build-production COMMIT="$TAG" PRUNE_FLAG="" if [ "$PRUNE" = true ]; then