Ctrl K
ComunidadGuías…

scripts/validate-production-image.sh

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env bash
set -euo pipefail

usage() {
  cat <<'EOF'
Validate the production control-plane image before a remote VPS run.

This checks the production Compose file, builds the app image, and verifies that
the runtime image contains:

- the bundled Linux devpush-agent binary used by the server installer
- the standalone migration and database-backup runners, plus SQL migrations
- the built TanStack Start server entrypoint
- no development dependency tree or TypeScript runtime

Optional environment:
  DEVPUSH_PRODUCTION_IMAGE  Image tag to build, default: devpush-app:local
  DEVPUSH_PRODUCTION_ENV    Env file for Compose config, default: deploy/.env.production.example

Example:
  pnpm validate:production
EOF
}

if [[ "${1:-}" == "--" ]]; then
  shift
fi

if [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
  usage
  exit 0
fi

image="${DEVPUSH_PRODUCTION_IMAGE:-devpush-app:local}"
env_file="${DEVPUSH_PRODUCTION_ENV:-deploy/.env.production.example}"

step() {
  printf '\n==> %s\n' "$1"
}

compose() {
  if docker compose version >/dev/null 2>&1; then
    docker compose "$@"
    return
  fi

  if command -v docker-compose >/dev/null 2>&1; then
    docker-compose "$@"
    return
  fi

  echo "Docker Compose is required." >&2
  exit 1
}

# The tracked example deliberately contains blank secrets; use dummy probe values.
if [[ "$env_file" == "deploy/.env.production.example" ]]; then
  export BETTER_AUTH_SECRET=production-image-validation-auth-secret
  export DEVPUSH_SECRET_ENCRYPTION_KEY=production-image-validation-encryption-secret
  export DEVPUSH_CRON_SECRET=production-image-validation-cron-secret
fi

step "Checking production Compose config"
compose --env-file "$env_file" -f deploy/compose.production.yml config >/dev/null

step "Building production app image"
export DEVPUSH_PRODUCTION_IMAGE="$image"
compose --env-file "$env_file" -f deploy/compose.production.yml build app

step "Checking production runtime image"
docker run --rm "$image" sh -lc '
  set -euo pipefail
  test "$(id -u)" -ne 0
  test -w /app/storage
  test -w /repo/app/public/uploads
  test -x /repo/bin/devpush-agent-linux-amd64
  test -x /repo/bin/devpush-agent-linux-arm64
  case "$(uname -m)" in
    x86_64) /repo/bin/devpush-agent-linux-amd64 --help >/dev/null ;;
    aarch64) /repo/bin/devpush-agent-linux-arm64 --help >/dev/null ;;
    *) echo "Unsupported validation architecture: $(uname -m)" >&2; exit 1 ;;
  esac
  test -f /repo/app/.output/server/index.mjs
  test -f /repo/app/.maintenance/migrate.mjs
  test -f /repo/app/.maintenance/migrate.mjs.map
  test -f /repo/app/src/server/db/migrations-sqlite/meta/_journal.json
  test -f /repo/app/src/server/db/migrations-logs/meta/_journal.json
  test -f /repo/app/.maintenance/backup-sqlite.mjs
  test ! -e /repo/node_modules
  test -L /repo/app/node_modules
  test ! -e /repo/app/scripts/migrate.ts
  ! command -v npm >/dev/null
  ! command -v yarn >/dev/null
  ! command -v corepack >/dev/null
  ! command -v pnpm >/dev/null
  ! command -v tsx >/dev/null
  node --check /repo/app/.maintenance/migrate.mjs
'

missing_database_output="$(docker run --rm "$image" node .maintenance/migrate.mjs 2>&1 || true)"
if ! grep -q 'DEVPUSH_DATABASE_PATH is required in production' <<<"$missing_database_output"; then
  echo "Production migration runner did not reject a missing DEVPUSH_DATABASE_PATH." >&2
  exit 1
fi

step "Exercising production migrations and health check"
probe_id="devpush-production-probe-${RANDOM:-0}-$$"
probe_network="${probe_id}-net"
probe_volume="${probe_id}-data"
probe_app="${probe_id}-app"
probe_cron_secret="production-image-validation-cron-secret"
probe_dir="$(mktemp -d)"

# Keep real operator configuration out of the disposable Compose probe.
probe_compose() (
  for name in "${!DEVPUSH_@}" "${!BETTER_AUTH_@}" "${!GITHUB_APP_@}" "${!GOOGLE_@}" EMAIL_FROM RESEND_API_KEY; do
    unset "$name"
  done
  export DEVPUSH_PRODUCTION_IMAGE="$image"
  compose -p "$probe_id" --env-file "$probe_dir/compose.env" -f deploy/compose.production.yml "$@"
)
cat > "$probe_dir/compose.env" <<EOF
BETTER_AUTH_SECRET=production-image-validation-auth-secret
DEVPUSH_SECRET_ENCRYPTION_KEY=production-image-validation-encryption-secret
DEVPUSH_CRON_SECRET=$probe_cron_secret
DEVPUSH_BASE_URL=http://localhost:3000
DEVPUSH_HTTP_PORT=127.0.0.1:0
EOF

cleanup_probe() {
  probe_compose down --volumes --remove-orphans >/dev/null 2>&1 || true
  rm -rf "$probe_dir"
  docker rm -f "$probe_app" >/dev/null 2>&1 || true
  docker volume rm "$probe_volume" >/dev/null 2>&1 || true
  docker network rm "$probe_network" >/dev/null 2>&1 || true
}
trap cleanup_probe EXIT INT TERM

docker network create "$probe_network" >/dev/null
docker volume create "$probe_volume" >/dev/null

docker run --rm \
  --network "$probe_network" \
  -v "$probe_volume:/app/storage" \
  -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \
  -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \
  "$image" \
  node --enable-source-maps /repo/app/.maintenance/migrate.mjs

docker run -d \
  --name "$probe_app" \
  --network "$probe_network" \
  -v "$probe_volume:/app/storage" \
  -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \
  -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \
  -e BETTER_AUTH_SECRET=production-image-validation-secret \
  -e DEVPUSH_CRON_SECRET=production-image-validation-cron-secret \
  -e DEVPUSH_BASE_URL=http://localhost:3000 \
  "$image" >/dev/null

app_ready=false
for _ in $(seq 1 60); do
  if docker exec "$probe_app" wget -qO- http://127.0.0.1:3000/api/health 2>/dev/null | grep -q '"status":"ok"'; then
    app_ready=true
    break
  fi
  if [[ "$(docker inspect "$probe_app" --format '{{.State.Running}}')" != "true" ]]; then
    break
  fi
  sleep 1
done
if [[ "$app_ready" != "true" ]]; then
  docker logs "$probe_app" >&2
  echo "Production image health check failed." >&2
  exit 1
fi

step "Checking login and scheduled maintenance"
docker exec "$probe_app" curl --fail --silent http://127.0.0.1:3000/auth/login >/dev/null
docker exec "$probe_app" curl --fail --silent --request POST \
  --header "Authorization: Bearer $probe_cron_secret" \
  http://127.0.0.1:3000/api/cron/maintenance >/dev/null

step "Checking online backups and restored snapshots"
docker run --rm \
  -v "$probe_volume:/app/storage" \
  -e DEVPUSH_DATABASE_PATH=/app/storage/devpush.sqlite \
  -e DEVPUSH_LOG_DATABASE_PATH=/app/storage/logs.sqlite \
  -e DEVPUSH_DB_BACKUP_DIR=/app/storage/backups \
  "$image" node .maintenance/backup-sqlite.mjs
docker run --rm -v "$probe_volume:/app/storage" "$image" node --input-type=module -e '
  import { createRequire } from "node:module";
  import { readdirSync } from "node:fs";
  const require = createRequire("/repo/app/.output/server/index.mjs");
  const Sqlite = require("better-sqlite3");
  const files = readdirSync("/app/storage/backups");
  if (files.length !== 2) throw new Error("Expected two snapshots");
  for (const file of files) {
    const db = new Sqlite("/app/storage/backups/" + file, { readonly: true });
    if (db.pragma("integrity_check", { simple: true }) !== "ok") throw new Error("Snapshot integrity failure");
    if (!db.prepare("select count(*) as n from sqlite_master where type = ?").get("table").n) throw new Error("Missing migrated tables");
    db.close();
  }
'

step "Checking production Compose startup"
probe_compose up -d --no-build --wait --wait-timeout 90
compose_app="$(probe_compose ps -q app)"
docker exec "$compose_app" sh -ec '
  curl --fail --silent --request POST --header "Authorization: Bearer $DEVPUSH_CRON_SECRET" http://127.0.0.1:3000/api/cron/maintenance >/dev/null
'

cleanup_probe
trap - EXIT INT TERM

image_size="$(docker image inspect "$image" --format '{{.Size}}')"

cat <<EOF

Production image validation passed.

Image: $image
Uncompressed image size: $image_size bytes
EOF