diff --git a/.containerignore b/.containerignore deleted file mode 100644 index ff296c3..0000000 --- a/.containerignore +++ /dev/null @@ -1,41 +0,0 @@ -# Keep the build context small and secret-free. -# Read by Podman/Buildah (`.containerignore`) and, as a fallback, Docker. - -# VCS / editor / local state -.git -.gitignore -.containerignore -.env -.env.* -.vscode -.idea -.agents -.gemini - -# Node artifacts (installed/rebuilt inside the image) -node_modules -**/node_modules -frontend/.output -frontend/.tanstack -frontend/dist -.output -dist - -# PocketBase local data & binaries -backend/pb_data -backend/pocketbase -backend/pocketbase.exe -*.db -*.db-shm -*.db-wal - -# Logs & scratch -*.log -npm-debug.log* -scratch -page.html - -# Ops assets not needed inside the image -ansible -Makefile -README.md diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fada6c2 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,39 @@ +# Dependencies. Installed inside the image. +node_modules +**/node_modules + +# Build output. Produced by the builder stage. +frontend/.output +frontend/.tanstack +.output +dist + +# Version control and editor. +.git +.gitignore +.dockerignore +.vscode +.idea +.agents +.gemini + +# Environment and local state. +.env +.env.* +backend/pb_data +backend/pocketbase +backend/pocketbase.exe +*.db +*.db-shm +*.db-wal + +# Caches and logs. +*.log +npm-debug.log* +scratch +page.html + +# Repo tooling not needed inside the image. +ansible +Makefile +README.md diff --git a/Dockerfile b/Dockerfile index b845159..a0733d5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ # syntax=docker/dockerfile:1 -# ------------------------------------------------------------------------------ -# Stage 1: Install frontend dependencies (Native execution on host architecture) -# ------------------------------------------------------------------------------ +# Frontend dependencies. Runs on the build platform. FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps WORKDIR /app/frontend @@ -10,9 +8,7 @@ COPY frontend/package.json frontend/package-lock.json* ./ RUN --mount=type=cache,target=/root/.npm \ npm ci -# ------------------------------------------------------------------------------ -# Stage 2: Build the TanStack Start frontend (Native execution on host architecture) -# ------------------------------------------------------------------------------ +# Frontend build. Runs on the build platform. FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder WORKDIR /app/frontend @@ -22,9 +18,7 @@ COPY frontend/ ./ ENV NODE_ENV=production RUN npm run build -# ------------------------------------------------------------------------------ -# Stage 3: Fetch PocketBase binary for target architecture (Native host downloader) -# ------------------------------------------------------------------------------ +# PocketBase binary for the target architecture. Runs on the build platform. FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader ARG TARGETARCH ARG PB_VERSION=0.40.2 @@ -41,9 +35,7 @@ RUN apk add --no-cache curl unzip ca-certificates \ && chmod +x /app/pocketbase \ && rm -f /tmp/pb.zip -# ------------------------------------------------------------------------------ -# Stage 4: Production runner (Target architecture) -# ------------------------------------------------------------------------------ +# Runtime image. Runs on the target architecture. FROM node:lts-trixie-slim AS runner LABEL maintainer="Amritanshu " @@ -54,31 +46,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ WORKDIR /app -# Copy PocketBase binary and migrations COPY --from=pb-downloader /app/pocketbase /app/pocketbase COPY backend/pb_migrations /app/pb_migrations - -# Copy compiled frontend application COPY --from=builder /app/frontend/.output /app/frontend/.output - -# Copy entrypoint script COPY docker-entrypoint.sh /app/docker-entrypoint.sh RUN chmod +x /app/docker-entrypoint.sh -# Environment variables ENV NODE_ENV=production \ PORT=3000 \ HOST=0.0.0.0 \ PB_DATA_DIR=/app/pb_data -# Persistent data volume for PocketBase SQLite database and file storage VOLUME ["/app/pb_data"] -# Port 3000: TanStack Start Web Application -# Port 8090: PocketBase REST API & Admin UI EXPOSE 3000 8090 -# Health check for both PocketBase and frontend server +# podman drops this when it builds an OCI image. The Quadlet unit repeats it. HEALTHCHECK --interval=15s --timeout=3s --start-period=5s --retries=3 \ CMD curl -fsS http://127.0.0.1:8090/api/health > /dev/null && curl -fsS http://127.0.0.1:3000 > /dev/null || exit 1 diff --git a/Makefile b/Makefile index 15370a7..5177419 100644 --- a/Makefile +++ b/Makefile @@ -1,42 +1,30 @@ -# Hops n Grains — 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 - +# emily is aarch64, so that is the default target. A foreign platform needs +# the host's binfmt handlers, because podman has no built-in QEMU. +PLATFORMS ?= linux/arm64 IMAGE ?= registry.tanshu.com/hops .PHONY: build-production -build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push - @echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest" +build-production: ## Build HEAD with podman, push :$(COMMIT) and :main + @echo "==> podman build --platform $(PLATFORMS) -> $(IMAGE):$(COMMIT) + :main" @git archive --format=tar HEAD | podman build \ - --platform $(PLATFORM) \ + --platform $(PLATFORMS) \ --tag $(IMAGE):$(COMMIT) \ - --tag $(IMAGE):latest \ - --pull=always \ + --tag $(IMAGE):main \ - || { \ - 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"; \ + 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):latest + podman push $(IMAGE):main .PHONY: build-check build-check: ## Compile check from HEAD with podman, nothing pushed - @echo "==> podman build --platform $(PLATFORM) (no push)" - git archive --format=tar HEAD | podman build \ - --platform $(PLATFORM) \ - --tag localhost/hops:check \ - --pull=always \ + @git archive --format=tar HEAD | podman build \ + --platform $(PLATFORMS) \ + --tag hops:check \ + --pull \ - diff --git a/README.md b/README.md index 0a17534..0d89d9e 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ A modern, full-stack craft microbrewery web application built with **TanStack St --- -## Architecture Overview +## Architecture overview ``` hops/ @@ -36,7 +36,7 @@ hops/ │ └── package.json ├── ansible/ # Deployment playbook (roles: register, hops, caddy) ├── Dockerfile # Multi-stage production container build -├── .containerignore # Build-context excludes (Podman/Buildah) +├── .dockerignore # Build-context excludes (podman and Docker) ├── .gitignore # Git repository ignore rules ├── docker-entrypoint.sh # Process supervisor running PB & Nitro ├── Makefile # Podman build targets (build-production / build-check) @@ -47,9 +47,9 @@ hops/ --- -## Running Locally +## Running locally -### 1. Start Both Frontend & Backend Concurrently +### 1. Start the frontend and backend together ```bash npm run dev ``` @@ -79,32 +79,36 @@ npm run dev:frontend ## Deploying -The image is built with **Podman/Buildah** (not Docker) from the committed tree -(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host -is exactly what git knows about. `deploy.sh` pushes the commit first, refuses a -dirty tracked tree and requires `HEAD == origin/main`. Log in to the registry -once per machine: `podman login registry.tanshu.com`. +`deploy.sh` pushes the current branch, refuses a dirty tracked tree, and +requires `HEAD == origin/main`. It builds that commit with podman and runs the +Ansible play on emily. Log in to the registry once per machine: +`podman login registry.tanshu.com`. ```bash -./deploy.sh # push, build , deploy it to emily -./deploy.sh --no-push # skip the push (you already pushed) -./deploy.sh --force # skip the guards +./deploy.sh # deploy HEAD +./deploy.sh --prune # also drop old images on emily, keep 3 +./deploy.sh --force # skip the guards ``` -Or run the pieces yourself: +The image is tagged with its commit id and `:main`. Rollback runs the same play +with an older `image_tag`: ```bash -make build-production # podman build HEAD, push : + :latest -make build-check # compile check from HEAD, no push +make build-production # podman build HEAD, push : and :main +make build-check # build from HEAD, push nothing -cd ansible && ansible-playbook -u tanshu playbook.yml -e "image_tag=$(git rev-parse --short HEAD)" +cd ansible && ansible-playbook playbook.yml -e "image_tag=$(git rev-parse --short HEAD)" ``` -The image is built for **linux/arm64** by default because `emily` is aarch64. -Podman has no built-in QEMU, so cross-building from an x86_64 controller needs -binfmt there (`qemu-user-binfmt`); override with `PLATFORM=linux/amd64`. +The image builds for `linux/arm64` by default because emily is aarch64. Podman +has no built-in QEMU, so cross-building from an x86_64 controller needs binfmt +there (`qemu-user-static`). Override with `PLATFORMS=linux/amd64`: -To run the production image locally instead: +```bash +make build-production PLATFORMS=linux/amd64 +``` + +To run the production image locally: ```bash podman build -t localhost/hops:dev . @@ -115,55 +119,51 @@ podman run --rm -p 3000:3000 -p 8090:8090 \ ### Ansible deployment -Requirements on the controller: `ansible` (no collections — the play uses -built-in modules plus the `podman` / `systemctl --user` CLI). The target host -must already run the fleet's shared Caddy **rootless Quadlet** setup: the -`caddy/playbook.yml` from the `ansible-playbooks` repo owns the Caddy unit, the -rootless prerequisites (Podman, `loginctl enable-linger`, the low-port sysctl) -and the Caddyfile this play appends to. +The controller needs `ansible`. The target host needs the shared Caddy rootless +Quadlet setup from the `ansible-playbooks` repo. That playbook owns the Caddy +unit, the rootless prerequisites, and the Caddyfile this play appends to. -The play targets the inventory host `emily` (present in `/etc/ansible/hosts`), -connects as the unprivileged container user and **does not `become`**: the -Quadlet units belong to that user, and the SSH connection's PAM session is what -makes `systemctl --user` work. +The play targets `emily`, connects as the unprivileged container user, and does +not use `become`. The Quadlet units belong to that user, and the SSH session's +PAM context makes `systemctl --user` work. ```bash cd ansible -ansible-playbook -u tanshu playbook.yml -e "image_tag=" +ansible-playbook playbook.yml -e "image_tag=" ``` -All deployment variables live in `ansible/vars/default.yml` (registry, image -tag, domains, PocketBase admin credentials). The play: +Skip the Caddyfile update until DNS points at emily. Otherwise Caddy retries +certificate issuance for a name that still resolves to monoco: -1. Writes `~/.config/containers/systemd/hops.network` - (`NetworkName=hops_net`) and a drop-in that attaches the shared Caddy unit to - it, so Caddy can resolve `hops` by container name. -2. Logs in to the private registry, pulls `registry.tanshu.com/hops:{image_tag}` - (rollback is the same play with an older commit), writes - `~/podman-data/hops/.env`, installs - `~/.config/containers/systemd/hops.container`, starts it as a **systemd user - service** and waits for the image's healthcheck (PocketBase `/api/health` + - the site). -3. Appends the site blocks to the shared Caddyfile - (`~/podman-data/caddy/conf/Caddyfile`) and reloads Caddy: +```bash +ansible-playbook playbook.yml -e "image_tag=" --skip-tags caddy +``` - - `www.hopsngrains.com` (+ apex `hopsngrains.com`) — frontend on port 3000, - PocketBase API/admin proxied under `/api/*` and `/_/*` - - `admin.hopsngrains.com` — redirects to the PocketBase admin UI +Deployment variables live in `ansible/vars/default.yml`. The play: -Nothing is published on the host; Caddy reaches the container over `hops_net`. +1. Writes `~/.config/containers/systemd/hops.network` with `NetworkName=hops_net`, + plus a drop-in that attaches the shared Caddy unit to it. Caddy can then + resolve `hops` by container name. +2. Logs in to the registry, pulls `registry.tanshu.com/hops:{image_tag}`, writes + `~/podman-data/hops/.env`, installs `hops.container`, starts it as a systemd + user service, and waits for the healthcheck. +3. Appends the site blocks to `~/podman-data/caddy/conf/Caddyfile` and reloads + Caddy. `www.hopsngrains.com` and the apex serve the frontend on port 3000 and + proxy `/api/*` and `/_/*` to PocketBase. `admin.hopsngrains.com` redirects to + the PocketBase admin UI. + +Nothing is published on the host. Caddy reaches the container over `hops_net`. The container runs as uid 0, so the unit maps it onto the podman user with -`UserNS=keep-id:uid=0,gid=0`: `~/podman-data/hops` stays owned by `tanshu`, and -PocketBase's files need no `podman unshare` to inspect or back up. +`UserNS=keep-id:uid=0,gid=0`. Files under `~/podman-data/hops` stay owned by +`tanshu`, and PocketBase's files need no `podman unshare` to read or back up. -DNS records for all three hosts must point at `emily`; Caddy obtains TLS -certificates automatically on first request. This deploy starts from empty -data — the schema migration in `backend/pb_migrations/` is applied on first -start, so the seed beers and food are recreated automatically. +DNS for all three hosts must point at emily before Caddy can issue certificates. +The first deploy starts with empty data. PocketBase applies the schema migration +in `backend/pb_migrations/` on first boot and recreates the seed beers and food. --- -## Features & Pages +## Features and pages | Route | Name | Highlights | |---|---|---| diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 79ffc1f..520a932 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -1,18 +1,7 @@ --- -# Hops n Grains — rootless Podman + Quadlet, behind the fleet's shared Caddy -# unit (caddy/playbook.yml in the ansible-playbooks repo). -# -# 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 -# `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, podman_data_dir and quadlet_dir are -# derived from the user_id / user_dir facts. -# -# Run with: ansible-playbook -u tanshu playbook.yml +# Hops n Grains as a rootless Podman Quadlet on emily, behind the shared Caddy. +# Run: ansible-playbook playbook.yml -e image_tag= +# Use --skip-tags caddy until DNS for hopsngrains.com points at emily. - name: Deploy Hops n Grains as a rootless Podman Quadlet hosts: emily gather_facts: true @@ -20,17 +9,12 @@ - vars/default.yml pre_tasks: - # This must happen before the app unit is started, not after: the generated - # hops.container references {{ network_name }}.network, and Quadlet fails - # *unit generation* (so `systemctl --user start` reports "unit not found") - # when a referenced .network file does not exist yet. The same include - # writes the drop-in that attaches the shared Caddy unit to this network, - # which is what makes `hops` resolvable to Caddy. - - name: Register Hops n Grains' network with Caddy + # Quadlet fails unit generation when hops.network is missing, so write the + # network and the Caddy drop-in before the app unit starts. + - name: Register the Hops n Grains network with Caddy ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml" roles: - # The app: data directory, .env, the Quadlet unit, start + healthcheck. - hops - # Appends this site's blocks to the shared Caddyfile and reloads Caddy. + # The Caddyfile task is tagged caddy. Skip it until DNS points here. - caddy diff --git a/ansible/roles/caddy/handlers/main.yaml b/ansible/roles/caddy/handlers/main.yaml index ec7973a..719be8a 100644 --- a/ansible/roles/caddy/handlers/main.yaml +++ b/ansible/roles/caddy/handlers/main.yaml @@ -1,27 +1,13 @@ --- -# Caddy is its own playbook (caddy/playbook.yml in the ansible-playbooks repo) -# and a rootless Quadlet unit there. These handlers run as the connection user, -# so `systemctl --user` and `podman exec` work without any XDG_RUNTIME_DIR -# juggling. - -# Notified by roles/caddy when the Caddyfile changes. A reload keeps existing -# connections; a restart would briefly drop every hosted site. -# -# Note the config path: /etc/caddy/Caddyfile is the mount inside the container, -# not {{ caddyfile_path }}, which is the host side of it. +# A reload keeps existing connections. A restart would drop every hosted site. - name: Reload Caddy configuration ansible.builtin.command: >- podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile changed_when: true listen: "Reload Caddy" -# Notified by roles/register when this service's network unit, or the drop-in -# that attaches Caddy to it, is written. Quadlet has no live equivalent of -# `docker network connect`, so the container has to be recreated — a brief -# restart of every hosted site, not just this one. -# -# Defined after the reload above so that when both are notified the cheap config -# reload happens first. +# Notified when the network drop-in changes. Quadlet cannot add a network to a +# running container, so Caddy must be recreated. - name: Restart Caddy ansible.builtin.systemd_service: name: "{{ caddy_name }}.service" diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml index 63015f9..8e6a74c 100644 --- a/ansible/roles/caddy/tasks/main.yaml +++ b/ansible/roles/caddy/tasks/main.yaml @@ -1,16 +1,9 @@ --- -# Append Hops n Grains' site blocks to the shared Caddyfile. -# -# The Caddyfile is a bind mount into the Caddy container, so Caddy sees the -# change as soon as the file is written; 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 -# slurp the file and compare first — which also matters because slurp *fails* -# when the Caddyfile does not exist yet, on a host where caddy/playbook.yml has -# not run. +# Append the site blocks to the shared Caddyfile. The file is bind-mounted into +# Caddy, so the reload handler is enough. blockinfile is idempotent. - name: "Add the {{ host }} site blocks to the Caddyfile" + tags: [caddy] ansible.builtin.blockinfile: path: "{{ caddyfile_path }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" diff --git a/ansible/roles/hops/handlers/main.yaml b/ansible/roles/hops/handlers/main.yaml index f5c0406..0fc489b 100644 --- a/ansible/roles/hops/handlers/main.yaml +++ b/ansible/roles/hops/handlers/main.yaml @@ -1,6 +1,5 @@ --- -# All handlers run as the connection user ({{ podman_user }}), so -# `systemctl --user` works without setting XDG_RUNTIME_DIR. +# Handlers run as the connection user, so systemctl --user works as is. - name: Reload systemd user units ansible.builtin.command: systemctl --user daemon-reload diff --git a/ansible/roles/hops/tasks/main.yaml b/ansible/roles/hops/tasks/main.yaml index f39f522..7c889ea 100644 --- a/ansible/roles/hops/tasks/main.yaml +++ b/ansible/roles/hops/tasks/main.yaml @@ -1,14 +1,7 @@ --- -# Hops n Grains as a rootless Podman Quadlet unit. -# -# Everything here runs as {{ podman_user }} — the connection user. No become, no -# XDG_RUNTIME_DIR: the SSH connection gives a real PAM session, so systemd's -# user manager is reachable through the standard environment. +# Hops n Grains as a rootless Podman Quadlet. Runs as {{ podman_user }}. +# The SSH session's PAM context makes systemctl --user work without extra setup. -# The registry is private, so the pull below has to be authenticated. Podman -# keeps the credential in the user's own auth file; it is removed again at the -# end of this role, so nothing but the image is left behind. Skipped when no -# credentials are configured. - name: Log in to the private registry ansible.builtin.command: cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}" @@ -19,12 +12,19 @@ - registry_username | length > 0 - registry_password | length > 0 -# Pulled here rather than left to the unit: Quadlet only pulls when the image is -# missing, and at that point the service has no registry credential to offer. +# Pull here. Quadlet only pulls a missing image, and the unit has no credential. - name: Pull the Hops n Grains image ansible.builtin.command: "podman pull {{ image }}" changed_when: false +# Opt-in via ./deploy.sh --prune. Keeps the three newest images. +- name: Prune old images + ansible.builtin.shell: > + podman images -q {{ image_name }} | tail -n +4 | xargs -r podman rmi + when: hops_image_prune | default(false) | bool + failed_when: false + changed_when: true + - name: Ensure the PocketBase data directory exists ansible.builtin.file: path: "{{ host_directory }}/pb_data" @@ -33,9 +33,7 @@ group: "{{ podman_user }}" mode: '0755' -# Secrets (the PocketBase superuser) and the run-time configuration are read by -# systemd via EnvironmentFile=, not by Ansible, so this has to be written before -# the unit starts. Mode 0600: it holds the admin password. +# systemd reads this file through EnvironmentFile. It holds the admin password. - name: Write the environment file ansible.builtin.template: src: hops.env.j2 @@ -65,10 +63,8 @@ state: started daemon_reload: true rescue: - # systemd reports "control process exited with error code" and nothing else, - # which is useless for the usual failures (a missing image, an unattached - # network, an unwritable data directory). Pull the real reason out. - - name: Collect Hops n Grains diagnostics + # systemd reports only the exit code. Collect the real reason. + - name: Collect diagnostics ansible.builtin.command: "{{ item }}" loop: - "systemctl --user status {{ unit_name }} --no-pager" @@ -78,36 +74,26 @@ changed_when: false failed_when: false - - name: Show Hops n Grains diagnostics + - name: Show diagnostics ansible.builtin.debug: msg: "{{ hops_diag.results | map(attribute='stdout') | list }}" - name: Fail ansible.builtin.fail: - msg: "Hops n Grains did not start — see the diagnostics above." + msg: "Hops n Grains did not start. See the diagnostics above." -# The unit template notifies "Restart Hops n Grains", 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. +# Handlers run at the end of the play. Flush them so the healthcheck gate +# tests the container with the current unit. - name: Apply pending unit changes before verifying ansible.builtin.meta: flush_handlers -# Quadlet honours [Install] WantedBy= when it generates the unit, so -# `systemctl --user enable` is neither needed nor possible on a generated unit. - name: Verify the Hops n Grains unit is active ansible.builtin.command: "systemctl --user is-active {{ unit_name }}" register: hops_active changed_when: false failed_when: hops_active.stdout != 'active' -# `is-active` says the unit is running, not that both processes inside the -# container are healthy: the entrypoint starts PocketBase, waits for -# /api/health and upserts the superuser before the site server answers. The -# image ships a HEALTHCHECK covering both; `healthcheck run` forces one now and -# reports its result instead of waiting out the interval. +# is-active does not prove both processes are healthy. Force a healthcheck. - name: Wait for the container healthcheck to pass ansible.builtin.command: "podman healthcheck run {{ container_name }}" register: hops_health @@ -131,8 +117,7 @@ {{ container_name }} is on {{ hops_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. +# The image is local and the unit only pulls a missing image. - name: Log out of the private registry ansible.builtin.command: "podman logout {{ registry }}" changed_when: false diff --git a/ansible/roles/hops/templates/hops.container.j2 b/ansible/roles/hops/templates/hops.container.j2 index 3020e67..e5cb676 100644 --- a/ansible/roles/hops/templates/hops.container.j2 +++ b/ansible/roles/hops/templates/hops.container.j2 @@ -1,9 +1,5 @@ -# Hops n Grains — rootless Podman Quadlet unit. -# Managed by Ansible (roles/hops). Do not edit by hand. -# -# This unit joins {{ network_name }}.network, which is created by -# roles/register — the same include that attaches the shared Caddy unit to it, -# so Caddy can resolve {{ container_name }} by name. +# Hops n Grains rootless Quadlet unit. Managed by Ansible. +# Joins {{ network_name }}.network so Caddy resolves {{ container_name }}. [Unit] Description=Hops n Grains microbrewery (PocketBase + TanStack Start) @@ -14,27 +10,21 @@ Wants=network-online.target ContainerName={{ container_name }} Image={{ image }} -# The image runs as root: docker-entrypoint.sh starts PocketBase, bootstraps the -# superuser and then starts the site server, and the PocketBase data files it -# writes are root-owned. Mapping container root onto {{ podman_user }} keeps -# {{ host_directory }} readable and writable by both the container and the host -# user, and leaves no subuid-owned files to trip over backups. +# The image runs as root. keep-id maps that onto {{ podman_user }}, so +# {{ host_directory }} stays owned by the host user. UserNS=keep-id:uid=0,gid=0 -# NODE_ENV, PORT, PB_URL and the PocketBase superuser live here. Read by -# systemd, not by Ansible, so the file has to exist before this unit starts. +# systemd reads the env file. It must exist before the unit starts. EnvironmentFile={{ host_directory }}/.env Network={{ network_name }}.network Volume={{ host_directory }}/pb_data:/app/pb_data -# No PublishPort on purpose: the Docker-era deployment had none either, and -# Caddy reaches both ports over {{ network_name }}_net. +# No PublishPort. Caddy reaches both ports over {{ network_name }}_net. -# 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`. curl is in the runner image. +# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK, +# so declare the check here. HealthCmd={{ health_cmd }} HealthInterval=15s HealthTimeout=3s @@ -43,8 +33,7 @@ HealthStartPeriod=5s [Service] Restart=always -# The entrypoint waits for PocketBase and upserts the superuser before the site -# server answers, so allow generously for a large database. +# The entrypoint waits for PocketBase and the superuser before the site answers. TimeoutStartSec=300 [Install] diff --git a/ansible/roles/register/tasks/main.yaml b/ansible/roles/register/tasks/main.yaml index fff9643..a71e1b8 100644 --- a/ansible/roles/register/tasks/main.yaml +++ b/ansible/roles/register/tasks/main.yaml @@ -1,18 +1,6 @@ --- -# Register Hops n Grains' network with the shared Caddy unit. -# -# Caddy is the base unit (caddy/playbook.yml in the ansible-playbooks repo) and -# knows nothing about its upstreams. A service adds itself by writing two files -# into the Quadlet directory: -# -# 1. its own hops.network unit, and -# 2. a drop-in next to caddy.container that pulls that network into Caddy. -# -# This mirrors ansible-playbooks/caddy/roles/register/tasks/main.yaml, vendored -# 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 }}. +# Write hops.network and the Caddy drop-in that joins Caddy to it. +# Run from pre_tasks, before the app unit is generated. - name: Ensure the Quadlet directory exists ansible.builtin.file: @@ -29,15 +17,8 @@ group: "{{ podman_user }}" mode: '0644' content: | - # {{ network_name }} — logical app network. - # Managed by Ansible. Do not edit by hand. - # - # NetworkName pins the Podman network name. Without it Quadlet would - # create "systemd-{{ network_name }}". - # - # 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 it is a member of it. + # {{ network_name }} network unit. Managed by Ansible. + # NetworkName pins the Podman name to {{ network_name }}_net. [Network] NetworkName={{ network_name }}_net Options=isolate=true @@ -58,10 +39,8 @@ group: "{{ podman_user }}" mode: '0644' content: | - # Added by Hops n Grains' playbook. Do not edit by hand. - # - # Quadlet merges this into caddy.container. Deleting this file removes - # the network from Caddy on the next daemon-reload. + # Added by the Hops n Grains playbook. Managed by Ansible. + # Quadlet merges this into caddy.container. [Container] Network={{ network_name }}.network notify: Restart Caddy diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index 31562d5..57f2e56 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -1,67 +1,47 @@ --- -# --------------------------------------------------------------------------- -# Rootless Podman / Quadlet -# --------------------------------------------------------------------------- -# Derived from the connection: this play carries no become, so the Quadlet -# units belong to whoever Ansible connects as. Requires gather_facts (set in -# playbook.yml). A host that has already run caddy/playbook.yml from the -# ansible-playbooks repo has the rootless prerequisites. +# Rootless Podman and Quadlet on emily. The play runs as the connecting user, +# so these paths follow that user's home. podman_user: "{{ ansible_facts['user_id'] }}" podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data" quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd" -# --------------------------------------------------------------------------- -# Image -# --------------------------------------------------------------------------- registry: registry.tanshu.com registry_username: ta-registry registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f image_name: "{{ registry }}/hops" -tag: latest -# deploy.sh passes -e image_tag=; `tag` is the fallback when running the -# play by hand. Rollback = -e image_tag=. +# deploy.sh passes image_tag=. Rollback uses an older commit. +tag: main image: "{{ image_name }}:{{ image_tag | default(tag) }}" -# --------------------------------------------------------------------------- -# Hops n Grains -# --------------------------------------------------------------------------- app_name: hops container_name: "{{ app_name }}" unit_name: "{{ container_name }}.service" -# The Quadlet file is .network; roles/register pins the Podman -# network name to _net. +# The register role pins NetworkName to hops_net. network_name: "{{ app_name }}" host_directory: "{{ podman_data_dir }}/{{ container_name }}" -# Ports inside the container. Caddy proxies to them over hops_net; nothing is -# published on the host. +# Caddy reaches both ports over hops_net. Nothing is published on the host. site_port: 3000 pb_port: 8090 -# The Dockerfile's HEALTHCHECK is dropped when podman builds an OCI image (its -# default), so the same check is declared on the Quadlet unit and the play gates -# on `podman healthcheck run`. curl is in the runner image. +# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK, +# so the unit declares the check and the play gates on it. health_cmd: "curl -fsS http://127.0.0.1:{{ pb_port }}/api/health > /dev/null && curl -fsS http://127.0.0.1:{{ site_port }} > /dev/null" -# Public hosts for frontend & PocketBase routing. host: www.hopsngrains.com apex_host: hopsngrains.com admin_host: admin.hopsngrains.com -# --------------------------------------------------------------------------- -# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo) -# --------------------------------------------------------------------------- caddy_name: caddy caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" -# Appended to the shared Caddyfile by roles/caddy. The sites are served from -# the container name over hops_net; no host ports are involved. +# Appended to the shared Caddyfile by roles/caddy. snippet_block: | {{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} { - # PocketBase Admin UI and REST API + # PocketBase admin UI and REST API redir /_ /_/ handle /_/* { reverse_proxy {{ container_name }}:{{ pb_port }} @@ -70,7 +50,7 @@ snippet_block: | reverse_proxy {{ container_name }}:{{ pb_port }} } - # TanStack Start frontend application + # TanStack Start frontend handle { reverse_proxy {{ container_name }}:{{ site_port }} } @@ -82,8 +62,6 @@ snippet_block: | } {% endif %} -# --------------------------------------------------------------------------- -# PocketBase initial superuser credentials (bootstrapped on first start) -# --------------------------------------------------------------------------- +# Bootstrapped on first start. pb_admin_email: admin@hopsngrains.com pb_admin_password: HopsPassword1234! diff --git a/deploy.sh b/deploy.sh index e49603f..38c1655 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,72 +1,47 @@ #!/usr/bin/env bash -# -# Hops n Grains deploy — push the current commit, build it with podman, and -# install the Quadlet unit on emily with ansible. -# -# The image is built from the committed tree (`git archive HEAD`) and tagged -# with its commit id, so what runs on the host is exactly what git knows about. -# -# ./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 -# -# Log in to the registry once beforehand: podman login registry.tanshu.com +# Deploy HEAD with podman and ansible on emily. Flags: --prune keeps three old +# images, --force skips the guards. The tree must be clean and HEAD pushed. set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")" -BRANCH="${BRANCH:-main}" +git push FORCE=false -NO_PUSH=false +PRUNE=false for arg in "$@"; do case "$arg" in --force) FORCE=true ;; - --no-push) NO_PUSH=true ;; + --prune) PRUNE=true ;; *) - echo "Usage: $0 [--force] [--no-push]" >&2 + echo "Usage: $0 [--force] [--prune]" >&2 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 - -# Guards. Only tracked-file state matters — untracked and ignored files never -# 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 [ "$FORCE" != true ]; then if [ -n "$(git status --porcelain --untracked-files=no)" ]; then - echo "x tracked files modified - commit or stash first (or --force)" >&2 + echo "x tracked files modified, commit or stash first (or --force)" >&2 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 + if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then + echo "x HEAD != origin/main, push or pull first (or --force)" >&2 exit 1 fi fi +git pull --ff-only TAG=$(git rev-parse --short HEAD) echo "==> deploying commit $TAG" - -echo "========================================================" -echo " Building & Uploading Container Image (Podman)" -echo "========================================================" make build-production COMMIT="$TAG" -echo "" -echo "========================================================" -echo " Executing Ansible Deployment" -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=. -ansible-playbook -u "$deploy_user" playbook.yml -e "image_tag=$TAG" +if [ "$PRUNE" = true ]; then + PRUNE_FLAG="-e hops_image_prune=true" +else + PRUNE_FLAG="" +fi + +cd ansible +# shellcheck disable=SC2086 +ansible-playbook playbook.yml -e "image_tag=$TAG" $PRUNE_FLAG