diff --git a/.containerignore b/.containerignore new file mode 100644 index 0000000..ff296c3 --- /dev/null +++ b/.containerignore @@ -0,0 +1,41 @@ +# 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 deleted file mode 100644 index 24c5d42..0000000 --- a/.dockerignore +++ /dev/null @@ -1,40 +0,0 @@ -# Version control & IDEs -.git -.gitignore -.dockerignore -.vscode -.idea -.agents -.gemini - -# Build artifacts & compiled outputs -.output -frontend/.output -frontend/.tanstack -frontend/dist -dist - -# Dependencies -node_modules -frontend/node_modules - -# PocketBase local data & local binaries -backend/pb_data -backend/pocketbase -backend/pocketbase.exe -*.db -*.db-shm -*.db-wal - -# Logs & temporary files -*.log -npm-debug.log* -scratch -page.html -.DS_Store -Thumbs.db - -# Documentation -README.md -walkthrough.md -implementation_plan.md diff --git a/.gitignore b/.gitignore index 60d3bca..5337cab 100644 --- a/.gitignore +++ b/.gitignore @@ -23,7 +23,6 @@ backend/pocketbase.exe .env.local .env.*.local !.env.example -!ansible/files/.env # IDEs & System files .vscode/ diff --git a/Makefile b/Makefile index 19b772b..15370a7 100644 --- a/Makefile +++ b/Makefile @@ -1,36 +1,42 @@ +# 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 + +IMAGE ?= registry.tanshu.com/hops + .PHONY: build-production -build-production: ## Build the production docker image and push to private registry - @docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --tag registry.tanshu.com/hops:latest \ - $(if $(filter-out latest,$(TAG)),--tag registry.tanshu.com/hops:$(TAG)) \ - --push \ - --pull \ - git@git.tanshu.com:tanshu/hops.git +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 .PHONY: build-check -build-check: ## Multi-arch build without push (compile check) - @docker buildx build \ - --platform linux/amd64,linux/arm64 \ - --tag hops:test \ - --pull \ - --progress=plain \ - git@git.tanshu.com:tanshu/hops.git - -.PHONY: build-check-local -build-check-local: ## Multi-arch build without push (compile check) - @git archive --format=tar HEAD | docker buildx build \ - --platform linux/amd64 \ - --tag hops:test \ - --pull \ - --progress=plain \ - --load \ +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 \ - - -# .PHONY: deploy -# deploy: ## Build, upload to registry, and trigger Ansible deployment -# @./deploy.sh --deploy $(if $(TAG),$(TAG)) - -# .PHONY: ansible-deploy -# ansible-deploy: ## Run Ansible playbook directly -# @cd ansible && ansible-playbook -i hosts playbook.yml $(if $(TAG),-e "tag=$(TAG)") diff --git a/README.md b/README.md index e162b73..0a17534 100644 --- a/README.md +++ b/README.md @@ -34,11 +34,13 @@ hops/ │ │ │ └── pocketbase.ts # PocketBase SDK client with fallback data resilience │ │ └── styles.css # Tailwind theme tokens, Playfair Display, Montserrat, textures │ └── package.json +├── ansible/ # Deployment playbook (roles: register, hops, caddy) ├── Dockerfile # Multi-stage production container build -├── .dockerignore # Container build ignore rules +├── .containerignore # Build-context excludes (Podman/Buildah) ├── .gitignore # Git repository ignore rules -├── docker-compose.yml # Local & server orchestration with persistent storage ├── docker-entrypoint.sh # Process supervisor running PB & Nitro +├── Makefile # Podman build targets (build-production / build-check) +├── deploy.sh # Build & push image, then run the Ansible deploy ├── package.json # Monorepo management scripts └── README.md ``` @@ -75,34 +77,90 @@ npm run dev:frontend --- -## Docker Deployment +## Deploying -The application is containerized using a multi-stage Docker build that bundles both the TanStack Start Nitro SSR server and PocketBase into a single, self-contained container. +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`. -### Option A: Using Docker Compose (Recommended) ```bash -docker compose up --build -d +./deploy.sh # push, build , deploy it to emily +./deploy.sh --no-push # skip the push (you already pushed) +./deploy.sh --force # skip the guards ``` -- Web Application: `http://localhost:3000` -- PocketBase Admin: `http://localhost:8090/_/` -- Data Persistence: Automatically stored in the `pb_data` named Docker volume. -### Option B: Building & Running Directly with Docker +Or run the pieces yourself: + ```bash -# Build the Docker image -docker build -t hops-n-grains:latest . +make build-production # podman build HEAD, push : + :latest +make build-check # compile check from HEAD, no push -# Run container with persistent volume -docker run -d \ - --name hops-app \ - -p 3000:3000 \ - -p 8090:8090 \ - -v hops_pb_data:/app/pb_data \ - -e PB_ADMIN_EMAIL=admin@hopsngrains.com \ - -e PB_ADMIN_PASSWORD=HopsPassword1234! \ - hops-n-grains:latest +cd ansible && ansible-playbook -u tanshu 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`. + +To run the production image locally instead: + +```bash +podman build -t localhost/hops:dev . +podman run --rm -p 3000:3000 -p 8090:8090 \ + -e PB_ADMIN_EMAIL=admin@hopsngrains.com -e PB_ADMIN_PASSWORD=HopsPassword1234! \ + -v hops_dev_data:/app/pb_data localhost/hops:dev +``` + +### 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 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. + +```bash +cd ansible +ansible-playbook -u tanshu playbook.yml -e "image_tag=" +``` + +All deployment variables live in `ansible/vars/default.yml` (registry, image +tag, domains, PocketBase admin credentials). The play: + +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: + + - `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 + +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. + +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. + --- ## Features & Pages diff --git a/ansible/files/Caddyfile.j2 b/ansible/files/Caddyfile.j2 deleted file mode 100644 index c8f0395..0000000 --- a/ansible/files/Caddyfile.j2 +++ /dev/null @@ -1,22 +0,0 @@ -{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} { - # PocketBase Admin UI and REST API - redir /_ /_/ - handle /_/* { - reverse_proxy {{ container_name }}:8090 - } - handle /api/* { - reverse_proxy {{ container_name }}:8090 - } - - # TanStack Start Frontend Application - handle { - reverse_proxy {{ container_name }}:3000 - } -} - -{% if admin_host is defined and admin_host %} -{{ admin_host }} { - redir / /_/ - reverse_proxy {{ container_name }}:8090 -} -{% endif %} diff --git a/ansible/playbook.yml b/ansible/playbook.yml index 1298842..79ffc1f 100644 --- a/ansible/playbook.yml +++ b/ansible/playbook.yml @@ -1,11 +1,36 @@ --- -- name: Deploy Hops n Grains Microbrewery - hosts: monoco - become: true +# 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 +- name: Deploy Hops n Grains as a rootless Podman Quadlet + hosts: emily + gather_facts: true vars_files: - 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 + ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml" + roles: - - network + # The app: data directory, .env, the Quadlet unit, start + healthcheck. - hops + # Appends this site's blocks to the shared Caddyfile and reloads Caddy. - caddy diff --git a/ansible/requirements.yml b/ansible/requirements.yml deleted file mode 100644 index 660f775..0000000 --- a/ansible/requirements.yml +++ /dev/null @@ -1,3 +0,0 @@ ---- -collections: - - name: community.docker diff --git a/ansible/roles/caddy/defaults/main.yaml b/ansible/roles/caddy/defaults/main.yaml deleted file mode 100644 index 259ebf2..0000000 --- a/ansible/roles/caddy/defaults/main.yaml +++ /dev/null @@ -1,3 +0,0 @@ ---- -caddy_container: caddy -caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile diff --git a/ansible/roles/caddy/handlers/main.yaml b/ansible/roles/caddy/handlers/main.yaml index 5fd9c8a..ec7973a 100644 --- a/ansible/roles/caddy/handlers/main.yaml +++ b/ansible/roles/caddy/handlers/main.yaml @@ -1,5 +1,31 @@ --- +# 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. - name: Reload Caddy configuration - ansible.builtin.command: "docker exec -w /etc/caddy {{ caddy_container }} caddy reload" - listen: "Reload Caddy" + 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. +- name: Restart Caddy + ansible.builtin.systemd_service: + name: "{{ caddy_name }}.service" + scope: user + state: restarted + daemon_reload: true + listen: Restart Caddy diff --git a/ansible/roles/caddy/tasks/main.yaml b/ansible/roles/caddy/tasks/main.yaml index 3fdaee9..63015f9 100644 --- a/ansible/roles/caddy/tasks/main.yaml +++ b/ansible/roles/caddy/tasks/main.yaml @@ -1,26 +1,22 @@ --- -- name: Read snippet from template file - ansible.builtin.set_fact: - caddy_snippet_block: "{{ lookup('template', 'files/Caddyfile.j2') }}" +# 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. -- name: Read current Caddyfile - ansible.builtin.slurp: - path: "{{ caddy_caddyfile_path }}" - register: caddy_caddyfile_raw - -- name: Decode Caddyfile content - ansible.builtin.set_fact: - caddy_caddyfile_content: "{{ caddy_caddyfile_raw['content'] | b64decode }}" - -- name: Check if snippet already exists - ansible.builtin.set_fact: - caddy_snippet_present: "{{ caddy_snippet_block in caddy_caddyfile_content }}" - -- name: Add or update snippet in Caddyfile +- name: "Add the {{ host }} site blocks to the Caddyfile" ansible.builtin.blockinfile: - path: "{{ caddy_caddyfile_path }}" + path: "{{ caddyfile_path }}" marker: "# {mark} Ansible managed Caddy snippet for {{ host }}" - block: "{{ caddy_snippet_block }}" + block: "{{ snippet_block }}" create: true - mode: "0644" - notify: Reload Caddy configuration + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: Reload Caddy diff --git a/ansible/roles/hops/handlers/main.yaml b/ansible/roles/hops/handlers/main.yaml new file mode 100644 index 0000000..f5c0406 --- /dev/null +++ b/ansible/roles/hops/handlers/main.yaml @@ -0,0 +1,16 @@ +--- +# All handlers run as the connection user ({{ podman_user }}), so +# `systemctl --user` works without setting XDG_RUNTIME_DIR. + +- name: Reload systemd user units + ansible.builtin.command: systemctl --user daemon-reload + changed_when: true + listen: Reload systemd user units + +- name: Restart Hops n Grains + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + state: restarted + daemon_reload: true + listen: Restart Hops n Grains diff --git a/ansible/roles/hops/tasks/main.yaml b/ansible/roles/hops/tasks/main.yaml index b5e9dd0..814d625 100644 --- a/ansible/roles/hops/tasks/main.yaml +++ b/ansible/roles/hops/tasks/main.yaml @@ -1,43 +1,133 @@ --- -- name: Log in to private Docker registry - community.docker.docker_login: - registry: "{{ registry }}" - username: "{{ username }}" - password: "{{ password }}" +# 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. -- name: Pull Hops n Grains image - community.docker.docker_image: - name: "{{ image_name }}" - source: pull - state: present - force_source: true +# 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 }}" + stdin: "{{ registry_password }}" + no_log: true + changed_when: false + when: + - registry_username | length > 0 + - registry_password | length > 0 -- name: Ensure Host Directory exists +# 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. +- name: Pull the Hops n Grains image + ansible.builtin.command: "podman pull {{ image }}" + changed_when: false + +- name: Ensure the PocketBase data directory exists ansible.builtin.file: - path: "{{ host_data_path }}" + path: "{{ host_directory }}/pb_data" state: directory - mode: "0755" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0755' -- name: Ensure PocketBase data directory exists - ansible.builtin.file: - path: "{{ host_data_path }}/pb_data" - state: directory - mode: "0755" - -- name: Upload the .env file +# 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. +- name: Write the environment file ansible.builtin.template: - src: "files/.env" - dest: "{{ host_data_path }}/.env" - mode: "0600" + src: hops.env.j2 + dest: "{{ host_directory }}/.env" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0600' + notify: Restart Hops n Grains -- name: Create and run Hops n Grains container - community.docker.docker_container: - name: "{{ container_name }}" - image: "{{ image_name }}" - state: started - restart_policy: "unless-stopped" - env_file: "{{ host_data_path }}/.env" - volumes: - - "{{ host_data_path }}/pb_data:/app/pb_data" - networks: - - name: "{{ docker_network }}" +- name: Install the Hops n Grains Quadlet unit + ansible.builtin.template: + src: hops.container.j2 + dest: "{{ quadlet_dir }}/{{ container_name }}.container" + owner: "{{ podman_user }}" + group: "{{ podman_user }}" + mode: '0644' + notify: + - Reload systemd user units + - Restart Hops n Grains + +- name: Start Hops n Grains + block: + - name: Start Hops n Grains + ansible.builtin.systemd_service: + name: "{{ unit_name }}" + scope: user + 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 + ansible.builtin.command: "{{ item }}" + loop: + - "systemctl --user status {{ unit_name }} --no-pager" + - "journalctl --user -u {{ unit_name }} --no-pager -n 60" + - "podman logs --tail 60 {{ container_name }}" + register: hops_diag + changed_when: false + failed_when: false + + - name: Show Hops n Grains 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." + +# 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. +- name: Wait for the container healthcheck to pass + ansible.builtin.command: "podman healthcheck run {{ container_name }}" + register: hops_health + until: hops_health.rc == 0 + retries: 36 + delay: 5 + changed_when: false + failed_when: hops_health.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: hops_networks_now + changed_when: false + failed_when: false + +- name: Report the network + ansible.builtin.debug: + msg: >- + {{ 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. +- 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 diff --git a/ansible/roles/hops/templates/hops.container.j2 b/ansible/roles/hops/templates/hops.container.j2 new file mode 100644 index 0000000..4689b50 --- /dev/null +++ b/ansible/roles/hops/templates/hops.container.j2 @@ -0,0 +1,46 @@ +# 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. + +[Unit] +Description=Hops n Grains microbrewery (PocketBase + TanStack Start) +After=network-online.target +Wants=network-online.target + +[Container] +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. +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. +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. + +# The image's own HEALTHCHECK (PocketBase /api/health on {{ pb_port }} and the +# site on {{ site_port }}) is used as is; the playbook gates on +# `podman healthcheck run`. + +[Service] +Restart=always +# The entrypoint waits for PocketBase and upserts the superuser before the site +# server answers, so allow generously for a large database. +TimeoutStartSec=300 + +[Install] +WantedBy=default.target diff --git a/ansible/files/.env b/ansible/roles/hops/templates/hops.env.j2 similarity index 69% rename from ansible/files/.env rename to ansible/roles/hops/templates/hops.env.j2 index c83332d..5f87368 100644 --- a/ansible/files/.env +++ b/ansible/roles/hops/templates/hops.env.j2 @@ -1,6 +1,7 @@ NODE_ENV=production -PORT=3000 +PORT={{ site_port }} HOST=0.0.0.0 PB_DATA_DIR=/app/pb_data +PB_URL=http://127.0.0.1:{{ pb_port }} PB_ADMIN_EMAIL={{ pb_admin_email }} PB_ADMIN_PASSWORD={{ pb_admin_password }} diff --git a/ansible/roles/network/tasks/main.yaml b/ansible/roles/network/tasks/main.yaml deleted file mode 100644 index f8d7781..0000000 --- a/ansible/roles/network/tasks/main.yaml +++ /dev/null @@ -1,8 +0,0 @@ ---- -- name: Ensure Docker network exists - community.docker.docker_network: - name: "{{ docker_network }}" - state: present - connected: - - "{{ caddy_container }}" - appends: true diff --git a/ansible/roles/register/tasks/main.yaml b/ansible/roles/register/tasks/main.yaml new file mode 100644 index 0000000..fff9643 --- /dev/null +++ b/ansible/roles/register/tasks/main.yaml @@ -0,0 +1,67 @@ +--- +# 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 }}. + +- 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" + ansible.builtin.copy: + dest: "{{ quadlet_dir }}/{{ network_name }}.network" + owner: "{{ podman_user }}" + 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] + NetworkName={{ network_name }}_net + Options=isolate=true + 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" + ansible.builtin.copy: + dest: "{{ quadlet_dir }}/{{ caddy_name }}.container.d/50-{{ network_name }}.conf" + owner: "{{ podman_user }}" + 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. + [Container] + Network={{ network_name }}.network + notify: Restart Caddy diff --git a/ansible/vars/default.yml b/ansible/vars/default.yml index f630c34..2b06d14 100644 --- a/ansible/vars/default.yml +++ b/ansible/vars/default.yml @@ -1,28 +1,84 @@ --- +# --------------------------------------------------------------------------- +# 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. +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 -username: ta-registry -password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f +registry_username: ta-registry +registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f -title: hops +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=. +image: "{{ image_name }}:{{ image_tag | default(tag) }}" -image_name: "{{ registry }}/hops:{{ tag }}" - +# --------------------------------------------------------------------------- +# Hops n Grains +# --------------------------------------------------------------------------- app_name: hops -host_directory: "{{ app_name }}" -host_data_path: "/var/lib/{{ host_directory }}" container_name: "{{ app_name }}" -docker_network: "{{ app_name }}_net" +unit_name: "{{ container_name }}.service" -# Public host for frontend & PocketBase routing +# The Quadlet file is .network; roles/register pins the Podman +# network name to _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. +site_port: 3000 +pb_port: 8090 + +# Public hosts for frontend & PocketBase routing. host: www.hopsngrains.com apex_host: hopsngrains.com admin_host: admin.hopsngrains.com -# Caddy configuration -caddy_container: caddy -caddy_caddyfile_path: /var/lib/caddy/conf/Caddyfile +# --------------------------------------------------------------------------- +# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo) +# --------------------------------------------------------------------------- +caddy_name: caddy +caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile" -# PocketBase Initial Superuser Credentials +# Appended to the shared Caddyfile by roles/caddy. The sites are served from +# the container name over hops_net; no host ports are involved. +snippet_block: | + {{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} { + # PocketBase Admin UI and REST API + redir /_ /_/ + handle /_/* { + reverse_proxy {{ container_name }}:{{ pb_port }} + } + handle /api/* { + reverse_proxy {{ container_name }}:{{ pb_port }} + } + + # TanStack Start frontend application + handle { + reverse_proxy {{ container_name }}:{{ site_port }} + } + } + {% if admin_host is defined and admin_host %} + {{ admin_host }} { + redir / /_/ + reverse_proxy {{ container_name }}:{{ pb_port }} + } + {% endif %} + +# --------------------------------------------------------------------------- +# PocketBase initial superuser credentials (bootstrapped on first start) +# --------------------------------------------------------------------------- pb_admin_email: admin@hopsngrains.com pb_admin_password: HopsPassword1234! diff --git a/deploy.sh b/deploy.sh index 9c70ad1..e49603f 100755 --- a/deploy.sh +++ b/deploy.sh @@ -1,19 +1,72 @@ #!/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 set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")" -parent_path=$( cd "$(dirname "${BASH_SOURCE[0]}")" || exit ; pwd -P ) -cd "$parent_path" || exit +BRANCH="${BRANCH:-main}" + +FORCE=false +NO_PUSH=false +for arg in "$@"; do + case "$arg" in + --force) FORCE=true ;; + --no-push) NO_PUSH=true ;; + *) + echo "Usage: $0 [--force] [--no-push]" >&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 + 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 + +TAG=$(git rev-parse --short HEAD) +echo "==> deploying commit $TAG" echo "========================================================" -echo " Building & Uploading Docker Image" +echo " Building & Uploading Container Image (Podman)" echo "========================================================" -current_version="${1:-latest}" -make build-production TAG="$current_version" - +make build-production COMMIT="$TAG" echo "" echo "========================================================" echo " Executing Ansible Deployment" echo "========================================================" -cd "$parent_path/ansible" || exit -ansible-playbook playbook.yml -e "tag=$current_version" +# 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" diff --git a/docker-compose.yml b/docker-compose.yml deleted file mode 100644 index 30e7169..0000000 --- a/docker-compose.yml +++ /dev/null @@ -1,21 +0,0 @@ -services: - hops: - build: - context: . - dockerfile: Dockerfile - image: hops-n-grains:latest - container_name: hops-app - restart: unless-stopped - ports: - - "3000:3000" - - "8090:8090" - environment: - - PORT=3000 - - PB_ADMIN_EMAIL=admin@hopsngrains.com - - PB_ADMIN_PASSWORD=HopsPassword1234! - volumes: - - pb_data:/app/pb_data - -volumes: - pb_data: - driver: local