#!/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