fix(docker): harden image and automate safe VPS updates
- Use floating node:alpine that tracks the latest supported LTS; pnpm bootstrap follows package.json's packageManager pin. - Drop corepack (removed from node:26), install pnpm via npm global. - Add pnpm fetch + offline install for stable dependency-layer caching. - Run as non-root nextjs (UID/GID 33 = host www-data) with tini as PID 1 for correct signal handling. - Open node engines to >=20.9.0 so patches/minors float automatically. - Add docker-preflight.sh (per-VPS checks incl. --fix) and gate docker-update.sh so Node major upgrades require explicit review while patches deploy silently.
This commit is contained in:
1 parent
7033d65846
commit
539e6d3fad
5 files changed
+201
-86
No files matched your search
Executable
+139
@@ -0,0 +1,139 @@
|
||||
#!/usr/bin/env bash
|
||||
# ==============================================================================
|
||||
# docker-preflight.sh — Verify a VPS is ready to run the Dockerized EpicNext-CMS.
|
||||
#
|
||||
# Lets any supplied .env drive the checks. Checks (all idempotent, none mutating):
|
||||
# 1. Docker + compose available and usable.
|
||||
# 2. Required runtime dirs exist (RW for UID 33 where the CMS writes).
|
||||
# 3. Volume owners are UID/GID 33 (www-data) on the host.
|
||||
# 4. .env exists and required host-network services are reachable.
|
||||
# 5. Port 3002 free (or the host CMS that must be stopped).
|
||||
#
|
||||
# Usage: ./scripts/docker-preflight.sh [--fix]
|
||||
# --fix attempts to auto-correct permission/owner issues (chown).
|
||||
#
|
||||
# Exit codes: 0 ready, 1 not ready (or fixed nothing).
|
||||
# ==============================================================================
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
cd "$DIR" || exit 1
|
||||
|
||||
FIX=0
|
||||
[ "${1:-}" = "--fix" ] && FIX=1
|
||||
|
||||
ENV_FILE="${ENV_FILE:-$DIR/.env}"
|
||||
fail=0
|
||||
warn=0
|
||||
|
||||
# Ports that are expected to be reachable ON THE HOST (network_mode: host).
|
||||
# These mirror the defaults in .env / the emulator stack; override via env.
|
||||
CMS_PORT="${CMS_PORT:-3002}"
|
||||
MYSQL_PORT="${MYSQL_PORT:-3306}"
|
||||
REDIS_PORT="${REDIS_PORT:-6379}"
|
||||
RCON_PORT="${RCON_PORT:-3003}"
|
||||
API_PORT="${API_PORT:-3001}"
|
||||
IMAGER_PORT="${IMAGER_PORT:-8082}"
|
||||
|
||||
# Relative dirs the CMS writes to, plus the absolute shared gamedata root.
|
||||
RW_DIRS=(
|
||||
"$DIR/public/nitro-assets"
|
||||
"$DIR/public/swf"
|
||||
"$DIR/storage"
|
||||
"/var/www/Gamedata"
|
||||
)
|
||||
|
||||
say() { printf ' %s\n' "$*"; }
|
||||
ok() { printf ' \033[32m[OK] \033[0m%s\n' "$*"; }
|
||||
err() { printf ' \033[31m[FAIL]\033[0m %s\n' "$*"; fail=1; }
|
||||
wrn() { printf ' \033[33m[WARN]\033[0m %s\n' "$*"; warn=1; }
|
||||
doing(){ printf '\n\033[1m%s\033[0m\n' "$*"; }
|
||||
|
||||
doing "1. Docker engine + compose"
|
||||
if command -v docker >/dev/null 2>&1; then
|
||||
ok "docker binary present"
|
||||
if docker info >/dev/null 2>&1; then ok "daemon reachable"; else err "daemon NOT reachable (is it running / does this user have access?)"; fi
|
||||
else
|
||||
err "docker binary missing"
|
||||
fi
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
ok "docker compose plugin present"
|
||||
else
|
||||
err "docker compose plugin missing (install docker-compose-plugin)"
|
||||
fi
|
||||
|
||||
doing "2. Runtime directories exist + RW for UID 33"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
if [ ! -e "$d" ]; then
|
||||
err "missing dir: $d"
|
||||
if [ "$FIX" -eq 1 ]; then
|
||||
mkdir -p "$d" && chown 33:33 "$d" && say " created + chown 33:33 $d" || err " could not create $d"
|
||||
fi
|
||||
continue
|
||||
fi
|
||||
if [ ! -d "$d" ]; then err "not a directory: $d"; continue; fi
|
||||
# Test write as the target owner via a temp file drop (as root), then remove.
|
||||
if [ "$(id -u)" -eq 0 ]; then
|
||||
if touch "$d/.preflight-write-test" 2>/dev/null; then
|
||||
rm -f "$d/.preflight-write-test"
|
||||
ok "writable: $d"
|
||||
else
|
||||
err "not writable: $d (needs owner 33:33)"
|
||||
[ "$FIX" -eq 1 ] && { chown -R 33:33 "$d" && say " chown -R 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
else
|
||||
if [ -w "$d" ]; then ok "writable: $d"; else err "not writable: $d"; fi
|
||||
fi
|
||||
done
|
||||
|
||||
doing "3. Volume ownership (UID/GID 33 = www-data)"
|
||||
for d in "${RW_DIRS[@]}"; do
|
||||
[ -e "$d" ] || continue
|
||||
owner="$(stat -c '%u:%g' "$d" 2>/dev/null || true)"
|
||||
if [ "$owner" = "33:33" ]; then
|
||||
ok "owner 33:33: $d"
|
||||
else
|
||||
err "owner ${owner:-unknown} (want 33:33): $d"
|
||||
[ "$FIX" -eq 1 ] && { chown 33:33 "$d" && say " chown 33:33 $d" || err " chown failed"; }
|
||||
fi
|
||||
done
|
||||
|
||||
doing "4. Configuration + required services (.env, host network)"
|
||||
if [ -f "$ENV_FILE" ]; then
|
||||
ok ".env present: $ENV_FILE"
|
||||
### shellcheck disable=SC1090
|
||||
set -a; . "$ENV_FILE"; set +a
|
||||
else
|
||||
err ".env missing: $ENV_FILE (copy your production env here)"
|
||||
fi
|
||||
|
||||
check_port() { # name port
|
||||
if command -v nc >/dev/null 2>&1; then
|
||||
if nc -z 127.0.0.1 "$2" >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
else
|
||||
if (echo >/dev/tcp/127.0.0.1/"$2") >/dev/null 2>&1; then ok "reachable 127.0.0.1:$2 ($1)"; else err "NOT reachable 127.0.0.1:$2 ($1)"; fi
|
||||
fi
|
||||
}
|
||||
check_port "MariaDB" "$MYSQL_PORT"
|
||||
check_port "Redis" "$REDIS_PORT"
|
||||
check_dep() { # name
|
||||
if command -v "$1" >/dev/null 2>&1; then ok "host binary: $1"; else wrn "host binary not found: $1 (may still work via container)"; fi
|
||||
}
|
||||
check_dep git
|
||||
|
||||
doing "5. Port 3002 state (host CMS clash)"
|
||||
if (echo >/dev/tcp/127.0.0.1/"$CMS_PORT") >/dev/null 2>&1; then
|
||||
err "port $CMS_PORT already in use on host — stop the host-side CMS (pm2 stop next) before starting the container"
|
||||
else
|
||||
ok "port $CMS_PORT free"
|
||||
fi
|
||||
|
||||
printf '\n'
|
||||
if [ "$fail" -eq 1 ]; then
|
||||
printf '\033[31m=== NOT READY: %s issue(s) found%s ===\033[0m\n' "$fail" "$([ "$FIX" -eq 1 ] && echo ' after --fix' || echo ' (re-run with --fix to auto-correct)')"
|
||||
exit 1
|
||||
fi
|
||||
if [ "$warn" -eq 1 ]; then printf '\033[33m=== READY (with %s warning(s)) ===\033[0m\n' "$warn"; exit 0; fi
|
||||
printf '\033[32m=== READY ===\033[0m\n'
|
||||
exit 0
|
||||
@@ -32,7 +32,13 @@ touch "$LOG_FILE"
|
||||
|
||||
log "=== Start docker-update ==="
|
||||
|
||||
# --- 0. Guard: uncommitted changes would break git pull / taint deploys ---
|
||||
# --- 0. Preflight: verify this VPS is ready (permissions, ports, deps) ---
|
||||
if ! "$DIR/scripts/docker-preflight.sh"; then
|
||||
die "preflight failed — fix issues first (see '--fix' flag)" 1
|
||||
fi
|
||||
log "preflight OK"
|
||||
|
||||
# --- 0b. Guard: uncommitted changes would break git pull / taint deploys ---
|
||||
if ! { git diff --quiet --exit-code && git diff --cached --quiet --exit-code; }; then
|
||||
die "working tree has uncommitted changes; commit or stash first" 1
|
||||
fi
|
||||
@@ -55,15 +61,32 @@ else
|
||||
fi
|
||||
log "db:migrate OK"
|
||||
|
||||
# --- 3. Rebuild the image ---
|
||||
# --- 3. Node major gate (patches auto, major upgrades need review) ---
|
||||
# `node:alpine` floats within, then across, Node majors. Patches/minors are
|
||||
# safe to apply silently; a NEW major (e.g. 26 -> 27) is a breaking risk for
|
||||
# native addons / Next compatibility, so require an explicit review before it
|
||||
# goes live. Compare the major of the deployed runtime image vs the floating
|
||||
# tag; abort (not deploy) when they differ.
|
||||
deployed_major="$(docker inspect --format '{{.Config.Image}}' epicnext-cms 2>/dev/null || true)"
|
||||
# Resolve the currently-deployed Node major from its image.
|
||||
if [ -n "$deployed_major" ] && docker image inspect "$deployed_major" >/dev/null 2>&1; then
|
||||
deployed_major="$(docker run --rm --entrypoint sh "$deployed_major" -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
fi
|
||||
float_major="$(docker run --rm --entrypoint sh node:alpine -c 'node -p "process.versions.node.split(\".\")[0]"' 2>/dev/null || true)"
|
||||
if [ -n "$deployed_major" ] && [ -n "$float_major" ] && [ "$deployed_major" != "$float_major" ]; then
|
||||
die "Node major change detected (deployed v$deployed_major, floating tag v$float_major). Major upgrades require review; update engines/Dockerfile deliberately first." 1
|
||||
fi
|
||||
log "Node major gate OK (major=${float_major:-?})"
|
||||
|
||||
# --- 4. Rebuild the image ---
|
||||
docker compose build >>"$LOG_FILE" 2>&1 || die "docker compose build failed" 2
|
||||
log "docker compose build OK"
|
||||
|
||||
# --- 4. Recreate the container ---
|
||||
# --- 5. Recreate the container ---
|
||||
docker compose up -d >>"$LOG_FILE" 2>&1 || die "docker compose up failed" 2
|
||||
log "docker compose up OK"
|
||||
|
||||
# --- 5. Wait for health (up to ~4 min) ---
|
||||
# --- 6. Wait for health (up to ~4 min) ---
|
||||
healthy=0
|
||||
for i in $(seq 1 16); do
|
||||
status="$(docker inspect --format='{{.State.Health.Status}}' epicnext-cms 2>/dev/null || true)"
|
||||
@@ -82,7 +105,7 @@ else
|
||||
exit 3
|
||||
fi
|
||||
|
||||
# --- 6. Make sure the stale host-side PM2 CMS stays stopped ---
|
||||
# --- 7. Make sure the stale host-side PM2 CMS stays stopped ---
|
||||
if command -v pm2 >/dev/null 2>&1 && pm2 jlist >/dev/null 2>&1; then
|
||||
if pm2 list 2>/dev/null | grep -q "${PM2_APP}"; then
|
||||
pm2 stop "$PM2_APP" >/dev/null 2>&1 && log "pm2 '${PM2_APP}' kept stopped (avoids port 3002 clash)"
|
||||
|
||||
Reference in new issue
Block a user