#!/bin/sh set -eu usage() { cat >&2 <<'EOF' Usage: ./deploy.sh test|prod Targets: test test.example.org/ -> /path/to/cartesium-test:2222 prod example.org/ -> /path/to/cartesium-prod:3333 Set CARTESIUM_DEPLOY_REMOTE to the SSH destination, for example: CARTESIUM_DEPLOY_REMOTE=root@your-host ./deploy.sh test The deployment waits for AppView's filtered archive backfill to complete. Override the bounded wait when a first deployment needs longer: CARTESIUM_APPVIEW_VERIFY_TIMEOUT=7200 CARTESIUM_DEPLOY_REMOTE=... ./deploy.sh test EOF } TARGET=${1:-} REMOTE=${CARTESIUM_DEPLOY_REMOTE:?Set CARTESIUM_DEPLOY_REMOTE to the SSH destination} APPVIEW_VERIFY_TIMEOUT=${CARTESIUM_APPVIEW_VERIFY_TIMEOUT:-3600} APPVIEW_VERIFY_INTERVAL=${CARTESIUM_APPVIEW_VERIFY_INTERVAL:-15} case "$APPVIEW_VERIFY_TIMEOUT" in ''|*[!0-9]*) echo 'CARTESIUM_APPVIEW_VERIFY_TIMEOUT must be a positive number of seconds.' >&2 exit 2 ;; esac case "$APPVIEW_VERIFY_INTERVAL" in ''|*[!0-9]*) echo 'CARTESIUM_APPVIEW_VERIFY_INTERVAL must be a positive number of seconds.' >&2 exit 2 ;; esac if [ "$APPVIEW_VERIFY_TIMEOUT" -lt 1 ] || [ "$APPVIEW_VERIFY_INTERVAL" -lt 1 ]; then echo 'CARTESIUM_APPVIEW_VERIFY_TIMEOUT and CARTESIUM_APPVIEW_VERIFY_INTERVAL must be positive.' >&2 exit 2 fi case "$TARGET" in test) BASE_PATH= ORIGIN=https://test.example.org HOST_PORT=2222 REMOTE_DIR=/path/to/cartesium-test COMPOSE_PROJECT=cartesium-test ;; prod) BASE_PATH= ORIGIN=https://example.org HOST_PORT=3333 REMOTE_DIR=/path/to/cartesium-prod COMPOSE_PROJECT=cartesium-prod ;; *) usage exit 2 ;; esac REPO_ROOT=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) APP_ROOT=$REPO_ROOT/apps/web echo "Preparing $REMOTE:$REMOTE_DIR..." ssh "$REMOTE" "install -d -m 0755 '$REMOTE_DIR'" rsync -avz "$APP_ROOT/.env.example" "$REMOTE:$REMOTE_DIR/" ssh "$REMOTE" \ "test -f '$REMOTE_DIR/.env' || { echo 'Missing $REMOTE_DIR/.env.' >&2; echo 'Run: cd $REMOTE_DIR && cp .env.example .env, then edit it.' >&2; exit 1; }" echo "Building Cartesium for $TARGET ($ORIGIN)..." CARTESIUM_ORIGIN="$ORIGIN" CARTESIUM_BASE_PATH="$BASE_PATH" npm --prefix "$APP_ROOT" run build rsync -avz "$APP_ROOT/build/" "$REMOTE:$REMOTE_DIR/build/" rsync -avz \ "$APP_ROOT/Dockerfile" \ "$APP_ROOT/compose.yaml" \ "$APP_ROOT/.dockerignore" \ "$REMOTE:$REMOTE_DIR/" rsync -avz --delete \ --exclude node_modules \ --exclude dist \ --exclude .svelte-kit \ "$APP_ROOT/appview/" \ "$REMOTE:$REMOTE_DIR/appview/" if ! ssh "$REMOTE" \ "cd '$REMOTE_DIR' && CARTESIUM_HOST_PORT='$HOST_PORT' docker compose -p '$COMPOSE_PROJECT' up --detach --build --remove-orphans --wait"; then echo "Deployment services did not become healthy. Inspect them with:" >&2 echo " ssh '$REMOTE' \"cd '$REMOTE_DIR' && docker compose -p '$COMPOSE_PROJECT' ps\"" >&2 echo " ssh '$REMOTE' \"cd '$REMOTE_DIR' && docker compose -p '$COMPOSE_PROJECT' logs --tail=100 web appview postgres\"" >&2 exit 1 fi echo "Verifying AppView index state on $REMOTE:$REMOTE_DIR..." ssh "$REMOTE" sh -s -- "$REMOTE_DIR" "$COMPOSE_PROJECT" "$APPVIEW_VERIFY_TIMEOUT" "$APPVIEW_VERIFY_INTERVAL" <<'REMOTE_APPVIEW_VERIFY' set -eu REMOTE_DIR=$1 COMPOSE_PROJECT=$2 VERIFY_TIMEOUT=$3 VERIFY_INTERVAL=$4 cd "$REMOTE_DIR" diagnostics() { echo 'Safe AppView diagnostics:' >&2 if docker compose -p "$COMPOSE_PROJECT" ps web appview postgres 2>/dev/null; then : fi echo "Inspect recent service output with: cd '$REMOTE_DIR' && docker compose -p '$COMPOSE_PROJECT' logs --tail=100 appview" >&2 echo "Inspect the database service with: cd '$REMOTE_DIR' && docker compose -p '$COMPOSE_PROJECT' logs --tail=100 postgres" >&2 echo "The verifier never prints .env, compose config, database URLs, or Jetstream credentials." >&2 } probe_appview() { # Keep parsing inside the AppView image, where node is already available. The # probe emits only fixed, non-secret fields and suppresses Docker/error output. timeout 10 docker compose -p "$COMPOSE_PROJECT" exec -T appview node -e ' fetch("http://127.0.0.1:4100/readyz", { signal: AbortSignal.timeout(5000) }) .then(async (response) => { const text = await response.text(); let payload; try { payload = JSON.parse(text); } catch { process.exitCode = 2; return; } const index = payload && payload.index; if ( !index || typeof index !== "object" || typeof index.state !== "string" || typeof index.backfillComplete !== "boolean" ) { process.exitCode = 3; return; } const sequence = (value) => Number.isSafeInteger(value) && value >= 0 ? String(value) : "-"; const lastEventAt = typeof index.lastEventAt === "string" && index.lastEventAt.length > 0 ? index.lastEventAt.replace(/[\t\r\n]/g, "") || "-" : "-"; process.stdout.write([ String(response.status), index.state, index.backfillComplete ? "true" : "false", sequence(index.backfillCursor), sequence(index.backfillTarget), lastEventAt ].join("\t")); }) .catch(() => { process.exitCode = 4; }); ' 2>/dev/null } started_at=$(date +%s) last_cursor= last_target= last_state= probe_failures=0 echo "AppView archive backfill may take a while on the first deployment; waiting up to ${VERIFY_TIMEOUT}s." while :; do now=$(date +%s) elapsed=$((now - started_at)) if [ "$elapsed" -ge "$VERIFY_TIMEOUT" ]; then echo "ERROR: AppView index verification timed out after ${VERIFY_TIMEOUT}s; the backfill may be stalled." >&2 echo "Last observed AppView state: ${last_state:-unavailable}, cursor ${last_cursor:--}, target ${last_target:--}." >&2 diagnostics exit 1 fi if probe=$(probe_appview); then probe_failures=0 else probe_failures=$((probe_failures + 1)) if [ "$probe_failures" -ge 3 ]; then echo 'ERROR: AppView /readyz is unreachable or returned an invalid response three times.' >&2 diagnostics exit 1 fi echo "AppView /readyz is not reachable yet (attempt ${probe_failures}/3); retrying in ${VERIFY_INTERVAL}s." >&2 sleep "$VERIFY_INTERVAL" continue fi # The probe has exactly six tab-separated, non-secret fields. Disable glob # expansion while splitting so a malformed response cannot match a filename. set -f old_ifs=$IFS IFS=' ' set -- $probe IFS=$old_ifs set +f if [ "$#" -ne 6 ]; then echo 'ERROR: AppView /readyz returned an invalid index response.' >&2 diagnostics exit 1 fi http_status=$1 state=$2 backfill_complete=$3 cursor=$4 target=$5 last_event_at=$6 case "$cursor" in ''|*[!0-9]*) echo 'ERROR: AppView /readyz returned an invalid backfill cursor.' >&2 diagnostics exit 1 ;; esac case "$target" in -) ;; ''|*[!0-9]*) echo 'ERROR: AppView /readyz returned an invalid backfill target.' >&2 diagnostics exit 1 ;; esac case "$http_status" in ''|*[!0-9]*) echo 'ERROR: AppView /readyz returned an invalid HTTP status.' >&2 diagnostics exit 1 ;; esac if [ "$http_status" -ne 200 ]; then if [ "$state" = degraded ]; then echo "ERROR: AppView reports degraded (HTTP ${http_status}; cursor ${cursor}, target ${target})." >&2 else echo "ERROR: AppView /readyz returned HTTP ${http_status} (state ${state})." >&2 fi echo "Last event timestamp: ${last_event_at}." >&2 diagnostics exit 1 fi case "$state:$backfill_complete" in degraded:*) echo "ERROR: AppView reports degraded (cursor ${cursor}, target ${target})." >&2 echo "Last event timestamp: ${last_event_at}." >&2 diagnostics exit 1 ;; complete:true) if [ "$target" = '-' ]; then echo 'ERROR: AppView reported complete without a pinned backfill target.' >&2 diagnostics exit 1 fi echo "AppView index verified complete (cursor ${cursor}, target ${target})." exit 0 ;; running:false) if [ "$state" != "$last_state" ] || [ "$cursor" != "$last_cursor" ] || [ "$target" != "$last_target" ]; then if [ "$target" = '-' ]; then echo "AppView backfill is running; Jetstream target is being planned (elapsed ${elapsed}s)." else echo "AppView backfill is running (cursor ${cursor}, target ${target}; elapsed ${elapsed}s)." fi fi last_state=$state last_cursor=$cursor last_target=$target ;; *) echo "ERROR: AppView returned inconsistent index state ${state}/${backfill_complete}." >&2 diagnostics exit 1 ;; esac sleep "$VERIFY_INTERVAL" done REMOTE_APPVIEW_VERIFY