← back to Nationalrealestate

scripts/deploy-usre-scoped.sh

168 lines

#!/usr/bin/env bash
# deploy-usre-scoped.sh — SAFE, scoped, reversible deploy for usre (usrealestate) → Kamatera.
# =============================================================================
# WHY THIS EXISTS (TK-10526 finding, 2026-08-15):
#   usre has NO .deploy.conf, so the shared _shared/scripts/deploy.sh is the only
#   generic path — but that does `rsync -az --delete` over the WHOLE repo. A dry-run
#   proved that against usre would DELETE REAL PROD SOURCE (property.html, src/index.ts,
#   src/zip_county.ts — present on prod, absent locally) and push ~1000 mixed-ticket
#   files. That is fail-dangerous. This script is the safe alternative: it copies ONLY
#   the files you name, backs up every file it replaces, restarts, smoke-tests, and
#   AUTO-ROLLS-BACK on any regression. It NEVER uses --delete and never touches a file
#   you didn't list. Modeled on the repo's proven scripts/deploy-usre-code-to-kamatera.sh.
#
# USAGE:
#   # DRY-RUN (default) — prints the plan + per-file local-vs-prod md5 diff, writes NOTHING:
#   bash scripts/deploy-usre-scoped.sh public/deals.html src/server/index.ts
#
#   # APPLY — actually deploy (backup → copy → pm2 restart → smoke → rollback-on-fail):
#   APPLY=1 bash scripts/deploy-usre-scoped.sh public/deals.html src/server/index.ts
#
#   # No file args → defaults to the TK-10526 grid/sort feature set (all currently == prod).
#
#   # Optional deeper API smoke (endpoints are auth-gated → pass creds to exercise sort):
#   SMOKE_AUTH='admin:PASS' APPLY=1 bash scripts/deploy-usre-scoped.sh src/server/index.ts
#
# GUARANTEES:
#   - Never runs rsync --delete; never git commits/pushes; never edits .env content.
#   - Only the listed files are backed up + replaced. A new file (absent on prod) is
#     tracked so rollback REMOVES it. Everything else on prod is untouched.
#   - Aborts + rolls back on the first smoke failure; keeps the backup dir for forensics.
# =============================================================================
set -euo pipefail

KAM="${KAM:-root@45.61.58.125}"
USRE_DIR="${USRE_DIR:-/root/public-projects/nationalrealestate}"
PM2_NAME="${PM2_NAME:-usrealestate}"
APPLY="${APPLY:-0}"
SMOKE_AUTH="${SMOKE_AUTH:-}"           # optional "user:pass" for auth-gated /api smoke
LOCAL_ROOT="$HOME/Projects/nationalrealestate"

# Default file set = the TK-10526 list/table/grid + backend-sort feature (parameterizable).
DEFAULT_FILES=(
  public/crcp-grid.js public/crcp-grid.css
  public/deals.html public/contractors.html public/commercial-feed.html public/brokers.html
  src/server/index.ts
)
FILES=("$@"); [ "${#FILES[@]}" -eq 0 ] && FILES=("${DEFAULT_FILES[@]}")

STAMP="$(cd "$LOCAL_ROOT" && git rev-parse --short HEAD 2>/dev/null || date +%Y%m%d-%H%M%S)"
cd "$LOCAL_ROOT"

echo "──────────────────────────────────────────────────────────────"
echo " usre scoped deploy   mode=$([ "$APPLY" = 1 ] && echo APPLY || echo DRY-RUN)   stamp=$STAMP"
echo " remote: $KAM:$USRE_DIR   pm2: $PM2_NAME"
echo " files (${#FILES[@]}):"; printf '   - %s\n' "${FILES[@]}"
echo "──────────────────────────────────────────────────────────────"

# ── pre-flight: every named file must exist locally ──────────────────────────
for f in "${FILES[@]}"; do
  [ -f "$f" ] || { echo "❌ local file missing: $f — aborting."; exit 1; }
done

# ── per-file local-vs-prod md5 (skip no-ops, show real deltas) ────────────────
echo "── diff vs prod (only DIFFERS files are worth shipping) ──"
PROD_MD=$(ssh -o BatchMode=yes "$KAM" "cd '$USRE_DIR' && for f in ${FILES[*]}; do if [ -f \"\$f\" ]; then echo \"\$f \$(md5sum \"\$f\" | cut -d' ' -f1)\"; else echo \"\$f PROD-MISSING\"; fi; done")
CHANGED=(); NEWFILES=()
for f in "${FILES[@]}"; do
  lm=$(md5 -q "$f" 2>/dev/null || md5sum "$f" | cut -d' ' -f1)
  pm=$(awk -v F="$f" '$1==F{print $2}' <<<"$PROD_MD")
  if [ "$pm" = "PROD-MISSING" ]; then echo "   NEW     $f"; CHANGED+=("$f"); NEWFILES+=("$f")
  elif [ "$lm" = "$pm" ]; then echo "   SAME    $f  (skip — already on prod)"
  else echo "   DIFFERS $f  local=${lm:0:8} prod=${pm:0:8}"; CHANGED+=("$f"); fi
done

if [ "${#CHANGED[@]}" -eq 0 ]; then
  echo "✔ nothing to deploy — every listed file already matches prod. Exiting clean."
  exit 0
fi

if [ "$APPLY" != "1" ]; then
  echo
  echo "DRY-RUN only — no writes. Would deploy ${#CHANGED[@]} file(s) with backup+smoke+rollback."
  echo "Re-run with APPLY=1 to execute:  APPLY=1 bash scripts/deploy-usre-scoped.sh ${FILES[*]}"
  exit 0
fi

# ── APPLY: stage → backup → copy → restart → smoke → rollback-on-fail ─────────
echo; echo "── APPLY: staging ${#CHANGED[@]} changed file(s) to Kamatera ──"
STAGE="/tmp/usre-scoped-$STAMP"
ssh "$KAM" "rm -rf '$STAGE' && mkdir -p '$STAGE'"
for f in "${CHANGED[@]}"; do
  ssh "$KAM" "mkdir -p '$STAGE/$(dirname "$f")'"
  scp -q "$f" "$KAM:$STAGE/$f"
done

# Serialize file lists for the remote heredoc.
CHANGED_STR="${CHANGED[*]}"
NEW_STR="${NEWFILES[*]:-}"

ssh "$KAM" "bash -s" <<REMOTE
set -uo pipefail
cd "$USRE_DIR" || { echo "❌ usre dir missing"; exit 1; }
PORT=\$(sed -n 's/^PORT=//p' .env 2>/dev/null | tr -dc '0-9'); PORT=\${PORT:-9913}
BK="/root/backups/usre-scoped-$STAMP"; mkdir -p "\$BK"
echo "backup dir: \$BK   (port \$PORT)"

CHANGED_FILES="$CHANGED_STR"
NEW_FILES="$NEW_STR"

# --- backup every file we're about to replace (new files have nothing to back up) ---
for f in \$CHANGED_FILES; do
  if [ -f "\$f" ]; then mkdir -p "\$BK/\$(dirname "\$f")"; cp -p "\$f" "\$BK/\$f" && echo "backed up \$f"; fi
done

# --- deploy the staged files into place (NO --delete, only these paths) ---
for f in \$CHANGED_FILES; do
  mkdir -p "\$(dirname "\$f")"
  cp "$STAGE/\$f" "\$f" && echo "deployed \$f"
done

echo "--- pm2 restart $PM2_NAME ---"
pm2 restart "$PM2_NAME" --update-env >/dev/null 2>&1; sleep 3

# --- smoke: /healthz must stay 200 (server up + routing intact) ---
HZ=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT/healthz")
echo "smoke: /healthz=\$HZ"
FAIL=0; [ "\$HZ" = "200" ] || FAIL=1

# --- smoke: each shipped public/*.html must serve 200 (static route intact) ---
for f in \$CHANGED_FILES; do
  case "\$f" in
    public/*.html)
      url="/\${f#public/}"
      code=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT\$url")
      echo "smoke: \$url=\$code"; [ "\$code" = "200" ] || FAIL=1 ;;
  esac
done

# --- optional deeper API sort smoke (only if creds passed) ---
if [ -n "$SMOKE_AUTH" ]; then
  A=\$(curl -s -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=asc")
  D=\$(curl -s -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=desc")
  ac=\$(curl -s -o /dev/null -w '%{http_code}' -u "$SMOKE_AUTH" "http://localhost:\$PORT/api/deals?limit=1&sort=doc_number&order=asc")
  echo "smoke: /api/deals sort http=\$ac  asc!=desc=\$([ "\$A" != "\$D" ] && echo yes || echo no)"
  [ "\$ac" = "200" ] || FAIL=1
fi

if [ "\$FAIL" = "0" ]; then
  echo "✅ DEPLOY OK — smoke green. Backup kept at \$BK; staging cleaned."
  rm -rf "$STAGE"
else
  echo "❌ SMOKE FAILED — ROLLING BACK."
  for f in \$CHANGED_FILES; do
    if [ -f "\$BK/\$f" ]; then cp -p "\$BK/\$f" "\$f"; echo "restored \$f";
    else
      # file was NEW on prod (no backup) → remove it to fully revert
      for nf in \$NEW_FILES; do [ "\$nf" = "\$f" ] && rm -f "\$f" && echo "removed new \$f"; done
    fi
  done
  pm2 restart "$PM2_NAME" --update-env >/dev/null 2>&1; sleep 2
  HZ2=\$(curl -s -o /dev/null -w '%{http_code}' "http://localhost:\$PORT/healthz")
  echo "ROLLED BACK to $STAMP baseline (/healthz=\$HZ2). Backup at \$BK. Investigate before retry."
  exit 1
fi
REMOTE

echo "── done ──"