← back to Dw Vendor Microsites
deploy-vendor.sh
152 lines
#!/usr/bin/env bash
# deploy-vendor.sh <vendor_code>
#
# Wildcard-cert deploy of one BUILT vendor microsite to Kamatera (45.61.58.125).
# Wildcard version of ~/deploy-cowtan.sh: NO per-host certbot — every vhost references
# the single /etc/letsencrypt/live/designerwallcoverings.com/ wildcard cert.
#
# GATED: this is an outward-facing prod write. Do NOT run without Steve's approval.
#
# Reads the vendor's port + subdomain from manifest.json. Single-pass nginx vhost,
# Basic Auth admin/DW2024!, X-Robots-Tag noindex, listen 45.61.58.125:443 ssl http2.
set -euo pipefail
VENDOR="${1:?usage: deploy-vendor.sh <vendor_code>}"
REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$REPO/lib/manifest-lib.sh"
PORT="$(manifest_get "$VENDOR" port)"
SUB="$(manifest_get "$VENDOR" subdomain)"
HOUSE="$(manifest_get "$VENDOR" house_name)"
BUILD_STATUS="$(manifest_get "$VENDOR" build_status)"
[ -z "$PORT" ] && { echo "deploy: no port for $VENDOR in manifest — build it first" >&2; exit 1; }
[ -z "$SUB" ] && { echo "deploy: no subdomain for $VENDOR in manifest" >&2; exit 1; }
case "$BUILD_STATUS" in BUILT*) : ;; *) echo "deploy: $VENDOR not BUILT (status=$BUILD_STATUS)" >&2; exit 1;; esac
# image-health gate: refuse to deploy a vendor whose product images are broken/missing
# (health < 0.5). This closes the gap that let BUILT_NEEDS_RESCRAPE sites reach live with
# empty grids (command54/justindavid/phillip_jeffries, 2026-07-12). Override: DEPLOY_FORCE=1.
IMG_HEALTH="$(manifest_get "$VENDOR" image_health)"
if [ -n "$IMG_HEALTH" ] && [ "$IMG_HEALTH" != "null" ] && awk "BEGIN{exit !($IMG_HEALTH < 0.5)}" 2>/dev/null; then
if [ "${DEPLOY_FORCE:-0}" = "1" ]; then
echo "deploy: WARNING — $VENDOR image_health=$IMG_HEALTH < 0.5 (broken images); proceeding (DEPLOY_FORCE=1)" >&2
else
echo "deploy: REFUSING — $VENDOR image_health=$IMG_HEALTH < 0.5 (broken/missing product images)." >&2
echo " Re-scrape images first, or set DEPLOY_FORCE=1 to override." >&2
exit 2
fi
fi
DOMAIN="${SUB}.designerwallcoverings.com"
PM2NAME="${VENDOR}-viewer"
JSONL_REMOTE="/root/Projects/dw-vendor-microsites/staging/${VENDOR}.jsonl"
VDIR_REMOTE="/root/Projects/dw-vendor-microsites/vendors/${VENDOR}/viewer"
# server-port guard: the manifest's port map is blind to what's actually bound on Kamatera
# (200+ pm2 procs). If PORT is already listening, this deploy would fail to bind and nginx
# would silently proxy this domain to the squatter (catchii->denovowall->disignum, 2026-07-13).
# Free THIS vendor's own prior binding first (so a redeploy doesn't see its own port as a
# squatter), then probe the REAL server; if still taken, pick a verified-free port and persist it.
ssh my-server "pm2 delete ${PM2NAME} >/dev/null 2>&1 || true; sleep 1"
PORT_STATE="$(ssh my-server "(exec 3<>/dev/tcp/127.0.0.1/${PORT}) 2>/dev/null && echo BUSY || echo FREE")"
if [ "$PORT_STATE" = "BUSY" ]; then
echo "deploy: port ${PORT} already bound on server — selecting a verified-free port..." >&2
NEWPORT="$(ssh my-server "for p in \$(seq 10100 10400); do (exec 3<>/dev/tcp/127.0.0.1/\$p) 2>/dev/null || { echo \$p; break; }; done")"
[ -z "$NEWPORT" ] && { echo "deploy: no free server port in 10100-10400" >&2; exit 3; }
echo "deploy: reassigning ${VENDOR} port ${PORT} -> ${NEWPORT}" >&2
PORT="$NEWPORT"
manifest_upsert "$VENDOR" "{\"port\":${PORT}}"
fi
echo "== deploy ${VENDOR} -> https://${DOMAIN} (port ${PORT}, ${HOUSE}) =="
echo "== 1. rsync viewer + staging -> Kamatera =="
ssh my-server "mkdir -p ${VDIR_REMOTE} /root/Projects/dw-vendor-microsites/staging"
rsync -az --exclude node_modules --exclude .git --exclude '*.log' \
--exclude 'data/sample-requests.jsonl' \
"$REPO/vendors/${VENDOR}/viewer/" "my-server:${VDIR_REMOTE}/"
rsync -az "$REPO/staging/${VENDOR}.jsonl" "my-server:${JSONL_REMOTE}"
echo "== 2. pm2 + nginx vhost (wildcard cert, no per-host certbot) =="
ssh my-server "bash -s" <<REMOTE
set -e
cd ${VDIR_REMOTE}
pm2 delete ${PM2NAME} 2>/dev/null || true
# Shared microsite env (Shopify Memo-Sample checkout: SHOPIFY_STORE_DOMAIN /
# SHOPIFY_STOREFRONT_TOKEN / SHOPIFY_SAMPLE_VARIANT_ID). Fanned out by the
# secrets skill to /root/Projects/dw-vendor-microsites/.env. Source it so the
# pm2 proc inherits the Shopify creds; absent file = no-op (button falls back).
SHARED_ENV=/root/Projects/dw-vendor-microsites/.env
[ -f "\$SHARED_ENV" ] && set -a && . "\$SHARED_ENV" && set +a
VENDOR_JSONL=${JSONL_REMOTE} VENDOR_FIELDMAP=${VDIR_REMOTE}/fieldmap.json \
pm2 start server.js --name ${PM2NAME} --update-env -- ${PORT} ${JSONL_REMOTE} ${VDIR_REMOTE}/fieldmap.json
pm2 save
sleep 2
echo " app health: \$(curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:${PORT}/)"
# shared htpasswd (admin / DW2024!) reused across the fleet
[ -f /etc/nginx/.htpasswd-dwvendors ] || htpasswd -bc /etc/nginx/.htpasswd-dwvendors admin 'DW2024!'
cat > /etc/nginx/sites-available/${DOMAIN}.conf <<VH
server {
server_name ${DOMAIN};
auth_basic "Designer Wallcoverings — Trade Lines (Restricted)";
auth_basic_user_file /etc/nginx/.htpasswd-dwvendors;
add_header X-Frame-Options SAMEORIGIN always;
add_header X-Content-Type-Options nosniff always;
add_header X-Robots-Tag "noindex, nofollow" always;
location / {
proxy_pass http://127.0.0.1:${PORT};
proxy_http_version 1.1;
proxy_set_header Host \\\$host;
proxy_set_header X-Real-IP \\\$remote_addr;
proxy_set_header X-Forwarded-For \\\$proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto \\\$scheme;
proxy_read_timeout 60s;
}
listen 45.61.58.125:443 ssl http2;
ssl_certificate /etc/letsencrypt/live/designerwallcoverings.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/designerwallcoverings.com/privkey.pem;
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;
}
server {
if (\\\$host = ${DOMAIN}) { return 301 https://\\\$host\\\$request_uri; }
listen 80;
server_name ${DOMAIN};
return 404;
}
VH
ln -sf /etc/nginx/sites-available/${DOMAIN}.conf /etc/nginx/sites-enabled/${DOMAIN}
nginx -t && systemctl reload nginx
echo "== smoke (via listen IP) =="
echo " no-auth (want 401): \$(curl -s -o /dev/null -w '%{http_code}' -k --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
echo " admin (want 200): \$(curl -s -o /dev/null -w '%{http_code}' -k -u admin:DW2024! --resolve ${DOMAIN}:443:45.61.58.125 https://${DOMAIN}/)"
REMOTE
# post-deploy IDENTITY guard: verify the domain actually serves THIS vendor's catalog, not a
# squatter's. A collided deploy still 401/200s (both apps are gated) but serves the WRONG rows;
# compare the live row total to the staging line count. Mismatch => fail loudly, don't mark DEPLOYED.
STAGING_ROWS="$(grep -c . "$REPO/staging/${VENDOR}.jsonl" 2>/dev/null || echo 0)"
# force the listen IP with --resolve so this works even before the subdomain's DNS A record
# exists (DNS is a separate gated step) — we're verifying the deployed app, not DNS. Retry a few
# times because the check fires right after `systemctl reload nginx` and can catch a transient.
LIVE_TOTAL='?'
for _try in 1 2 3 4; do
LIVE_TOTAL="$(curl -s -k --resolve "${DOMAIN}:443:45.61.58.125" --max-time 20 -u admin:DW2024! "https://${DOMAIN}/api/skus?limit=1" \
| python3 -c 'import sys,json;print(json.load(sys.stdin).get("total","?"))' 2>/dev/null || echo '?')"
[ "$STAGING_ROWS" = "$LIVE_TOTAL" ] && break
sleep 2
done
if [ "$STAGING_ROWS" != "$LIVE_TOTAL" ]; then
echo "== IDENTITY MISMATCH: ${DOMAIN} serves ${LIVE_TOTAL} rows but ${VENDOR} staging has ${STAGING_ROWS} —" >&2
echo " likely PORT COLLISION (domain proxying to another vendor). NOT marking DEPLOYED. ==" >&2
exit 4
fi
echo "== identity OK: ${DOMAIN} serves ${LIVE_TOTAL} rows (matches ${VENDOR} staging) =="
# record deploy_status (local manifest only; the prod write itself is the gated part)
manifest_upsert "$VENDOR" "{\"deploy_status\":\"DEPLOYED\"}"
echo "== DONE — https://${DOMAIN} (admin / DW2024!) =="