#!/usr/bin/env bash # # booth.sh — operate the parking stack on the booth PC (Ubuntu). # # Wraps the three compose files (base + a dev/prod override) so the operator runs # one command instead of a long `docker compose -f … -f … --env-file …` line. # # ./scripts/booth.sh up # start the stack (detached) # ./scripts/booth.sh update # pull newer images + recreate (the "there are # # new images" case) — see `update` below # ./scripts/booth.sh down # stop the stack # ./scripts/booth.sh restart # restart without pulling # ./scripts/booth.sh status # what's running # ./scripts/booth.sh logs # follow logs (Ctrl-C to stop) # ./scripts/booth.sh ps|pull|config|exec … # # Environment is PROD by default (the booth runs prod: pull pinned registry images, # Caddy on :80, fast_alpr). Override with ENV=dev for a local build/dev run: # ENV=dev ./scripts/booth.sh up # # Config comes from an .env file next to the compose files (REGISTRY, TAG, # JWT_SECRET, …). Copy .env.example → .env and fill it in. See # wiki/decisions/container-deployment.md. set -euo pipefail # --- locate the repo (this script lives in /scripts) -------------------- SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" REPO_DIR="$(cd -- "$SCRIPT_DIR/.." && pwd)" cd "$REPO_DIR" # --- environment selection (prod by default; the booth is prod) --------------- ENV="${ENV:-prod}" case "$ENV" in prod|production) ENV=prod; OVERRIDE="docker-compose.prod.yml" ;; dev|development) ENV=dev; OVERRIDE="docker-compose.dev.yml" ;; *) echo "ERROR: ENV must be 'prod' or 'dev' (got '$ENV')." >&2; exit 2 ;; esac BASE="docker-compose.yml" ENV_FILE="${ENV_FILE:-.env}" # --- colours (only when attached to a terminal) ------------------------------- if [ -t 1 ]; then R="$(printf '\033[31m')"; G="$(printf '\033[32m')"; Y="$(printf '\033[33m')" B="$(printf '\033[1m')"; N="$(printf '\033[0m')" else R=""; G=""; Y=""; B=""; N="" fi info() { printf '%s==>%s %s\n' "$B" "$N" "$*"; } warn() { printf '%s!! %s%s\n' "$Y" "$*" "$N" >&2; } die() { printf '%sERROR:%s %s\n' "$R" "$N" "$*" >&2; exit 1; } usage() { sed -n '3,30p' "$0" | sed 's/^# \{0,1\}//' exit "${1:-0}" } # --- preflight (only for commands that actually talk to Docker) --------------- # Deferred into a function so `help`/usage works with no Docker and no .env. ENV_ARGS=() DC=() preflight() { command -v docker >/dev/null 2>&1 || die "docker is not installed or not on PATH." # Prefer the v2 plugin (`docker compose`); fall back to legacy `docker-compose`. if docker compose version >/dev/null 2>&1; then DC=(docker compose) elif command -v docker-compose >/dev/null 2>&1; then DC=(docker-compose) else die "Docker Compose v2 plugin not found ('docker compose'). Install docker-compose-plugin." fi [ -f "$BASE" ] || die "missing $BASE in $REPO_DIR" [ -f "$OVERRIDE" ] || die "missing $OVERRIDE in $REPO_DIR" # An .env is required for prod (JWT_SECRET et al. have no safe default); optional # for dev (we inject a benign local secret below). Pass --env-file only when it # exists so dev works without one. if [ -f "$ENV_FILE" ]; then ENV_ARGS=(--env-file "$ENV_FILE") elif [ "$ENV" = "prod" ]; then die "no $ENV_FILE found. Copy .env.example to $ENV_FILE and set JWT_SECRET/REGISTRY/TAG. (prod has no safe defaults.)" else # The BASE compose file makes JWT_SECRET shell-required (${JWT_SECRET:?}), which # the dev override's service-level default can't satisfy. For a dev run with no # .env, inject the same benign 32-char local secret the dev override documents so # `up`/`config` work out of the box. NEVER do this for prod (the die above). warn "no $ENV_FILE found — injecting the documented local-dev JWT_SECRET (dev only)." : "${JWT_SECRET:=localdevsecret0123456789abcdef0123}" export JWT_SECRET fi } # The assembled compose invocation every subcommand builds on (runs preflight once). compose() { "${DC[@]}" -f "$BASE" -f "$OVERRIDE" "${ENV_ARGS[@]}" "$@"; } # --- subcommands -------------------------------------------------------------- cmd="${1:-}"; [ "$#" -gt 0 ] && shift || true # Help/usage short-circuits before any Docker or .env requirement. case "$cmd" in ""|-h|--help|help) usage 0 ;; esac # Reject an unknown command up front (before preflight) so a typo gets a clear # "unknown command" rather than a confusing "no .env" from the prod env check. case "$cmd" in up|start|update|upgrade|down|stop|restart|pull|status|ps|logs|config|exec) ;; *) warn "unknown command: $cmd"; usage 1 ;; esac preflight case "$cmd" in up|start) info "Starting the parking stack ($B$ENV$N) …" compose up -d "$@" info "Up. ${G}$(compose ps --services 2>/dev/null | tr '\n' ' ')${N}" info "Booth UI: prod → http:/// · dev → http://:3000/" ;; update|upgrade) # The "I know there are new images" path: pull the moving branch tag, then # recreate only what changed. Compose recreates a service whose image digest # moved; unchanged services (and the named volumes — the SQLite DB!) are left # alone. Old image layers are pruned afterwards to reclaim disk. [ "$ENV" = "prod" ] || warn "update on ENV=$ENV: dev builds locally, so 'pull' may be a no-op. Use 'up --build' to rebuild dev." info "Pulling newer images for the ${B}$ENV_FILE${N} TAG …" compose pull info "Recreating changed services (volumes/DB preserved) …" compose up -d --remove-orphans info "Pruning dangling image layers …" docker image prune -f >/dev/null || true info "${G}Update complete.${N} Running:" compose ps ;; down|stop) info "Stopping the parking stack ($ENV) …" # NOTE: never pass -v here — that would delete the parking-data volume (the # signed event ledger). Volumes are intentionally preserved across down/up. compose down "$@" ;; restart) info "Restarting (no pull) …" compose restart "$@" ;; pull) info "Pulling images only (no recreate) …" compose pull "$@" ;; status|ps) compose ps "$@" ;; logs) # Follow by default; pass a service name to scope, e.g. `logs server`. compose logs -f --tail=200 "$@" ;; config) # Render the merged, variable-substituted compose config (debugging). compose config "$@" ;; exec) [ "$#" -ge 1 ] || die "usage: $0 exec [cmd…] (e.g. exec server sh)" compose exec "$@" ;; esac