[object Object]

← back to Nationalrealestate

usre: safe scoped deploy script (backup+smoke+auto-rollback, no rsync --delete)

14507afcbb959916f67f9b36dd2c71f1dff15117 · 2026-08-15 09:55:17 -0700 · Steve Abrams

Closes the TK-10526 gap: usre has no .deploy.conf, so the shared deploy.sh
full-repo rsync --delete would delete real prod source (property.html,
src/index.ts, src/zip_county.ts). This deploys ONLY named files, backs each
up, restarts, smoke-tests /healthz + shipped static pages, and auto-rolls-back
on failure. Dry-run by default (APPLY=1 to fire). Verified in dry-run: all 7
TK-10526 files currently match prod -> clean no-op exit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 14507afcbb959916f67f9b36dd2c71f1dff15117
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sat Aug 15 09:55:17 2026 -0700

    usre: safe scoped deploy script (backup+smoke+auto-rollback, no rsync --delete)
    
    Closes the TK-10526 gap: usre has no .deploy.conf, so the shared deploy.sh
    full-repo rsync --delete would delete real prod source (property.html,
    src/index.ts, src/zip_county.ts). This deploys ONLY named files, backs each
    up, restarts, smoke-tests /healthz + shipped static pages, and auto-rolls-back
    on failure. Dry-run by default (APPLY=1 to fire). Verified in dry-run: all 7
    TK-10526 files currently match prod -> clean no-op exit.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 scripts/deploy-usre-scoped.sh | 167 ++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 167 insertions(+)

diff --git a/scripts/deploy-usre-scoped.sh b/scripts/deploy-usre-scoped.sh
new file mode 100755
index 0000000..56b4893
--- /dev/null
+++ b/scripts/deploy-usre-scoped.sh
@@ -0,0 +1,167 @@
+#!/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 ──"

← ce73325 usre: true server-side sort on /api/brokers + /api/firms (wh  ·  back to Nationalrealestate  ·  chore: v0.19.0 (session close — residential desk + server-si 33399fa →