#!/usr/bin/env bash # # deploy.sh — build and start the forge stack on the Synology NAS, streaming # every stage live and ending with a clear PASS/FAIL result. # # forge has NO database migration step: the schema self-bootstraps in initDB() # when the app boots; the app + bundled Postgres come up together. Build -> up # -> health, with no migrate one-shot to wait on (unlike the utility runner). # # What it does (4 stages, each with a banner you can watch): # [1/4] Pull — `git pull` (only with --pull, and only in a git repo) # [2/4] Build — ` build` (full streaming output, not quiet) # [3/4] Up — ` up -d --remove-orphans` # (--remove-orphans cleans up stale containers left under # project `forge`) # [4/4] Health — poll the app container until Docker reports `healthy` # (≤120s), tailing app logs; fail with # "APP DID NOT BECOME HEALTHY" on timeout # # Prerequisites: # - Docker + Compose available on the NAS. Prefers the v2 plugin # (`docker compose`, DSM 7.2+); falls back to `docker-compose`. # - `.env` present next to docker-compose.yml on the NAS (SESSION_SECRET + AUTH_USER + # AUTH_PASS + POSTGRES_PASSWORD). It is gitignored and NOT # baked into the image. # - Run from the repo root, or from anywhere — the script cd's to its own # parent directory (the repo root) before doing anything, so paths with # spaces and odd CWDs are fine. # # How to run: # ./scripts/deploy.sh # build + up + watch (no git pull) # ./scripts/deploy.sh --pull # git pull first, then build + up # ./scripts/deploy.sh --fresh # tear down stack first, then build + up # ./scripts/deploy.sh --help # show usage # # Or via DSM Task Scheduler as a user-defined script (runs head-less — # ANSI colour is auto-disabled when stdout is not a TTY): # /volume1/docker/forge/scripts/deploy.sh --pull \ # >> /volume1/docker/forge/logs/deploy.log 2>&1 # # Flags: # --pull Run `git pull` before building (skipped by default, since you # may deploy from a copied folder rather than a git checkout). # --fresh Tear the stack down (`down --remove-orphans`, NEVER `-v`) BEFORE # building, for a clean recreate. The bundled Postgres volume # (`forge-db`) is PRESERVED — `--fresh` never passes `-v`, so ticket # data survives. Parsed locally; never forwarded as a compose argument. # --help Print usage and exit 0. # # Ownership: # This script is the single source of truth for compose project `forge`. # `docker compose` here adopts/recreates the very same containers a # Container Manager GUI "Project" of that name would show — so you never # need the GUI to deploy. See SETUP.md ("Container Manager coexistence"). # # Safety: # - Idempotent: safe to re-run; `up -d` only recreates containers whose # config/image changed. # - No destructive ops: never runs `down -v`, volume/image prune, or # anything that could drop data. `--fresh` runs `down --remove-orphans` # (no `-v`) which only removes containers/networks; the forge-db volume is kept. # - No secrets echoed. # # Exit codes: # 0 deploy OK (app healthy) # 1 precondition / build / up failure, or health timeout # 2 usage error (bad flag) # set -euo pipefail # --- PATH hardening (Synology-aware) -------------------------------------- # Non-interactive SSH on DSM 7.2 ships a minimal PATH (/usr/bin:/bin:...) # that does NOT include docker. Container Manager installs docker / # docker-compose under these locations. Only prepend when docker is missing, # so this is a no-op on dev/CI hosts where docker is already on PATH. if ! command -v docker >/dev/null 2>&1; then export PATH="/usr/local/bin:/var/packages/ContainerManager/target/usr/bin:$PATH" fi # --- Resolve repo root (works regardless of CWD; tolerates spaces) -------- SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" cd "$REPO_ROOT" # --- Config --------------------------------------------------------------- APP_SERVICE="app" HEALTH_TIMEOUT_SECS=120 # max wait for app to become healthy HEALTH_POLL_SECS=3 # gap between health polls HOST_PORT_FALLBACK="3089" # used if we can't derive the published port # Prefix prepended to every docker / compose invocation. Empty on hosts where # the current user can talk to the docker daemon directly (dev, CI); set to # "sudo" on the Synology NAS, where docker requires root. The # ${DOCKER_SUDO:+sudo} array idiom expands to nothing (NOT an empty arg) when # DOCKER_SUDO is empty, so the no-sudo path is byte-for-byte unchanged. DOCKER_SUDO="" # --- Flags ---------------------------------------------------------------- # --pull and --fresh are parsed here and consumed locally; neither is ever # forwarded to compose. (push-to-nas.sh forwards extra args verbatim to this # script, so `npm run deploy -- --fresh` / `-- --pull` reach this loop.) DO_PULL=0 DO_FRESH=0 for arg in "$@"; do case "$arg" in --pull) DO_PULL=1 ;; --fresh) DO_FRESH=1 ;; --help|-h) sed -n '2,/^set -euo pipefail/p' "$0" | sed -e 's/^# \{0,1\}//' -e '/^set -euo pipefail/d' exit 0 ;; *) printf 'ERROR: unknown flag: %s (try --help)\n' "$arg" >&2 exit 2 ;; esac done # --- Colour (only on a TTY; degrade to empty strings head-less) ----------- if [[ -t 1 ]]; then C_RESET=$'\033[0m' C_RED=$'\033[31m' C_GREEN=$'\033[32m' C_YELLOW=$'\033[33m' C_BOLD=$'\033[1m' else C_RESET='' C_RED='' C_GREEN='' C_YELLOW='' C_BOLD='' fi ts() { date -u +%Y-%m-%dT%H:%M:%SZ; } banner() { # banner "[1/4]" "Pulling latest code" printf '\n%s==> %s %s%s\n' "$C_BOLD" "$1" "$2" "$C_RESET" } log() { printf '[deploy %s] %s\n' "$(ts)" "$*"; } warn() { printf '%s[deploy %s] WARN: %s%s\n' "$C_YELLOW" "$(ts)" "$*" "$C_RESET" >&2; } # Print a FAILED banner with a stage label, then exit non-zero. fail() { # fail "" "" printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET" printf '%s%s DEPLOY FAILED at stage: %s%s\n' "$C_BOLD" "$C_RED" "$1" "$C_RESET" printf '%s%s %s%s\n' "$C_BOLD" "$C_RED" "$2" "$C_RESET" printf '%s%s================================================%s\n' "$C_BOLD" "$C_RED" "$C_RESET" exit 1 } # --- Detect compose command (prefer v2 plugin, fall back to v1) ----------- # COMPOSE is an array so quoting survives word-splitting and "docker compose" # (two words) is handled correctly. The ${DOCKER_SUDO:+sudo} prefix is folded # into the array so every compose call inherits root when needed; it expands # to nothing when DOCKER_SUDO is empty. detect_compose() { if ${DOCKER_SUDO:+sudo} docker compose version >/dev/null 2>&1; then COMPOSE=(${DOCKER_SUDO:+sudo} docker compose) elif command -v docker-compose >/dev/null 2>&1; then COMPOSE=(${DOCKER_SUDO:+sudo} docker-compose) else fail "preflight" "Neither 'docker compose' (v2) nor 'docker-compose' (v1) is available. Install Docker via Synology Package Center." fi } # --- Stage 0: preflight ---------------------------------------------------- preflight() { banner "[0/4]" "Preflight checks" if ! command -v docker >/dev/null 2>&1; then fail "preflight" "docker not found on PATH (looked in /usr/local/bin and Container Manager's target dir too)." fi # Determine whether docker needs root. On the Synology NAS the deploy user # cannot reach the docker daemon directly, so we route every call through # sudo. We only commit to sudo when we have a way to authenticate: # - `sudo -n true` succeeds -> passwordless sudo / cached credential, OR # - stdout is a TTY ([ -t 1 ]) -> interactive `sudo` can prompt (caller # ran us via `ssh -t`); sudo caches it. # With neither, an interactive prompt would hang head-less, so we bail early. if ! docker version >/dev/null 2>&1; then if sudo -n true >/dev/null 2>&1 || [[ -t 1 ]]; then DOCKER_SUDO="sudo" log "docker requires sudo on this host — you may be prompted for your password once" else fail "preflight" "docker requires root and no TTY/passwordless sudo is available. Run via 'ssh -t' or configure NOPASSWD sudo for docker." fi fi if [[ ! -f "$REPO_ROOT/.env" ]]; then fail "preflight" ".env not found at $REPO_ROOT/.env — create it on the NAS (SESSION_SECRET + AUTH_USER + AUTH_PASS + POSTGRES_PASSWORD) before deploying. It is gitignored and not baked into the image." fi detect_compose log "repo root: $REPO_ROOT" log "compose command: ${COMPOSE[*]}" log ".env: present" log "this script manages compose project 'forge' directly; any Container" log "Manager GUI 'Project' of the same name is cosmetic — these are its containers." } # --- Stage 1: git pull (optional) ----------------------------------------- stage_pull() { banner "[1/4]" "Pulling latest code" if [[ "$DO_PULL" -ne 1 ]]; then log "skipped (no --pull flag); deploying current working tree as-is" return 0 fi if [[ ! -d "$REPO_ROOT/.git" ]]; then warn "--pull requested but $REPO_ROOT is not a git repo; skipping pull" return 0 fi if ! command -v git >/dev/null 2>&1; then warn "--pull requested but git not found on PATH; skipping pull" return 0 fi log "running git pull..." git pull log "git pull complete" } # --- Stage 1b: optional fresh teardown (only with --fresh) ---------------- stage_fresh() { if [[ "$DO_FRESH" -ne 1 ]]; then return 0 fi banner "[1/4]" "Fresh teardown of existing stack (--fresh)" # NEVER pass -v: this compose HAS a named volume (forge-db) holding the ticket # database — dropping it would wipe all data. --remove-orphans sweeps up any # stale container lingering under project 'forge'; the volume is untouched. log "fresh mode: tearing down existing stack (forge-db volume PRESERVED)" "${COMPOSE[@]}" down --remove-orphans || true } # --- Stage 2: build (full streaming output) ------------------------------- stage_build() { banner "[2/4]" "Building images (this can take a few minutes on NAS hardware)" if ! "${COMPOSE[@]}" build; then fail "build" "Image build failed. See the build output above." fi log "build complete" } # --- Stage 3: up (starts app; no migrate one-shot for forge) -------------- stage_up() { banner "[3/4]" "Starting stack (app)" # --remove-orphans cleans up any stale container still pinned to project # 'forge' (e.g. left by the GUI or an old compose config). Build ran first, # so the app keeps serving until this quick recreate — minimal downtime. if ! "${COMPOSE[@]}" up -d --remove-orphans; then fail "up" "'up -d' failed. See the output above." fi } # --- Stage 4: wait for app health ----------------------------------------- stage_health() { banner "[4/4]" "Waiting for app to become healthy (timeout ${HEALTH_TIMEOUT_SECS}s)" local cid cid="$("${COMPOSE[@]}" ps -q "$APP_SERVICE" 2>/dev/null | head -n 1 || true)" if [[ -z "$cid" ]]; then "${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true fail "health" "APP DID NOT BECOME HEALTHY — no '$APP_SERVICE' container is running. 'up' may have failed. See logs above." fi # Show recent startup logs so the user sees progress while we poll. log "recent app startup logs:" "${COMPOSE[@]}" logs --no-color --tail 20 "$APP_SERVICE" 2>/dev/null || true local deadline status running polls deadline=$(( $(date +%s) + HEALTH_TIMEOUT_SECS )) polls=0 while true; do # If the container has a healthcheck, .State.Health.Status is one of # starting|healthy|unhealthy. If it has none, the field is empty — fall # back to .State.Status == running. status="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}none{{end}}' "$cid" 2>/dev/null || echo "unknown")" running="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.State.Running}}' "$cid" 2>/dev/null || echo "false")" if [[ "$status" == "healthy" ]]; then log "app is healthy" return 0 fi if [[ "$status" == "none" && "$running" == "true" ]]; then warn "app container has no healthcheck; treating 'running' as healthy" return 0 fi # HARD FAIL — only a TERMINAL healthcheck verdict aborts early. 'unhealthy' # means the healthcheck retries are exhausted. We deliberately do NOT infer # failure from running!=true: under `restart: unless-stopped`, a crash makes # 'running' transiently false while Docker restarts the container, so we # keep waiting (until the overall timeout) through transient states. if [[ "$status" == "unhealthy" ]]; then "${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true fail "health" "APP IS UNHEALTHY — '$APP_SERVICE' healthcheck reported 'unhealthy' (retries exhausted). See app logs above." fi if [[ "$(date +%s)" -ge "$deadline" ]]; then "${COMPOSE[@]}" logs --no-color --tail 50 "$APP_SERVICE" 2>/dev/null || true fail "health" "APP DID NOT BECOME HEALTHY within ${HEALTH_TIMEOUT_SECS}s (last status: ${status}/${running}). See app logs above." fi # Heartbeat: emit a timestamped, status-bearing line every few polls so # head-less logs (DSM Task Scheduler, which only flushes at exit) stay # useful — bare dots would otherwise appear only when the script ends. polls=$(( polls + 1 )) if (( polls % 5 == 1 )); then log "still waiting (status=${status}/${running})..." fi sleep "$HEALTH_POLL_SECS" done } # --- Derive the published host port (best effort) ------------------------- derive_host_port() { local port port="$("${COMPOSE[@]}" port "$APP_SERVICE" 3000 2>/dev/null | sed -n 's/.*:\([0-9][0-9]*\)$/\1/p' | head -n 1 || true)" if [[ -n "$port" ]]; then printf '%s' "$port" else printf '%s' "$HOST_PORT_FALLBACK" fi } # --- Final OK banner ------------------------------------------------------- success_banner() { local host_port image host_port="$(derive_host_port)" image="$(${DOCKER_SUDO:+sudo} docker inspect --format '{{.Config.Image}}' "$("${COMPOSE[@]}" ps -q "$APP_SERVICE" | head -n 1)" 2>/dev/null || echo "forge-app:latest")" printf '\n%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" printf '%s%s DEPLOY OK%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" printf '%s%s================================================%s\n' "$C_BOLD" "$C_GREEN" "$C_RESET" log "app image: $image" log "app health: healthy" log "app URL: http://:${host_port} (LAN; or via your reverse proxy)" } # --- Main ------------------------------------------------------------------ main() { preflight stage_pull stage_fresh stage_build stage_up stage_health success_banner } main