Model the build on mozimo and target emily

- Makefile: PLATFORMS, tags :<commit> and :main, build-check with --pull.
- deploy.sh: push, guards, git pull --ff-only, optional --prune, image_tag.
- Replace .containerignore with .dockerignore.
- Add the opt-in image prune to the hops role.
- Tag the Caddyfile task so --skip-tags caddy works before DNS cutover.
- Trim comments and rewrite build and deploy text in plain prose.
This commit is contained in:
tanshu committed 2026-10-20 17:47:49 +05:30
1 parent 7366596a87
commit a7c9324170
14 files changed
+194 -357

No files matched your search

+7 -23
View File
@@ -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=<commit>
# 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
+3 -17
View File
@@ -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"
+3 -10
View File
@@ -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 }}"
+1 -2
View File
@@ -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
+20 -35
View File
@@ -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
+9 -20
View File
@@ -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]
+6 -27
View File
@@ -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
+12 -34
View File
@@ -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=<commit>; `tag` is the fallback when running the
# play by hand. Rollback = -e image_tag=<older-commit>.
# deploy.sh passes image_tag=<commit>. 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_name>.network; roles/register pins the Podman
# network name to <network_name>_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!