vinterliste/deploy.sh
Ole-Morten Duesund d29e1fd3d5 feat(ops): deploy.sh — build, tag, replace, prune
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.
2026-05-25 21:39:44 +02:00

196 lines
6.7 KiB
Bash
Executable file

#!/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 <<EOF
Usage: $0 [command]
Commands:
deploy (default) build → run → prune
build Build and tag image only
run Run/restart container from :latest (no rebuild)
prune Drop old timestamped images
config Print resolved configuration and exit
Configuration is read from deploy.env if present. See README.md.
EOF
}
action_config() {
cat <<EOF
IMAGE_NAME=$IMAGE_NAME
CONTAINER_NAME=$CONTAINER_NAME
VOLUME_NAME=$VOLUME_NAME
HOST_PORT=$HOST_PORT
BIND_ADDR=$BIND_ADDR
KEEP_IMAGES=$KEEP_IMAGES
PUBLIC_BASE_URL=$PUBLIC_BASE_URL
EXTRA_PODMAN_RUN_ARGS=$EXTRA_PODMAN_RUN_ARGS
BUILD_DATE=$BUILD_DATE
TIMESTAMP_TAG=$TIMESTAMP_TAG
GIT_REVISION=$GIT_REVISION
EOF
}
# --- Dispatch -------------------------------------------------------------
cmd="${1:-deploy}"
case "$cmd" in
build) action_build ;;
run) action_run ;;
prune) action_prune ;;
config) action_config ;;
deploy) action_build; action_run; action_prune ;;
-h|--help|help) usage ;;
*) echo "Unknown command: $cmd" >&2; usage >&2; exit 2 ;;
esac