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:
1 parent
7366596a87
commit
a7c9324170
14 files changed
+194
-357
No files matched your search
@@ -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
|
|
||||||
@@ -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
|
||||||
+5
-22
@@ -1,8 +1,6 @@
|
|||||||
# syntax=docker/dockerfile:1
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# Frontend dependencies. Runs on the build platform.
|
||||||
# Stage 1: Install frontend dependencies (Native execution on host architecture)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps
|
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS deps
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -10,9 +8,7 @@ COPY frontend/package.json frontend/package-lock.json* ./
|
|||||||
RUN --mount=type=cache,target=/root/.npm \
|
RUN --mount=type=cache,target=/root/.npm \
|
||||||
npm ci
|
npm ci
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# Frontend build. Runs on the build platform.
|
||||||
# Stage 2: Build the TanStack Start frontend (Native execution on host architecture)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder
|
FROM --platform=$BUILDPLATFORM node:lts-trixie-slim AS builder
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
|
|
||||||
@@ -22,9 +18,7 @@ COPY frontend/ ./
|
|||||||
ENV NODE_ENV=production
|
ENV NODE_ENV=production
|
||||||
RUN npm run build
|
RUN npm run build
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# PocketBase binary for the target architecture. Runs on the build platform.
|
||||||
# Stage 3: Fetch PocketBase binary for target architecture (Native host downloader)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader
|
FROM --platform=$BUILDPLATFORM alpine:3.20 AS pb-downloader
|
||||||
ARG TARGETARCH
|
ARG TARGETARCH
|
||||||
ARG PB_VERSION=0.40.2
|
ARG PB_VERSION=0.40.2
|
||||||
@@ -41,9 +35,7 @@ RUN apk add --no-cache curl unzip ca-certificates \
|
|||||||
&& chmod +x /app/pocketbase \
|
&& chmod +x /app/pocketbase \
|
||||||
&& rm -f /tmp/pb.zip
|
&& rm -f /tmp/pb.zip
|
||||||
|
|
||||||
# ------------------------------------------------------------------------------
|
# Runtime image. Runs on the target architecture.
|
||||||
# Stage 4: Production runner (Target architecture)
|
|
||||||
# ------------------------------------------------------------------------------
|
|
||||||
FROM node:lts-trixie-slim AS runner
|
FROM node:lts-trixie-slim AS runner
|
||||||
LABEL maintainer="Amritanshu <docker@tanshu.com>"
|
LABEL maintainer="Amritanshu <docker@tanshu.com>"
|
||||||
|
|
||||||
@@ -54,31 +46,22 @@ RUN apt-get update && apt-get install -y --no-install-recommends \
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy PocketBase binary and migrations
|
|
||||||
COPY --from=pb-downloader /app/pocketbase /app/pocketbase
|
COPY --from=pb-downloader /app/pocketbase /app/pocketbase
|
||||||
COPY backend/pb_migrations /app/pb_migrations
|
COPY backend/pb_migrations /app/pb_migrations
|
||||||
|
|
||||||
# Copy compiled frontend application
|
|
||||||
COPY --from=builder /app/frontend/.output /app/frontend/.output
|
COPY --from=builder /app/frontend/.output /app/frontend/.output
|
||||||
|
|
||||||
# Copy entrypoint script
|
|
||||||
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
COPY docker-entrypoint.sh /app/docker-entrypoint.sh
|
||||||
RUN chmod +x /app/docker-entrypoint.sh
|
RUN chmod +x /app/docker-entrypoint.sh
|
||||||
|
|
||||||
# Environment variables
|
|
||||||
ENV NODE_ENV=production \
|
ENV NODE_ENV=production \
|
||||||
PORT=3000 \
|
PORT=3000 \
|
||||||
HOST=0.0.0.0 \
|
HOST=0.0.0.0 \
|
||||||
PB_DATA_DIR=/app/pb_data
|
PB_DATA_DIR=/app/pb_data
|
||||||
|
|
||||||
# Persistent data volume for PocketBase SQLite database and file storage
|
|
||||||
VOLUME ["/app/pb_data"]
|
VOLUME ["/app/pb_data"]
|
||||||
|
|
||||||
# Port 3000: TanStack Start Web Application
|
|
||||||
# Port 8090: PocketBase REST API & Admin UI
|
|
||||||
EXPOSE 3000 8090
|
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 \
|
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
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
COMMIT ?= $(shell git rev-parse --short HEAD)
|
||||||
|
|
||||||
# The deploy host (emily) is aarch64, so that is the default target: the image
|
# emily is aarch64, so that is the default target. A foreign platform needs
|
||||||
# is built for the machine it runs on, not the one running make. Podman carries
|
# the host's binfmt handlers, because podman has no built-in QEMU.
|
||||||
# no built-in QEMU (unlike docker buildx), so a foreign platform needs binfmt
|
PLATFORMS ?= linux/arm64
|
||||||
# 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
|
IMAGE ?= registry.tanshu.com/hops
|
||||||
|
|
||||||
.PHONY: build-production
|
.PHONY: build-production
|
||||||
build-production: ## Build HEAD with podman, tag :$(COMMIT) + :latest, and push
|
build-production: ## Build HEAD with podman, push :$(COMMIT) and :main
|
||||||
@echo "==> podman build --platform $(PLATFORM) → $(IMAGE):$(COMMIT) + :latest"
|
@echo "==> podman build --platform $(PLATFORMS) -> $(IMAGE):$(COMMIT) + :main"
|
||||||
@git archive --format=tar HEAD | podman build \
|
@git archive --format=tar HEAD | podman build \
|
||||||
--platform $(PLATFORM) \
|
--platform $(PLATFORMS) \
|
||||||
--tag $(IMAGE):$(COMMIT) \
|
--tag $(IMAGE):$(COMMIT) \
|
||||||
--tag $(IMAGE):latest \
|
--tag $(IMAGE):main \
|
||||||
--pull=always \
|
|
||||||
- || { \
|
- || { \
|
||||||
echo "✗ podman build failed."; \
|
echo "podman build failed."; \
|
||||||
echo " Cross-platform builds need binfmt on this host (podman has no built-in QEMU):"; \
|
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 " sudo apt install qemu-user-static # on an x86_64 host, for $(PLATFORMS)"; \
|
||||||
exit 1; \
|
exit 1; \
|
||||||
}
|
}
|
||||||
podman push $(IMAGE):$(COMMIT)
|
podman push $(IMAGE):$(COMMIT)
|
||||||
podman push $(IMAGE):latest
|
podman push $(IMAGE):main
|
||||||
|
|
||||||
.PHONY: build-check
|
.PHONY: build-check
|
||||||
build-check: ## Compile check from HEAD with podman, nothing pushed
|
build-check: ## Compile check from HEAD with podman, nothing pushed
|
||||||
@echo "==> podman build --platform $(PLATFORM) (no push)"
|
@git archive --format=tar HEAD | podman build \
|
||||||
git archive --format=tar HEAD | podman build \
|
--platform $(PLATFORMS) \
|
||||||
--platform $(PLATFORM) \
|
--tag hops:check \
|
||||||
--tag localhost/hops:check \
|
--pull \
|
||||||
--pull=always \
|
|
||||||
-
|
-
|
||||||
@@ -4,7 +4,7 @@ A modern, full-stack craft microbrewery web application built with **TanStack St
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Architecture Overview
|
## Architecture overview
|
||||||
|
|
||||||
```
|
```
|
||||||
hops/
|
hops/
|
||||||
@@ -36,7 +36,7 @@ hops/
|
|||||||
│ └── package.json
|
│ └── package.json
|
||||||
├── ansible/ # Deployment playbook (roles: register, hops, caddy)
|
├── ansible/ # Deployment playbook (roles: register, hops, caddy)
|
||||||
├── Dockerfile # Multi-stage production container build
|
├── Dockerfile # Multi-stage production container build
|
||||||
├── .containerignore # Build-context excludes (Podman/Buildah)
|
├── .dockerignore # Build-context excludes (podman and Docker)
|
||||||
├── .gitignore # Git repository ignore rules
|
├── .gitignore # Git repository ignore rules
|
||||||
├── docker-entrypoint.sh # Process supervisor running PB & Nitro
|
├── docker-entrypoint.sh # Process supervisor running PB & Nitro
|
||||||
├── Makefile # Podman build targets (build-production / build-check)
|
├── 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
|
```bash
|
||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
@@ -79,32 +79,36 @@ npm run dev:frontend
|
|||||||
|
|
||||||
## Deploying
|
## Deploying
|
||||||
|
|
||||||
The image is built with **Podman/Buildah** (not Docker) from the committed tree
|
`deploy.sh` pushes the current branch, refuses a dirty tracked tree, and
|
||||||
(`git archive HEAD`) and tagged with its **commit id**, so what runs on the host
|
requires `HEAD == origin/main`. It builds that commit with podman and runs the
|
||||||
is exactly what git knows about. `deploy.sh` pushes the commit first, refuses a
|
Ansible play on emily. Log in to the registry once per machine:
|
||||||
dirty tracked tree and requires `HEAD == origin/main`. Log in to the registry
|
`podman login registry.tanshu.com`.
|
||||||
once per machine: `podman login registry.tanshu.com`.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./deploy.sh # push, build <commit>, deploy it to emily
|
./deploy.sh # deploy HEAD
|
||||||
./deploy.sh --no-push # skip the push (you already pushed)
|
./deploy.sh --prune # also drop old images on emily, keep 3
|
||||||
./deploy.sh --force # skip the guards
|
./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
|
```bash
|
||||||
make build-production # podman build HEAD, push :<commit> + :latest
|
make build-production # podman build HEAD, push :<commit> and :main
|
||||||
make build-check # compile check from HEAD, no push
|
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.
|
The image builds for `linux/arm64` by default because emily is aarch64. Podman
|
||||||
Podman has no built-in QEMU, so cross-building from an x86_64 controller needs
|
has no built-in QEMU, so cross-building from an x86_64 controller needs binfmt
|
||||||
binfmt there (`qemu-user-binfmt`); override with `PLATFORM=linux/amd64`.
|
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
|
```bash
|
||||||
podman build -t localhost/hops:dev .
|
podman build -t localhost/hops:dev .
|
||||||
@@ -115,55 +119,51 @@ podman run --rm -p 3000:3000 -p 8090:8090 \
|
|||||||
|
|
||||||
### Ansible deployment
|
### Ansible deployment
|
||||||
|
|
||||||
Requirements on the controller: `ansible` (no collections — the play uses
|
The controller needs `ansible`. The target host needs the shared Caddy rootless
|
||||||
built-in modules plus the `podman` / `systemctl --user` CLI). The target host
|
Quadlet setup from the `ansible-playbooks` repo. That playbook owns the Caddy
|
||||||
must already run the fleet's shared Caddy **rootless Quadlet** setup: the
|
unit, the rootless prerequisites, and the Caddyfile this play appends to.
|
||||||
`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`),
|
The play targets `emily`, connects as the unprivileged container user, and does
|
||||||
connects as the unprivileged container user and **does not `become`**: the
|
not use `become`. The Quadlet units belong to that user, and the SSH session's
|
||||||
Quadlet units belong to that user, and the SSH connection's PAM session is what
|
PAM context makes `systemctl --user` work.
|
||||||
makes `systemctl --user` work.
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd ansible
|
cd ansible
|
||||||
ansible-playbook -u tanshu playbook.yml -e "image_tag=<commit>"
|
ansible-playbook playbook.yml -e "image_tag=<commit>"
|
||||||
```
|
```
|
||||||
|
|
||||||
All deployment variables live in `ansible/vars/default.yml` (registry, image
|
Skip the Caddyfile update until DNS points at emily. Otherwise Caddy retries
|
||||||
tag, domains, PocketBase admin credentials). The play:
|
certificate issuance for a name that still resolves to monoco:
|
||||||
|
|
||||||
1. Writes `~/.config/containers/systemd/hops.network`
|
```bash
|
||||||
(`NetworkName=hops_net`) and a drop-in that attaches the shared Caddy unit to
|
ansible-playbook playbook.yml -e "image_tag=<commit>" --skip-tags caddy
|
||||||
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,
|
Deployment variables live in `ansible/vars/default.yml`. The play:
|
||||||
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`.
|
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
|
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
|
`UserNS=keep-id:uid=0,gid=0`. Files under `~/podman-data/hops` stay owned by
|
||||||
PocketBase's files need no `podman unshare` to inspect or back up.
|
`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
|
DNS for all three hosts must point at emily before Caddy can issue certificates.
|
||||||
certificates automatically on first request. This deploy starts from empty
|
The first deploy starts with empty data. PocketBase applies the schema migration
|
||||||
data — the schema migration in `backend/pb_migrations/` is applied on first
|
in `backend/pb_migrations/` on first boot and recreates the seed beers and food.
|
||||||
start, so the seed beers and food are recreated automatically.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Features & Pages
|
## Features and pages
|
||||||
|
|
||||||
| Route | Name | Highlights |
|
| Route | Name | Highlights |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
+7
-23
@@ -1,18 +1,7 @@
|
|||||||
---
|
---
|
||||||
# Hops n Grains — rootless Podman + Quadlet, behind the fleet's shared Caddy
|
# Hops n Grains as a rootless Podman Quadlet on emily, behind the shared Caddy.
|
||||||
# unit (caddy/playbook.yml in the ansible-playbooks repo).
|
# Run: ansible-playbook playbook.yml -e image_tag=<commit>
|
||||||
#
|
# Use --skip-tags caddy until DNS for hopsngrains.com points at emily.
|
||||||
# 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
|
- name: Deploy Hops n Grains as a rootless Podman Quadlet
|
||||||
hosts: emily
|
hosts: emily
|
||||||
gather_facts: true
|
gather_facts: true
|
||||||
@@ -20,17 +9,12 @@
|
|||||||
- vars/default.yml
|
- vars/default.yml
|
||||||
|
|
||||||
pre_tasks:
|
pre_tasks:
|
||||||
# This must happen before the app unit is started, not after: the generated
|
# Quadlet fails unit generation when hops.network is missing, so write the
|
||||||
# hops.container references {{ network_name }}.network, and Quadlet fails
|
# network and the Caddy drop-in before the app unit starts.
|
||||||
# *unit generation* (so `systemctl --user start` reports "unit not found")
|
- name: Register the Hops n Grains network with Caddy
|
||||||
# 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"
|
ansible.builtin.include_tasks: "{{ playbook_dir }}/roles/register/tasks/main.yaml"
|
||||||
|
|
||||||
roles:
|
roles:
|
||||||
# The app: data directory, .env, the Quadlet unit, start + healthcheck.
|
|
||||||
- hops
|
- 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
|
- caddy
|
||||||
@@ -1,27 +1,13 @@
|
|||||||
---
|
---
|
||||||
# Caddy is its own playbook (caddy/playbook.yml in the ansible-playbooks repo)
|
# A reload keeps existing connections. A restart would drop every hosted site.
|
||||||
# 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
|
- name: Reload Caddy configuration
|
||||||
ansible.builtin.command: >-
|
ansible.builtin.command: >-
|
||||||
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
podman exec {{ caddy_name }} caddy reload --config /etc/caddy/Caddyfile
|
||||||
changed_when: true
|
changed_when: true
|
||||||
listen: "Reload Caddy"
|
listen: "Reload Caddy"
|
||||||
|
|
||||||
# Notified by roles/register when this service's network unit, or the drop-in
|
# Notified when the network drop-in changes. Quadlet cannot add a network to a
|
||||||
# that attaches Caddy to it, is written. Quadlet has no live equivalent of
|
# running container, so Caddy must be recreated.
|
||||||
# `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
|
- name: Restart Caddy
|
||||||
ansible.builtin.systemd_service:
|
ansible.builtin.systemd_service:
|
||||||
name: "{{ caddy_name }}.service"
|
name: "{{ caddy_name }}.service"
|
||||||
|
|||||||
@@ -1,16 +1,9 @@
|
|||||||
---
|
---
|
||||||
# Append Hops n Grains' site blocks to the shared Caddyfile.
|
# Append the site blocks to the shared Caddyfile. The file is bind-mounted into
|
||||||
#
|
# Caddy, so the reload handler is enough. blockinfile is idempotent.
|
||||||
# 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: "Add the {{ host }} site blocks to the Caddyfile"
|
- name: "Add the {{ host }} site blocks to the Caddyfile"
|
||||||
|
tags: [caddy]
|
||||||
ansible.builtin.blockinfile:
|
ansible.builtin.blockinfile:
|
||||||
path: "{{ caddyfile_path }}"
|
path: "{{ caddyfile_path }}"
|
||||||
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
|
marker: "# {mark} Ansible managed Caddy snippet for {{ host }}"
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
---
|
---
|
||||||
# All handlers run as the connection user ({{ podman_user }}), so
|
# Handlers run as the connection user, so systemctl --user works as is.
|
||||||
# `systemctl --user` works without setting XDG_RUNTIME_DIR.
|
|
||||||
|
|
||||||
- name: Reload systemd user units
|
- name: Reload systemd user units
|
||||||
ansible.builtin.command: systemctl --user daemon-reload
|
ansible.builtin.command: systemctl --user daemon-reload
|
||||||
|
|||||||
@@ -1,14 +1,7 @@
|
|||||||
---
|
---
|
||||||
# Hops n Grains as a rootless Podman Quadlet unit.
|
# 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.
|
||||||
# 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.
|
|
||||||
|
|
||||||
# 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
|
- name: Log in to the private registry
|
||||||
ansible.builtin.command:
|
ansible.builtin.command:
|
||||||
cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}"
|
cmd: "podman login --username {{ registry_username }} --password-stdin {{ registry }}"
|
||||||
@@ -19,12 +12,19 @@
|
|||||||
- registry_username | length > 0
|
- registry_username | length > 0
|
||||||
- registry_password | length > 0
|
- registry_password | length > 0
|
||||||
|
|
||||||
# Pulled here rather than left to the unit: Quadlet only pulls when the image is
|
# Pull here. Quadlet only pulls a missing image, and the unit has no credential.
|
||||||
# missing, and at that point the service has no registry credential to offer.
|
|
||||||
- name: Pull the Hops n Grains image
|
- name: Pull the Hops n Grains image
|
||||||
ansible.builtin.command: "podman pull {{ image }}"
|
ansible.builtin.command: "podman pull {{ image }}"
|
||||||
changed_when: false
|
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
|
- name: Ensure the PocketBase data directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
path: "{{ host_directory }}/pb_data"
|
path: "{{ host_directory }}/pb_data"
|
||||||
@@ -33,9 +33,7 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0755'
|
mode: '0755'
|
||||||
|
|
||||||
# Secrets (the PocketBase superuser) and the run-time configuration are read by
|
# systemd reads this file through EnvironmentFile. It holds the admin password.
|
||||||
# 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
|
- name: Write the environment file
|
||||||
ansible.builtin.template:
|
ansible.builtin.template:
|
||||||
src: hops.env.j2
|
src: hops.env.j2
|
||||||
@@ -65,10 +63,8 @@
|
|||||||
state: started
|
state: started
|
||||||
daemon_reload: true
|
daemon_reload: true
|
||||||
rescue:
|
rescue:
|
||||||
# systemd reports "control process exited with error code" and nothing else,
|
# systemd reports only the exit code. Collect the real reason.
|
||||||
# which is useless for the usual failures (a missing image, an unattached
|
- name: Collect diagnostics
|
||||||
# network, an unwritable data directory). Pull the real reason out.
|
|
||||||
- name: Collect Hops n Grains diagnostics
|
|
||||||
ansible.builtin.command: "{{ item }}"
|
ansible.builtin.command: "{{ item }}"
|
||||||
loop:
|
loop:
|
||||||
- "systemctl --user status {{ unit_name }} --no-pager"
|
- "systemctl --user status {{ unit_name }} --no-pager"
|
||||||
@@ -78,36 +74,26 @@
|
|||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: false
|
failed_when: false
|
||||||
|
|
||||||
- name: Show Hops n Grains diagnostics
|
- name: Show diagnostics
|
||||||
ansible.builtin.debug:
|
ansible.builtin.debug:
|
||||||
msg: "{{ hops_diag.results | map(attribute='stdout') | list }}"
|
msg: "{{ hops_diag.results | map(attribute='stdout') | list }}"
|
||||||
|
|
||||||
- name: Fail
|
- name: Fail
|
||||||
ansible.builtin.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
|
# Handlers run at the end of the play. Flush them so the healthcheck gate
|
||||||
# at the very end of the play — after the healthcheck gate below. Flush them
|
# tests the container with the current unit.
|
||||||
# here so the gate tests the container that has the current HealthCmd; without
|
|
||||||
# this, a run that only changes the unit would check the stale container and
|
|
||||||
# fail. On a run where nothing changed, no handler is pending and this is a
|
|
||||||
# no-op.
|
|
||||||
- name: Apply pending unit changes before verifying
|
- name: Apply pending unit changes before verifying
|
||||||
ansible.builtin.meta: flush_handlers
|
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
|
- name: Verify the Hops n Grains unit is active
|
||||||
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
ansible.builtin.command: "systemctl --user is-active {{ unit_name }}"
|
||||||
register: hops_active
|
register: hops_active
|
||||||
changed_when: false
|
changed_when: false
|
||||||
failed_when: hops_active.stdout != 'active'
|
failed_when: hops_active.stdout != 'active'
|
||||||
|
|
||||||
# `is-active` says the unit is running, not that both processes inside the
|
# is-active does not prove both processes are healthy. Force a healthcheck.
|
||||||
# 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
|
- name: Wait for the container healthcheck to pass
|
||||||
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
ansible.builtin.command: "podman healthcheck run {{ container_name }}"
|
||||||
register: hops_health
|
register: hops_health
|
||||||
@@ -131,8 +117,7 @@
|
|||||||
{{ container_name }} is on
|
{{ container_name }} is on
|
||||||
{{ hops_networks_now.stdout | default('(container not running)') }}
|
{{ hops_networks_now.stdout | default('(container not running)') }}
|
||||||
|
|
||||||
# Removes the credential from the user's auth file. The image is already local
|
# The image is local and the unit only pulls a missing image.
|
||||||
# and the unit only pulls when it is missing, so this does not break restarts.
|
|
||||||
- name: Log out of the private registry
|
- name: Log out of the private registry
|
||||||
ansible.builtin.command: "podman logout {{ registry }}"
|
ansible.builtin.command: "podman logout {{ registry }}"
|
||||||
changed_when: false
|
changed_when: false
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
# Hops n Grains — rootless Podman Quadlet unit.
|
# Hops n Grains rootless Quadlet unit. Managed by Ansible.
|
||||||
# Managed by Ansible (roles/hops). Do not edit by hand.
|
# Joins {{ network_name }}.network so Caddy resolves {{ container_name }}.
|
||||||
#
|
|
||||||
# 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]
|
[Unit]
|
||||||
Description=Hops n Grains microbrewery (PocketBase + TanStack Start)
|
Description=Hops n Grains microbrewery (PocketBase + TanStack Start)
|
||||||
@@ -14,27 +10,21 @@ Wants=network-online.target
|
|||||||
ContainerName={{ container_name }}
|
ContainerName={{ container_name }}
|
||||||
Image={{ image }}
|
Image={{ image }}
|
||||||
|
|
||||||
# The image runs as root: docker-entrypoint.sh starts PocketBase, bootstraps the
|
# The image runs as root. keep-id maps that onto {{ podman_user }}, so
|
||||||
# superuser and then starts the site server, and the PocketBase data files it
|
# {{ host_directory }} stays owned by the host user.
|
||||||
# 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
|
UserNS=keep-id:uid=0,gid=0
|
||||||
|
|
||||||
# NODE_ENV, PORT, PB_URL and the PocketBase superuser live here. Read by
|
# systemd reads the env file. It must exist before the unit starts.
|
||||||
# systemd, not by Ansible, so the file has to exist before this unit starts.
|
|
||||||
EnvironmentFile={{ host_directory }}/.env
|
EnvironmentFile={{ host_directory }}/.env
|
||||||
|
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
|
|
||||||
Volume={{ host_directory }}/pb_data:/app/pb_data
|
Volume={{ host_directory }}/pb_data:/app/pb_data
|
||||||
|
|
||||||
# No PublishPort on purpose: the Docker-era deployment had none either, and
|
# No PublishPort. Caddy reaches both ports over {{ network_name }}_net.
|
||||||
# Caddy reaches both ports over {{ network_name }}_net.
|
|
||||||
|
|
||||||
# podman builds an OCI image by default and drops the Dockerfile's HEALTHCHECK
|
# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
|
||||||
# instruction, so the same check is declared here and the play gates on it with
|
# so declare the check here.
|
||||||
# `podman healthcheck run`. curl is in the runner image.
|
|
||||||
HealthCmd={{ health_cmd }}
|
HealthCmd={{ health_cmd }}
|
||||||
HealthInterval=15s
|
HealthInterval=15s
|
||||||
HealthTimeout=3s
|
HealthTimeout=3s
|
||||||
@@ -43,8 +33,7 @@ HealthStartPeriod=5s
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Restart=always
|
Restart=always
|
||||||
# The entrypoint waits for PocketBase and upserts the superuser before the site
|
# The entrypoint waits for PocketBase and the superuser before the site answers.
|
||||||
# server answers, so allow generously for a large database.
|
|
||||||
TimeoutStartSec=300
|
TimeoutStartSec=300
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
|
|||||||
@@ -1,18 +1,6 @@
|
|||||||
---
|
---
|
||||||
# Register Hops n Grains' network with the shared Caddy unit.
|
# Write hops.network and the Caddy drop-in that joins Caddy to it.
|
||||||
#
|
# Run from pre_tasks, before the app unit is generated.
|
||||||
# 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
|
- name: Ensure the Quadlet directory exists
|
||||||
ansible.builtin.file:
|
ansible.builtin.file:
|
||||||
@@ -29,15 +17,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# {{ network_name }} — logical app network.
|
# {{ network_name }} network unit. Managed by Ansible.
|
||||||
# Managed by Ansible. Do not edit by hand.
|
# NetworkName pins the Podman name to {{ network_name }}_net.
|
||||||
#
|
|
||||||
# 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]
|
[Network]
|
||||||
NetworkName={{ network_name }}_net
|
NetworkName={{ network_name }}_net
|
||||||
Options=isolate=true
|
Options=isolate=true
|
||||||
@@ -58,10 +39,8 @@
|
|||||||
group: "{{ podman_user }}"
|
group: "{{ podman_user }}"
|
||||||
mode: '0644'
|
mode: '0644'
|
||||||
content: |
|
content: |
|
||||||
# Added by Hops n Grains' playbook. Do not edit by hand.
|
# Added by the Hops n Grains playbook. Managed by Ansible.
|
||||||
#
|
# Quadlet merges this into caddy.container.
|
||||||
# Quadlet merges this into caddy.container. Deleting this file removes
|
|
||||||
# the network from Caddy on the next daemon-reload.
|
|
||||||
[Container]
|
[Container]
|
||||||
Network={{ network_name }}.network
|
Network={{ network_name }}.network
|
||||||
notify: Restart Caddy
|
notify: Restart Caddy
|
||||||
+12
-34
@@ -1,67 +1,47 @@
|
|||||||
---
|
---
|
||||||
# ---------------------------------------------------------------------------
|
# Rootless Podman and Quadlet on emily. The play runs as the connecting user,
|
||||||
# Rootless Podman / Quadlet
|
# so these paths follow that user's home.
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# 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_user: "{{ ansible_facts['user_id'] }}"
|
||||||
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
podman_data_dir: "{{ ansible_facts['user_dir'] }}/podman-data"
|
||||||
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
quadlet_dir: "{{ ansible_facts['user_dir'] }}/.config/containers/systemd"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Image
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
registry: registry.tanshu.com
|
registry: registry.tanshu.com
|
||||||
registry_username: ta-registry
|
registry_username: ta-registry
|
||||||
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
|
registry_password: ff28a01f00c0f39315d94cd9dcb1e554968dba25676a8ea5f2be34e96a9a099f
|
||||||
|
|
||||||
image_name: "{{ registry }}/hops"
|
image_name: "{{ registry }}/hops"
|
||||||
tag: latest
|
# deploy.sh passes image_tag=<commit>. Rollback uses an older commit.
|
||||||
# deploy.sh passes -e image_tag=<commit>; `tag` is the fallback when running the
|
tag: main
|
||||||
# play by hand. Rollback = -e image_tag=<older-commit>.
|
|
||||||
image: "{{ image_name }}:{{ image_tag | default(tag) }}"
|
image: "{{ image_name }}:{{ image_tag | default(tag) }}"
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Hops n Grains
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
app_name: hops
|
app_name: hops
|
||||||
container_name: "{{ app_name }}"
|
container_name: "{{ app_name }}"
|
||||||
unit_name: "{{ container_name }}.service"
|
unit_name: "{{ container_name }}.service"
|
||||||
|
|
||||||
# The Quadlet file is <network_name>.network; roles/register pins the Podman
|
# The register role pins NetworkName to hops_net.
|
||||||
# network name to <network_name>_net.
|
|
||||||
network_name: "{{ app_name }}"
|
network_name: "{{ app_name }}"
|
||||||
|
|
||||||
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
host_directory: "{{ podman_data_dir }}/{{ container_name }}"
|
||||||
|
|
||||||
# Ports inside the container. Caddy proxies to them over hops_net; nothing is
|
# Caddy reaches both ports over hops_net. Nothing is published on the host.
|
||||||
# published on the host.
|
|
||||||
site_port: 3000
|
site_port: 3000
|
||||||
pb_port: 8090
|
pb_port: 8090
|
||||||
|
|
||||||
# The Dockerfile's HEALTHCHECK is dropped when podman builds an OCI image (its
|
# podman builds OCI images by default and drops the Dockerfile HEALTHCHECK,
|
||||||
# default), so the same check is declared on the Quadlet unit and the play gates
|
# so the unit declares the check and the play gates on it.
|
||||||
# on `podman healthcheck run`. curl is in the runner image.
|
|
||||||
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"
|
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
|
host: www.hopsngrains.com
|
||||||
apex_host: hopsngrains.com
|
apex_host: hopsngrains.com
|
||||||
admin_host: admin.hopsngrains.com
|
admin_host: admin.hopsngrains.com
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
# Shared Caddy unit (caddy/playbook.yml in the ansible-playbooks repo)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
caddy_name: caddy
|
caddy_name: caddy
|
||||||
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
caddyfile_path: "{{ podman_data_dir }}/{{ caddy_name }}/conf/Caddyfile"
|
||||||
|
|
||||||
# Appended to the shared Caddyfile by roles/caddy. The sites are served from
|
# Appended to the shared Caddyfile by roles/caddy.
|
||||||
# the container name over hops_net; no host ports are involved.
|
|
||||||
snippet_block: |
|
snippet_block: |
|
||||||
{{ host }}{% if apex_host is defined and apex_host %}, {{ apex_host }}{% endif %} {
|
{{ 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 /_ /_/
|
redir /_ /_/
|
||||||
handle /_/* {
|
handle /_/* {
|
||||||
reverse_proxy {{ container_name }}:{{ pb_port }}
|
reverse_proxy {{ container_name }}:{{ pb_port }}
|
||||||
@@ -70,7 +50,7 @@ snippet_block: |
|
|||||||
reverse_proxy {{ container_name }}:{{ pb_port }}
|
reverse_proxy {{ container_name }}:{{ pb_port }}
|
||||||
}
|
}
|
||||||
|
|
||||||
# TanStack Start frontend application
|
# TanStack Start frontend
|
||||||
handle {
|
handle {
|
||||||
reverse_proxy {{ container_name }}:{{ site_port }}
|
reverse_proxy {{ container_name }}:{{ site_port }}
|
||||||
}
|
}
|
||||||
@@ -82,8 +62,6 @@ snippet_block: |
|
|||||||
}
|
}
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# Bootstrapped on first start.
|
||||||
# PocketBase initial superuser credentials (bootstrapped on first start)
|
|
||||||
# ---------------------------------------------------------------------------
|
|
||||||
pb_admin_email: admin@hopsngrains.com
|
pb_admin_email: admin@hopsngrains.com
|
||||||
pb_admin_password: HopsPassword1234!
|
pb_admin_password: HopsPassword1234!
|
||||||
@@ -1,72 +1,47 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
#
|
# Deploy HEAD with podman and ansible on emily. Flags: --prune keeps three old
|
||||||
# Hops n Grains deploy — push the current commit, build it with podman, and
|
# images, --force skips the guards. The tree must be clean and HEAD pushed.
|
||||||
# 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
|
set -euo pipefail
|
||||||
cd "$(dirname "${BASH_SOURCE[0]}")"
|
cd "$(dirname "${BASH_SOURCE[0]}")"
|
||||||
|
|
||||||
BRANCH="${BRANCH:-main}"
|
git push
|
||||||
|
|
||||||
FORCE=false
|
FORCE=false
|
||||||
NO_PUSH=false
|
PRUNE=false
|
||||||
for arg in "$@"; do
|
for arg in "$@"; do
|
||||||
case "$arg" in
|
case "$arg" in
|
||||||
--force) FORCE=true ;;
|
--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
|
exit 1
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
done
|
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 [ "$FORCE" != true ]; then
|
||||||
if [ -n "$(git status --porcelain --untracked-files=no)" ]; 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
|
exit 1
|
||||||
fi
|
fi
|
||||||
git fetch origin --quiet
|
git fetch origin --quiet
|
||||||
if [ "$(git rev-parse HEAD)" != "$(git rev-parse "origin/$BRANCH")" ]; then
|
if [ "$(git rev-parse HEAD)" != "$(git rev-parse origin/main)" ]; then
|
||||||
echo "x HEAD != origin/$BRANCH - push or pull first (or --force)" >&2
|
echo "x HEAD != origin/main, push or pull first (or --force)" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
git pull --ff-only
|
||||||
TAG=$(git rev-parse --short HEAD)
|
TAG=$(git rev-parse --short HEAD)
|
||||||
echo "==> deploying commit $TAG"
|
echo "==> deploying commit $TAG"
|
||||||
|
|
||||||
echo "========================================================"
|
|
||||||
echo " Building & Uploading Container Image (Podman)"
|
|
||||||
echo "========================================================"
|
|
||||||
make build-production COMMIT="$TAG"
|
make build-production COMMIT="$TAG"
|
||||||
|
|
||||||
echo ""
|
if [ "$PRUNE" = true ]; then
|
||||||
echo "========================================================"
|
PRUNE_FLAG="-e hops_image_prune=true"
|
||||||
echo " Executing Ansible Deployment"
|
else
|
||||||
echo "========================================================"
|
PRUNE_FLAG=""
|
||||||
# The play configures rootless Podman Quadlet units that belong to the
|
fi
|
||||||
# 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.
|
cd ansible
|
||||||
deploy_user="${ANSIBLE_USER:-tanshu}"
|
# shellcheck disable=SC2086
|
||||||
cd ansible || exit
|
ansible-playbook playbook.yml -e "image_tag=$TAG" $PRUNE_FLAG
|
||||||
# image_tag pins the exact commit; rollback is -e image_tag=<older-commit>.
|
|
||||||
ansible-playbook -u "$deploy_user" playbook.yml -e "image_tag=$TAG"
|
|
||||||
Reference in new issue
Block a user