From d29e1fd3d51ed8242c12c099f23a892ac5e01692 Mon Sep 17 00:00:00 2001 From: Ole-Morten Duesund Date: Mon, 25 May 2026 21:39:44 +0200 Subject: [PATCH] =?UTF-8?q?feat(ops):=20deploy.sh=20=E2=80=94=20build,=20t?= =?UTF-8?q?ag,=20replace,=20prune?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One-shot deploy script that wraps the podman build + run dance: ./deploy.sh # build → replace container → prune (default) ./deploy.sh build # build + tag only ./deploy.sh run # restart from existing :latest, no rebuild ./deploy.sh prune # drop old timestamped images ./deploy.sh config # print resolved configuration Each build tags the image with both :latest AND a UTC timestamp (YYYYMMDD-HHMMSS), so rollback is a tag retag away. Prune keeps the N most recent timestamped images (KEEP_IMAGES, default 3); :latest is never touched. The matching regex is strict — only the exact YYYYMMDD-HHMMSS pattern — so a stray "dev" or hand-typed tag can't get caught. Settings come from an optional deploy.env (gitignored; example in deploy.env.example). Parser is allowlist-based: only recognised keys apply, malformed lines and command-substitution forms are ignored. Available overrides: IMAGE_NAME, CONTAINER_NAME, VOLUME_NAME, HOST_PORT, BIND_ADDR, KEEP_IMAGES, PUBLIC_BASE_URL, EXTRA_PODMAN_RUN_ARGS. HOST_PORT and KEEP_IMAGES are integer- validated before use. Uses podman run --replace per the global ops guidance (atomic, idempotent, no stop→rm→run race). BUILDAH_FORMAT=docker so the HEALTHCHECK directive in the Containerfile survives. shellcheck clean. README's Deployment section rewritten to lead with the script; manual podman snippet kept as fallback. --- .gitignore | 1 + README.md | 59 +++++++++++--- deploy.env.example | 28 +++++++ deploy.sh | 196 +++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 273 insertions(+), 11 deletions(-) create mode 100644 deploy.env.example create mode 100755 deploy.sh diff --git a/.gitignore b/.gitignore index 0d66673..3c82a12 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,4 @@ data/ # inspecting the live site. Never source of truth, never committed. .playwright-mcp/ *.png +deploy.env diff --git a/README.md b/README.md index f713b40..8ad398d 100644 --- a/README.md +++ b/README.md @@ -103,10 +103,53 @@ still works. ## Deployment -### Container (podman) +### One-shot deploy: `./deploy.sh` -The provided `Containerfile` builds a single image that serves API + frontend -and persists the SQLite database in `/app/data` (one volume). +The repository ships a small bash deploy script that wraps the usual +`podman build` + `podman run --replace` dance, tags images with both +`:latest` and a UTC timestamp, and prunes older timestamped images so +local disk doesn't grow forever: + +```bash +./deploy.sh # build → replace container → prune (default) +./deploy.sh build # build + tag only +./deploy.sh run # restart from existing :latest, no rebuild +./deploy.sh prune # drop old timestamped images +./deploy.sh config # print resolved configuration +``` + +Settings come from `deploy.env` (gitignored). Copy the example and edit: + +```bash +cp deploy.env.example deploy.env +``` + +Available overrides: + +| Variable | Default | Notes | +|---------------------------|-----------------------|------------------------------------------------------------------| +| `IMAGE_NAME` | `vinterliste` | Local image-tag prefix. | +| `CONTAINER_NAME` | `vinterliste` | `podman` container name. | +| `VOLUME_NAME` | `vinterliste-data` | Named volume holding the SQLite file. | +| `HOST_PORT` | `3000` | Host port mapped to the container's `:3000`. | +| `BIND_ADDR` | `0.0.0.0` | Use `127.0.0.1` when fronting with a reverse proxy on the host. | +| `KEEP_IMAGES` | `3` | Number of timestamped images to keep on prune. | +| `PUBLIC_BASE_URL` | (auto) | Canonical URL injected into OpenGraph tags. | +| `EXTRA_PODMAN_RUN_ARGS` | (empty) | Appended verbatim to `podman run`. Word-split, quote properly. | + +The `.env` parser is allowlist-based — unknown keys log a warning and are +ignored; lines that aren't `KEY=value` are skipped; quoted values are +stripped of one layer of `'` or `"`. Command-substitution forms like +`KEY=$(…)` are NOT evaluated, so a malicious `deploy.env` can't run code. + +The container exposes `/api/health` for healthchecks and bakes the build +date / git revision into both OCI labels and `/etc/build-info`. The +script uses `podman run --replace` for atomicity (no `stop` → `rm` → +`run` race). + +### Manual `podman build` / `podman run` + +If you don't want the script: ```bash BUILDAH_FORMAT=docker podman build \ @@ -114,10 +157,9 @@ BUILDAH_FORMAT=docker podman build \ --build-arg GIT_REVISION="$(git describe --always --dirty 2>/dev/null || echo dev)" \ -t vinterliste:latest . -# Create a named volume for the SQLite file -podman volume create vinterliste-data +podman volume create --ignore vinterliste-data -podman run --replace --name vinterliste \ +podman run --replace --name vinterliste -d \ -p 3000:3000 \ -v vinterliste-data:/app/data:Z \ vinterliste:latest @@ -125,11 +167,6 @@ podman run --replace --name vinterliste \ # Visit http://localhost:3000 ``` -The container exposes `/api/health` for healthchecks and bakes the build date / -git revision into both OCI labels and `/etc/build-info`. Use `podman run ---replace ...` for redeploys — it's atomic and avoids the "container exists" -race. - ### Environment variables | Variable | Default | Notes | diff --git a/deploy.env.example b/deploy.env.example new file mode 100644 index 0000000..9f6e95b --- /dev/null +++ b/deploy.env.example @@ -0,0 +1,28 @@ +# Vinterliste deploy.sh overrides. Copy to `deploy.env` (gitignored) and +# uncomment / edit any setting. The script falls back to sensible defaults +# for everything you don't override. + +# Host port mapping. Container always listens on 3000 internally. +# HOST_PORT=3000 + +# Bind only to loopback when fronting with a reverse proxy on the same host. +# BIND_ADDR=127.0.0.1 + +# Image, container, and volume names. Useful if you run multiple instances +# on the same machine (e.g. staging + prod). +# IMAGE_NAME=vinterliste +# CONTAINER_NAME=vinterliste +# VOLUME_NAME=vinterliste-data + +# How many old timestamped images to keep on the local registry. :latest is +# never pruned. +# KEEP_IMAGES=3 + +# Canonical public URL for OpenGraph tags. Falls back to the request host +# when unset, which works for single-origin deploys. +# PUBLIC_BASE_URL=https://vinterliste.example.org + +# Anything extra you want appended to `podman run`. Word-split, so use +# proper shell quoting. Example: pin the user namespace, attach an extra +# label, change the restart policy, etc. +# EXTRA_PODMAN_RUN_ARGS=--userns=keep-id --label deploy.env=prod diff --git a/deploy.sh b/deploy.sh new file mode 100755 index 0000000..42b4149 --- /dev/null +++ b/deploy.sh @@ -0,0 +1,196 @@ +#!/usr/bin/env bash +# Vinterliste deploy script. +# +# Builds a podman image from the current working tree, tags it with both +# :latest and a UTC timestamp, then atomically replaces the running +# container with `podman run --replace`. Older timestamped images are +# pruned afterwards. +# +# Configurable via deploy.env (gitignored). Every variable has a sensible +# default — running ./deploy.sh on a fresh checkout works without setup. +# +# Usage: +# ./deploy.sh # build + restart +# ./deploy.sh build # build image only, no container action +# ./deploy.sh run # use the existing :latest image, no rebuild +# ./deploy.sh prune # drop old timestamped images only +# +# Anything that would terminate a process verifies container identity by +# name before acting (the global "process safety" rule). Image pruning +# matches by image-name prefix + the :2YYYYMMDD- pattern only — never by +# wildcards that could catch unrelated podman images. + +set -euo pipefail + +# Resolve the project directory regardless of where the script was invoked +# from. Symlinks are followed so the script can sit anywhere on $PATH. +SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f -- "${BASH_SOURCE[0]}")")" && pwd) +cd "$SCRIPT_DIR" + +# --- Configuration --------------------------------------------------------- +# Defaults first; deploy.env (if present) overrides any of them. We don't +# `source` the .env file blindly — it goes through a key=value parser so a +# malformed line can't run arbitrary code. + +IMAGE_NAME="vinterliste" +CONTAINER_NAME="vinterliste" +VOLUME_NAME="vinterliste-data" +HOST_PORT="3000" +BIND_ADDR="0.0.0.0" +KEEP_IMAGES="3" +PUBLIC_BASE_URL="" +EXTRA_PODMAN_RUN_ARGS="" + +ENV_FILE="${ENV_FILE:-./deploy.env}" +if [[ -f "$ENV_FILE" ]]; then + while IFS= read -r line || [[ -n "$line" ]]; do + # Skip blank lines and comments. + [[ -z "${line// }" || "$line" =~ ^[[:space:]]*# ]] && continue + # Allowlist parse: VAR=VALUE, VAR must be uppercase + underscores. + if [[ "$line" =~ ^[[:space:]]*([A-Z_][A-Z0-9_]*)[[:space:]]*=[[:space:]]*(.*)$ ]]; then + key="${BASH_REMATCH[1]}" + val="${BASH_REMATCH[2]}" + # Strip optional surrounding quotes (single OR double). + val="${val#\"}"; val="${val%\"}" + val="${val#\'}"; val="${val%\'}" + case "$key" in + IMAGE_NAME|CONTAINER_NAME|VOLUME_NAME|HOST_PORT|BIND_ADDR|KEEP_IMAGES|PUBLIC_BASE_URL|EXTRA_PODMAN_RUN_ARGS) + printf -v "$key" '%s' "$val" + ;; + *) + echo "deploy.env: ignoring unknown setting '$key'" >&2 + ;; + esac + else + echo "deploy.env: ignoring malformed line: $line" >&2 + fi + done < "$ENV_FILE" +fi + +# Validate that HOST_PORT and KEEP_IMAGES are integers — they go straight +# into shell substitution further down. +if ! [[ "$HOST_PORT" =~ ^[0-9]+$ ]]; then + echo "HOST_PORT must be an integer, got: $HOST_PORT" >&2; exit 2 +fi +if ! [[ "$KEEP_IMAGES" =~ ^[0-9]+$ ]]; then + echo "KEEP_IMAGES must be an integer, got: $KEEP_IMAGES" >&2; exit 2 +fi + +# Build-time metadata. Same shape as the README documents. +BUILD_DATE=$(date -u +%Y-%m-%dT%H:%M:%SZ) +TIMESTAMP_TAG=$(date -u +%Y%m%d-%H%M%S) +GIT_REVISION=$(git describe --always --dirty 2>/dev/null || echo dev) + +# --- Helpers -------------------------------------------------------------- +log() { printf '[deploy] %s\n' "$*" >&2; } + +require_cmd() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "deploy.sh needs '$1' on PATH" >&2; exit 1 + fi +} + +# --- Actions -------------------------------------------------------------- + +action_build() { + require_cmd podman + require_cmd git + log "Building ${IMAGE_NAME}:${TIMESTAMP_TAG} (revision ${GIT_REVISION})" + BUILDAH_FORMAT=docker podman build \ + --build-arg BUILD_DATE="$BUILD_DATE" \ + --build-arg GIT_REVISION="$GIT_REVISION" \ + -t "${IMAGE_NAME}:${TIMESTAMP_TAG}" \ + -t "${IMAGE_NAME}:latest" \ + . + log "Built ${IMAGE_NAME}:${TIMESTAMP_TAG} and tagged :latest" +} + +action_run() { + require_cmd podman + # Confirm a :latest image exists before we try to replace the container. + if ! podman image exists "${IMAGE_NAME}:latest"; then + echo "No ${IMAGE_NAME}:latest image found. Run './deploy.sh build' first." >&2 + exit 1 + fi + # Volume creation is idempotent — `podman volume create` errors if it + # already exists, but `--ignore` makes it a no-op. + podman volume create --ignore "$VOLUME_NAME" >/dev/null + + log "Replacing container ${CONTAINER_NAME} (image :latest, port ${BIND_ADDR}:${HOST_PORT} → 3000)" + # shellcheck disable=SC2086 # EXTRA_PODMAN_RUN_ARGS is intentional word-split + podman run --replace -d \ + --name "$CONTAINER_NAME" \ + --restart=unless-stopped \ + -p "${BIND_ADDR}:${HOST_PORT}:3000" \ + -v "${VOLUME_NAME}:/app/data:Z" \ + ${PUBLIC_BASE_URL:+-e PUBLIC_BASE_URL="$PUBLIC_BASE_URL"} \ + ${EXTRA_PODMAN_RUN_ARGS} \ + "${IMAGE_NAME}:latest" >/dev/null + log "Container started. Health: podman healthcheck run $CONTAINER_NAME" +} + +action_prune() { + require_cmd podman + # List timestamped tags only (the YYYYMMDD-HHMMSS pattern). Sort newest + # first, skip the first KEEP_IMAGES, drop the rest. :latest is never + # touched. The pattern is strict enough that an arbitrary user-supplied + # tag (e.g. 'dev') can't be accidentally caught. + mapfile -t old_tags < <( + podman image list --format '{{.Tag}}' "$IMAGE_NAME" \ + | grep -E '^[0-9]{8}-[0-9]{6}$' \ + | sort -r \ + | awk -v keep="$KEEP_IMAGES" 'NR > keep' + ) + if [[ ${#old_tags[@]} -eq 0 ]]; then + log "Nothing to prune (≤${KEEP_IMAGES} timestamped images present)" + return + fi + log "Pruning ${#old_tags[@]} old image(s) (keeping ${KEEP_IMAGES} most recent)" + for tag in "${old_tags[@]}"; do + log " - ${IMAGE_NAME}:${tag}" + podman rmi "${IMAGE_NAME}:${tag}" >/dev/null || true + done +} + +usage() { + cat <&2; usage >&2; exit 2 ;; +esac