354 lines
15 KiB
Bash
354 lines
15 KiB
Bash
|
|
#!/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
|