black/apricot homelan died 2026-06-27. Point everything at the DO store tier: - @lilith npm registry: forge.black.lan/npm.black.lan -> cocotte-forge Verdaccio (134.199.243.61:4873) across bunfig.toml scopes, all deploy.sh .npmrc writers, and package.json publishConfig. - Forgejo URL (git/CI): forge.black.lan -> 134.199.243.61:3000 / :2222. - quinn.www prod.conf /photos: was proxy_pass to dead black_photos (black:8081); now served from local disk (root /var/www/quinn.www/dist). Prevents a future deploy from re-breaking photos. (Phase G: repoint to DO Spaces/CDN later.) Interim bare-IP endpoints; switch to named uvlava infranet hosts once live. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
452 lines
22 KiB
Bash
Executable file
452 lines
22 KiB
Bash
Executable file
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
|
REPO_ROOT="$(cd "$SCRIPT_DIR/../../../" && pwd)"
|
|
|
|
REMOTE="quinn-vps"
|
|
REMOTE_DIST="/var/www/quinn.admin/dist"
|
|
REMOTE_API="/opt/quinn-admin-api"
|
|
# Admin gallery photo store. The admin backend's PHOTOS_DIR points here (see
|
|
# scripts/quinn-admin-api.service). Holds the DESCRIPTIVE-named gallery set
|
|
# (matching the gallery_items DB) + the adversary/ visualization trees — a
|
|
# different set from the public quinn.www hash-named photos, served from a
|
|
# separate dir so the public vhost is untouched.
|
|
REMOTE_PHOTOS="/var/www/quinn.admin/photos"
|
|
REMOTE_ORIGINALS="/var/www/quinn.admin/originals"
|
|
ORIGINALS_SRC="${REPO_ROOT}/users/transquinnftw/originals"
|
|
PHOTOS_SRC="${REPO_ROOT}/deployments/@domains/quinn.www/root/public/photos"
|
|
REMOTE_BACKUPS_DIST="/var/www/quinn.admin/.deploy-backups/dist"
|
|
REMOTE_BACKUPS_API="/opt/quinn-admin-api.deploy-backups"
|
|
API_BUNDLE="dist/server.node.js"
|
|
TIMESTAMP="$(date '+%Y%m%d_%H%M%S')"
|
|
BACKUP_DIST="${REMOTE_BACKUPS_DIST}/${TIMESTAMP}"
|
|
BACKUP_API="${REMOTE_BACKUPS_API}/${TIMESTAMP}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --rollback flag: restore the most recent backup of both dist and API
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "${1:-}" == "--rollback" ]]; then
|
|
echo "==> [ROLLBACK] Restoring previous admin frontend + API on ${REMOTE}..."
|
|
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
|
|
REMOTE_BACKUPS_DIST="/var/www/quinn.admin/.deploy-backups/dist"
|
|
REMOTE_BACKUPS_API="/opt/quinn-admin-api.deploy-backups"
|
|
REMOTE_DIST="/var/www/quinn.admin/dist"
|
|
REMOTE_API="/opt/quinn-admin-api"
|
|
|
|
latest_dist="$(ls -1t "$REMOTE_BACKUPS_DIST" 2>/dev/null | head -1)"
|
|
latest_api="$(ls -1t "$REMOTE_BACKUPS_API" 2>/dev/null | head -1)"
|
|
|
|
if [[ -z "$latest_dist" && -z "$latest_api" ]]; then
|
|
echo "ERROR: no backups found for dist or API." >&2
|
|
exit 1
|
|
fi
|
|
|
|
if [[ -n "$latest_dist" ]]; then
|
|
echo " Restoring dist from $REMOTE_BACKUPS_DIST/$latest_dist ..."
|
|
rsync -a --delete "$REMOTE_BACKUPS_DIST/$latest_dist/" "$REMOTE_DIST/"
|
|
else
|
|
echo " WARNING: no dist backup found — skipping dist restore."
|
|
fi
|
|
|
|
if [[ -n "$latest_api" ]]; then
|
|
echo " Restoring API from $REMOTE_BACKUPS_API/$latest_api ..."
|
|
rsync -a --delete "$REMOTE_BACKUPS_API/$latest_api/" "$REMOTE_API/"
|
|
else
|
|
echo " WARNING: no API backup found — skipping API restore."
|
|
fi
|
|
ENDSSH
|
|
echo "==> Reloading nginx and restarting API service..."
|
|
ssh "$REMOTE" "nginx -t && systemctl reload nginx && systemctl restart quinn-admin-api"
|
|
echo ""
|
|
echo "Rollback completed at $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
|
exit 0
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# --skip-build flag: artifacts pre-built in CI; skip local builds + tests
|
|
# ---------------------------------------------------------------------------
|
|
SKIP_BUILD=false
|
|
SKIP_E2E=false
|
|
for arg in "$@"; do
|
|
[[ "$arg" == "--skip-build" ]] && SKIP_BUILD=true
|
|
[[ "$arg" == "--skip-e2e" ]] && SKIP_E2E=true
|
|
done
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Rollback trap — fires on any error after backups are created
|
|
# ---------------------------------------------------------------------------
|
|
BACKUPS_CREATED=false
|
|
|
|
rollback_on_error() {
|
|
local exit_code=$?
|
|
echo ""
|
|
echo "✖ Deploy step failed (exit ${exit_code})."
|
|
if [[ "$BACKUPS_CREATED" == "true" ]]; then
|
|
echo "==> [AUTO-ROLLBACK] Restoring previous dist and API on ${REMOTE}..."
|
|
ssh "$REMOTE" bash -euo pipefail <<ENDSSH || echo " WARNING: rollback failed. Manual intervention required." >&2
|
|
set -euo pipefail
|
|
RESTORED=false
|
|
if [[ -d "${BACKUP_DIST}" ]]; then
|
|
rsync -a --delete "${BACKUP_DIST}/" "${REMOTE_DIST}/"
|
|
echo " dist restored from ${BACKUP_DIST}"
|
|
RESTORED=true
|
|
fi
|
|
if [[ -d "${BACKUP_API}" ]]; then
|
|
rsync -a --delete "${BACKUP_API}/" "${REMOTE_API}/"
|
|
echo " API restored from ${BACKUP_API}"
|
|
RESTORED=true
|
|
fi
|
|
if [[ "\$RESTORED" == "true" ]]; then
|
|
nginx -t && systemctl reload nginx
|
|
systemctl restart quinn-admin-api
|
|
echo " Services reloaded."
|
|
fi
|
|
ENDSSH
|
|
echo " Rollback complete — admin is on the previous release."
|
|
else
|
|
echo " No backups were created — nothing to roll back."
|
|
fi
|
|
exit "$exit_code"
|
|
}
|
|
|
|
trap rollback_on_error ERR
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [1/10] Build + [2.5/10] Tests
|
|
# Skipped when --skip-build: artifacts pre-built and tests already ran in CI
|
|
# ---------------------------------------------------------------------------
|
|
if [[ "$SKIP_BUILD" == false ]]; then
|
|
# Auto-bump patch version with deploy timestamp: 0.1.2-20260412_223433
|
|
_ver=$(cat "${REPO_ROOT}/VERSION.txt" | head -1)
|
|
_base=${_ver%%-*}
|
|
_major=${_base%%.*}; _rest=${_base#*.}; _minor=${_rest%%.*}; _patch=${_rest#*.}
|
|
echo "${_major}.${_minor}.$((_patch + 1))-${TIMESTAMP}" > "${REPO_ROOT}/VERSION.txt"
|
|
|
|
echo "==> [1/10] Building admin frontend..."
|
|
cd "$REPO_ROOT/codebase/@features/admin/frontend-public" && bun run build
|
|
|
|
echo "==> [1b/10] Building newsletter frontend..."
|
|
cd "$REPO_ROOT/codebase/@features/comm-newsletter/frontend-admin" && bun run build
|
|
|
|
echo "==> [2/10] Type-checking + bundling @features/api monolith..."
|
|
cd "$REPO_ROOT/codebase/@features/api"
|
|
bun run typecheck
|
|
# Build stamp injected into the bundle so GET /health reports exactly which
|
|
# build is live (version, monotonic build count, git sha, UTC build time).
|
|
_bi_ver="$(cat "${REPO_ROOT}/VERSION.txt" | head -1)"
|
|
_bi_bc="$(cat "${REPO_ROOT}/BUILD_COUNT" 2>/dev/null | head -1)"
|
|
_bi_sha="$(git -C "$REPO_ROOT" rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
|
_bi_time="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
|
bun build --target=node --external better-sqlite3 --external sharp \
|
|
--define "__BUILD_INFO__={\"version\":\"${_bi_ver}\",\"buildCount\":\"${_bi_bc}\",\"sha\":\"${_bi_sha}\",\"builtAt\":\"${_bi_time}\"}" \
|
|
src/app/server.ts --outfile="${API_BUNDLE}" --sourcemap=none
|
|
cd "$SCRIPT_DIR"
|
|
|
|
echo "==> [2b/10] Type-checking + bundling newsletter API..."
|
|
cd "$REPO_ROOT/codebase/@features/comm-newsletter/backend-api" && bun run typecheck && bun run build:prod
|
|
cd "$SCRIPT_DIR"
|
|
|
|
echo "==> [2.5/10] Running @features/api unit tests..."
|
|
cd "$REPO_ROOT/codebase/@features/api"
|
|
if ! bun run test 2>&1; then
|
|
echo "ERROR: @features/api unit tests FAILED — deploy aborted." >&2
|
|
exit 1
|
|
fi
|
|
echo " Unit tests passed."
|
|
cd "$SCRIPT_DIR"
|
|
|
|
if [[ "$SKIP_E2E" == "true" ]]; then
|
|
echo "==> [2.6/10] Skipping admin E2E smoke tests (--skip-e2e)."
|
|
else
|
|
echo "==> [2.6/10] Running admin E2E smoke tests..."
|
|
echo " (ephemeral DB + dev test env — not VPS secrets from step [9/10]; use --skip-e2e to bypass)"
|
|
cd "$SCRIPT_DIR"
|
|
if ! bun run test:e2e 2>&1; then
|
|
echo "ERROR: Admin E2E smoke tests FAILED — deploy aborted." >&2
|
|
exit 1
|
|
fi
|
|
echo " Smoke tests passed."
|
|
cd "$SCRIPT_DIR"
|
|
fi
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [3/10] Backup current dist and API on remote before deploying
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [3/10] Backing up current dist and API on ${REMOTE}..."
|
|
ssh "$REMOTE" bash -euo pipefail <<ENDSSH
|
|
set -euo pipefail
|
|
mkdir -p "${REMOTE_BACKUPS_DIST}" "${REMOTE_BACKUPS_API}"
|
|
|
|
if [[ -d "${REMOTE_DIST}" && -n "\$(ls -A '${REMOTE_DIST}' 2>/dev/null)" ]]; then
|
|
rsync -a "${REMOTE_DIST}/" "${BACKUP_DIST}/"
|
|
echo " dist backup: ${BACKUP_DIST}"
|
|
find "${REMOTE_BACKUPS_DIST}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
|
else
|
|
echo " No existing dist — first deploy."
|
|
fi
|
|
|
|
if [[ -d "${REMOTE_API}" && -n "\$(ls -A '${REMOTE_API}' 2>/dev/null)" ]]; then
|
|
rsync -a --exclude='node_modules' "${REMOTE_API}/" "${BACKUP_API}/"
|
|
echo " API backup: ${BACKUP_API}"
|
|
find "${REMOTE_BACKUPS_API}" -maxdepth 1 -mindepth 1 -type d -mtime +7 -exec rm -rf {} + 2>/dev/null || true
|
|
else
|
|
echo " No existing API — first deploy."
|
|
fi
|
|
ENDSSH
|
|
BACKUPS_CREATED=true
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [4/10] Deploy frontend dist
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [4/10] Deploying admin frontend dist/ to ${REMOTE}..."
|
|
rsync -avz --delete "$REPO_ROOT/codebase/@features/admin/frontend-public/dist/" "${REMOTE}:${REMOTE_DIST}/"
|
|
|
|
echo "==> [4b/10] Deploying newsletter frontend dist/ to ${REMOTE}..."
|
|
ssh "$REMOTE" "mkdir -p /var/www/quinn.admin/newsletter/dist"
|
|
rsync -avz --delete "$REPO_ROOT/codebase/@features/comm-newsletter/frontend-admin/dist/" "${REMOTE}:/var/www/quinn.admin/newsletter/dist/"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [4c/10] Deploy admin gallery photo store
|
|
# The admin backend serves /admin/photos/files/* from REMOTE_PHOTOS (its
|
|
# PHOTOS_DIR); nginx rewrites /photos/* → /admin/photos/files/*. This set is
|
|
# the DESCRIPTIVE-named gallery photos (matching the gallery_items DB) plus the
|
|
# adversary/ visualization trees — distinct from the public quinn.www hash-named
|
|
# set, so it lives in its own dir and the public vhost is unaffected.
|
|
# Additive (NO --delete): repo photos are authoring source-of-truth; never prune
|
|
# the serving copy out from under live admin traffic.
|
|
# ---------------------------------------------------------------------------
|
|
if [[ -d "$PHOTOS_SRC" ]]; then
|
|
echo "==> [4c/10] Deploying admin gallery photos to ${REMOTE}:${REMOTE_PHOTOS}..."
|
|
ssh "$REMOTE" "install -d -o www-data -g www-data ${REMOTE_PHOTOS}"
|
|
rsync -avz "${PHOTOS_SRC}/" "${REMOTE}:${REMOTE_PHOTOS}/"
|
|
ssh "$REMOTE" "chown -R www-data:www-data ${REMOTE_PHOTOS}"
|
|
else
|
|
echo "==> [4c/10] WARNING: ${PHOTOS_SRC} not found on this host — skipping admin gallery photo sync." >&2
|
|
echo " Admin gallery thumbnails + variant lenses will 404 until the photo set is present." >&2
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [4d/10] Deploy clean masters (originals) for ?master=1 downloads
|
|
# PROTECT_PHOTOS_DIR=/var/www/quinn.admin/originals in the service unit.
|
|
# CRITICAL security properties:
|
|
# - NOT reachable via any nginx static root/location (only SSO-gated backend reads it)
|
|
# - www-data readable (service runs as www-data)
|
|
# - Additive (NO --delete): never prune originals out from under live traffic
|
|
# ---------------------------------------------------------------------------
|
|
if [[ -d "$ORIGINALS_SRC" ]]; then
|
|
echo "==> [4d/10] Deploying clean masters to ${REMOTE}:${REMOTE_ORIGINALS}..."
|
|
ssh "$REMOTE" "install -d -o www-data -g www-data -m 750 ${REMOTE_ORIGINALS}"
|
|
rsync -avz "${ORIGINALS_SRC}/" "${REMOTE}:${REMOTE_ORIGINALS}/"
|
|
ssh "$REMOTE" "chown -R www-data:www-data ${REMOTE_ORIGINALS} && chmod -R 640 ${REMOTE_ORIGINALS}/*"
|
|
echo " Clean masters synced. Verify nginx has NO static location for ${REMOTE_ORIGINALS}."
|
|
else
|
|
echo "==> [4d/10] WARNING: ${ORIGINALS_SRC} not found on this host — skipping originals sync." >&2
|
|
echo " ?master=1 downloads will fall back to PHOTOS_DIR (colorswapped versions)." >&2
|
|
fi
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [5/10] Deploy @features/api monolith bundle
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [5/10] Deploying @features/api monolith bundle to ${REMOTE}:${REMOTE_API}..."
|
|
# Only ship the compiled bundle — no sources, no node_modules (synced below).
|
|
ssh "$REMOTE" "mkdir -p ${REMOTE_API}/dist"
|
|
rsync -avz \
|
|
"$REPO_ROOT/codebase/@features/api/${API_BUNDLE}" "${REMOTE}:${REMOTE_API}/${API_BUNDLE}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [5b/10] Deploy newsletter API bundle
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [5b/10] Deploying newsletter API bundle to ${REMOTE}..."
|
|
ssh "$REMOTE" "mkdir -p /opt/quinn-newsletter-api"
|
|
rsync -avz --delete \
|
|
--exclude='src' \
|
|
--exclude='tsconfig.json' \
|
|
--exclude='node_modules' \
|
|
--exclude='.env' \
|
|
--exclude='data' \
|
|
"$REPO_ROOT/codebase/@features/comm-newsletter/backend-api/" "${REMOTE}:/opt/quinn-newsletter-api/"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [6/10] Install production dependencies on remote (native modules like sharp)
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [6/10] Installing @features/api deps locally + syncing node_modules to ${REMOTE}..."
|
|
# VPS has no public route to Forgejo/Verdaccio, so resolve @lilith deps here
|
|
# using the local .npmrc, then rsync the built node_modules over. Same
|
|
# pattern as quinn.api/deploy.sh — keeps the VPS registry-agnostic.
|
|
ADMIN_DEPS_DIR="$(mktemp -d)"
|
|
# Strip workspace:* deps — they are inlined by `bun build` into server.node.js,
|
|
# so node_modules at runtime never needs them. npm install refuses workspace:
|
|
# protocol outside a real workspace context, hence the strip.
|
|
node -e '
|
|
const fs = require("fs");
|
|
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
if (!pkg[section]) continue;
|
|
for (const [name, ver] of Object.entries(pkg[section])) {
|
|
if (typeof ver === "string" && ver.startsWith("workspace:")) delete pkg[section][name];
|
|
}
|
|
}
|
|
fs.writeFileSync(process.argv[2], JSON.stringify(pkg, null, 2));
|
|
' "$REPO_ROOT/codebase/@features/api/package.json" "$ADMIN_DEPS_DIR/package.json"
|
|
echo '@lilith:registry=http://134.199.243.61:4873/' > "$ADMIN_DEPS_DIR/.npmrc"
|
|
(cd "$ADMIN_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3)
|
|
rsync -avz --delete "$ADMIN_DEPS_DIR/node_modules/" "${REMOTE}:${REMOTE_API}/node_modules/"
|
|
rm -rf "${ADMIN_DEPS_DIR:-/nonexistent}"
|
|
|
|
echo "==> [6b/10] Installing newsletter API dependencies locally + syncing to ${REMOTE}..."
|
|
NL_DEPS_DIR="$(mktemp -d)"
|
|
node -e '
|
|
const fs = require("fs");
|
|
const pkg = JSON.parse(fs.readFileSync(process.argv[1], "utf8"));
|
|
for (const section of ["dependencies", "devDependencies", "peerDependencies", "optionalDependencies"]) {
|
|
if (!pkg[section]) continue;
|
|
for (const [name, ver] of Object.entries(pkg[section])) {
|
|
if (typeof ver === "string" && ver.startsWith("workspace:")) delete pkg[section][name];
|
|
}
|
|
}
|
|
fs.writeFileSync(process.argv[2], JSON.stringify(pkg, null, 2));
|
|
' "$REPO_ROOT/codebase/@features/comm-newsletter/backend-api/package.json" "$NL_DEPS_DIR/package.json"
|
|
echo '@lilith:registry=http://134.199.243.61:4873/' > "$NL_DEPS_DIR/.npmrc"
|
|
(cd "$NL_DEPS_DIR" && npm install --omit=dev --legacy-peer-deps 2>&1 | tail -3)
|
|
rsync -avz --delete "$NL_DEPS_DIR/node_modules/" "${REMOTE}:/opt/quinn-newsletter-api/node_modules/"
|
|
rm -rf "${NL_DEPS_DIR:-/nonexistent}"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [7/10] nginx: prod.conf
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [7/10] Syncing nginx prod.conf (admin.transquinnftw.com)..."
|
|
scp "$SCRIPT_DIR/nginx/prod.conf" "${REMOTE}:/etc/nginx/sites-available/admin.transquinnftw.com"
|
|
ssh "$REMOTE" "ln -sf /etc/nginx/sites-available/admin.transquinnftw.com /etc/nginx/sites-enabled/admin.transquinnftw.com 2>/dev/null || true"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [8/10] nginx test + reload
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [8/10] Testing and reloading nginx..."
|
|
ssh "$REMOTE" "nginx -t && systemctl reload nginx"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [9/10] Sync systemd service files + restart services
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [9/10] Syncing systemd service files and restarting services..."
|
|
scp "$SCRIPT_DIR/scripts/quinn-admin-api.service" "${REMOTE}:/etc/systemd/system/quinn-admin-api.service"
|
|
scp "$SCRIPT_DIR/scripts/quinn-newsletter-api.service" "${REMOTE}:/etc/systemd/system/quinn-newsletter-api.service"
|
|
|
|
# Provision secrets file for newsletter if it doesn't exist yet
|
|
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
|
|
if [[ ! -f /etc/quinn-newsletter-api/secrets.env ]]; then
|
|
mkdir -p /etc/quinn-newsletter-api
|
|
# Copy JWT_SECRET from admin secrets so the shared cookie works
|
|
JWT_SECRET="$(grep '^JWT_SECRET=' /etc/quinn-admin-api/secrets.env | cut -d= -f2-)"
|
|
printf 'JWT_SECRET=%s\nSMTP_HOST=localhost\nSMTP_PORT=587\nSMTP_REQUIRE_TLS=false\n' "$JWT_SECRET" \
|
|
> /etc/quinn-newsletter-api/secrets.env
|
|
chmod 600 /etc/quinn-newsletter-api/secrets.env
|
|
chown root:root /etc/quinn-newsletter-api/secrets.env
|
|
echo " newsletter secrets.env provisioned."
|
|
else
|
|
echo " newsletter secrets.env already exists — skipping."
|
|
fi
|
|
ENDSSH
|
|
|
|
# Provision mail-admin SSH access: www-data key → root@127.0.0.1 → docker
|
|
ssh "$REMOTE" bash -euo pipefail <<'ENDSSH'
|
|
# root can always run docker — no group add needed
|
|
|
|
# Create www-data SSH directory
|
|
install -d -m 700 -o www-data -g www-data /var/www/.ssh
|
|
|
|
# Generate ed25519 key for www-data if not present
|
|
if [[ ! -f /var/www/.ssh/id_ed25519 ]]; then
|
|
ssh-keygen -t ed25519 -N '' -C 'www-data@quinn-vps-mailcmd' \
|
|
-f /var/www/.ssh/id_ed25519
|
|
chown www-data:www-data /var/www/.ssh/id_ed25519 /var/www/.ssh/id_ed25519.pub
|
|
echo " www-data SSH key generated."
|
|
else
|
|
echo " www-data SSH key already exists — skipping keygen."
|
|
fi
|
|
|
|
# Add www-data pubkey to root's authorized_keys (idempotent)
|
|
PUBKEY="$(cat /var/www/.ssh/id_ed25519.pub)"
|
|
install -d -m 700 -o root -g root /root/.ssh
|
|
touch /root/.ssh/authorized_keys
|
|
chmod 600 /root/.ssh/authorized_keys
|
|
chown root:root /root/.ssh/authorized_keys
|
|
if ! grep -qF "$PUBKEY" /root/.ssh/authorized_keys; then
|
|
# Ensure the file ends with a newline before appending to avoid key concatenation
|
|
[[ -s /root/.ssh/authorized_keys ]] && tail -c1 /root/.ssh/authorized_keys | grep -qP '[^\n]' && echo "" >> /root/.ssh/authorized_keys
|
|
echo "$PUBKEY" >> /root/.ssh/authorized_keys
|
|
echo " www-data pubkey added to root authorized_keys."
|
|
else
|
|
echo " www-data pubkey already in root authorized_keys — skipping."
|
|
fi
|
|
|
|
# Pre-populate www-data known_hosts so BatchMode=yes doesn't reject localhost
|
|
ssh-keyscan -H 127.0.0.1 2>/dev/null > /var/www/.ssh/known_hosts
|
|
chown www-data:www-data /var/www/.ssh/known_hosts
|
|
chmod 600 /var/www/.ssh/known_hosts
|
|
|
|
# QUINN_MY_SERVICE_TOKEN — sourced from SSO secrets (the vps distribution point for
|
|
# the plum-canonical token), NOT generated here. Admin self-generation was a
|
|
# split-brain source: it could mint a value that disagreed with my-api. Re-synced
|
|
# every deploy so a plum rotation (applied via quinn.sso deploy) propagates here too.
|
|
SECRETS=/etc/quinn-admin-api/secrets.env
|
|
SSO_SECRETS=/etc/quinn-sso-api/secrets.env
|
|
MY_SVC_TOKEN="$(grep '^QUINN_MY_SERVICE_TOKEN=' "$SSO_SECRETS" 2>/dev/null | cut -d= -f2- || true)"
|
|
if [[ -z "$MY_SVC_TOKEN" ]]; then
|
|
echo "ERROR: QUINN_MY_SERVICE_TOKEN missing from $SSO_SECRETS — deploy quinn.sso first." >&2
|
|
exit 1
|
|
fi
|
|
if grep -q '^QUINN_MY_SERVICE_TOKEN=' "$SECRETS"; then
|
|
sed -i "s|^QUINN_MY_SERVICE_TOKEN=.*|QUINN_MY_SERVICE_TOKEN=${MY_SVC_TOKEN}|" "$SECRETS"
|
|
echo " QUINN_MY_SERVICE_TOKEN re-synced from SSO secrets."
|
|
else
|
|
echo "QUINN_MY_SERVICE_TOKEN=${MY_SVC_TOKEN}" >> "$SECRETS"
|
|
echo " QUINN_MY_SERVICE_TOKEN added from SSO secrets."
|
|
fi
|
|
chmod 600 "$SECRETS"
|
|
|
|
# SERVICE_TOKEN is required for service-to-service auth (@features/api monolith).
|
|
# It must be provisioned manually in /etc/quinn-admin-api/secrets.env — this
|
|
# deploy script will not generate it automatically to avoid silent token churn.
|
|
if ! grep -q '^SERVICE_TOKEN=' "$SECRETS"; then
|
|
echo "ERROR: SERVICE_TOKEN is missing from ${SECRETS} on $(hostname)." >&2
|
|
echo " Add it manually: echo 'SERVICE_TOKEN=<token>' >> ${SECRETS}" >&2
|
|
echo " Generate a token with: openssl rand -hex 32" >&2
|
|
exit 1
|
|
fi
|
|
echo " SERVICE_TOKEN present in secrets.env."
|
|
|
|
# QUINN_DB_URL is required for the @features/api monolith (Postgres).
|
|
if ! grep -q '^QUINN_DB_URL=' "$SECRETS"; then
|
|
echo "ERROR: QUINN_DB_URL is missing from ${SECRETS} on $(hostname)." >&2
|
|
echo " Add it manually: echo 'QUINN_DB_URL=postgres://...' >> ${SECRETS}" >&2
|
|
exit 1
|
|
fi
|
|
echo " QUINN_DB_URL present in secrets.env."
|
|
|
|
# QUINN_MACSYNC_DB_URL is required for the @features/api monolith.
|
|
if ! grep -q '^QUINN_MACSYNC_DB_URL=' "$SECRETS"; then
|
|
echo "ERROR: QUINN_MACSYNC_DB_URL is missing from ${SECRETS} on $(hostname)." >&2
|
|
echo " Add it manually: echo 'QUINN_MACSYNC_DB_URL=postgres://...' >> ${SECRETS}" >&2
|
|
exit 1
|
|
fi
|
|
echo " QUINN_MACSYNC_DB_URL present in secrets.env."
|
|
ENDSSH
|
|
|
|
ssh "$REMOTE" "systemctl daemon-reload && systemctl enable quinn-admin-api quinn-newsletter-api && systemctl restart quinn-admin-api quinn-newsletter-api"
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# [10/10] Health check — abort on failure (does not echo WARN)
|
|
# ---------------------------------------------------------------------------
|
|
echo "==> [10/10] Verifying admin site is live..."
|
|
sleep 5
|
|
if ! curl -sf -A 'Mozilla/5.0' https://admin.transquinnftw.com/health > /dev/null; then
|
|
echo "ERROR: health check failed — https://admin.transquinnftw.com/health did not respond." >&2
|
|
exit 1
|
|
fi
|
|
echo " Health check passed."
|
|
|
|
echo ""
|
|
echo "Deployed quinn.admin successfully at $(date '+%Y-%m-%d %H:%M:%S %Z')"
|
|
echo "To roll back: bash $SCRIPT_DIR/deploy.sh --rollback"
|