Files

354 lines
15 KiB
Bash
Raw Permalink Normal View History

2026-08-29 12:30:19 +03:00
#!/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 — `<compose> build` (full streaming output, not quiet)
# [3/4] Up — `<compose> 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 "<stage>" "<message>"
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://<nas-host>:${host_port} (LAN; or via your reverse proxy)"
}
# --- Main ------------------------------------------------------------------
main() {
preflight
stage_pull
stage_fresh
stage_build
stage_up
stage_health
success_banner
}
main