#!/usr/bin/env bash set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # shellcheck source=wp13-common.sh source "${SCRIPT_DIR}/wp13-common.sh" usage() { cat <<'EOF' Usage: preflight.sh [options] Validate an offline bundle or an existing Compose project before install/upgrade. Options: --bundle-root PATH Bundle or repository root. --compose-file PATH Compose file (default: /deploy/docker/docker-compose.yml). --env-file PATH Optional Compose environment file. --environment-profile PATH Validated JSON profile; selects matching .env and project. --project-name NAME Compose project (default: dataops-test). --checksums-only Verify checksums and exit before Docker access. --skip-checksums Repository development mode only. --help Show this help. EOF } BUNDLE_ROOT="$(wp13_default_root)" COMPOSE_FILE="" ENV_FILE="" ENVIRONMENT_PROFILE="" PROJECT_NAME="" CHECKSUMS_ONLY=0 SKIP_CHECKSUMS=0 while [[ $# -gt 0 ]]; do case "$1" in --bundle-root) BUNDLE_ROOT="$2"; shift 2 ;; --compose-file) COMPOSE_FILE="$2"; shift 2 ;; --env-file) ENV_FILE="$2"; shift 2 ;; --environment-profile) ENVIRONMENT_PROFILE="$2"; shift 2 ;; --project-name) PROJECT_NAME="$2"; shift 2 ;; --checksums-only) CHECKSUMS_ONLY=1; shift ;; --skip-checksums) SKIP_CHECKSUMS=1; shift ;; --help|-h) usage; exit 0 ;; *) wp13_die "unknown argument: $1" ;; esac done BUNDLE_ROOT="$(cd "${BUNDLE_ROOT}" && pwd)" COMPOSE_FILE="${COMPOSE_FILE:-${BUNDLE_ROOT}/deploy/docker/docker-compose.yml}" if [[ "${SKIP_CHECKSUMS}" -eq 0 ]]; then wp13_verify_checksums "${BUNDLE_ROOT}" fi if [[ "${CHECKSUMS_ONLY}" -eq 1 ]]; then exit 0 fi if [[ -n "${ENVIRONMENT_PROFILE}" ]]; then if [[ "${ENVIRONMENT_PROFILE}" != /* ]]; then ENVIRONMENT_PROFILE="${BUNDLE_ROOT}/${ENVIRONMENT_PROFILE}" fi [[ -r "${ENVIRONMENT_PROFILE}" ]] \ || wp13_die "environment profile is not readable: ${ENVIRONMENT_PROFILE}" wp13_require_command python3 python3 "${SCRIPT_DIR}/product_engineering.py" validate-environments \ --profiles-dir "$(dirname "${ENVIRONMENT_PROFILE}")" >/dev/null PROFILE_PROJECT="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1], encoding="utf-8"))["project_name"])' "${ENVIRONMENT_PROFILE}")" PROFILE_ENV_FILE="${ENVIRONMENT_PROFILE%.json}.env" [[ -r "${PROFILE_ENV_FILE}" ]] \ || wp13_die "matching environment file is not readable: ${PROFILE_ENV_FILE}" if [[ -n "${PROJECT_NAME}" && "${PROJECT_NAME}" != "${PROFILE_PROJECT}" ]]; then wp13_die "project name does not match environment profile: ${PROJECT_NAME} != ${PROFILE_PROJECT}" fi PROJECT_NAME="${PROFILE_PROJECT}" if [[ -n "${ENV_FILE}" && "$(cd "$(dirname "${ENV_FILE}")" && pwd)/$(basename "${ENV_FILE}")" != "$(cd "$(dirname "${PROFILE_ENV_FILE}")" && pwd)/$(basename "${PROFILE_ENV_FILE}")" ]]; then wp13_die "--env-file must match --environment-profile" fi ENV_FILE="${PROFILE_ENV_FILE}" wp13_log "environment profile validated: $(basename "${ENVIRONMENT_PROFILE}")" fi PROJECT_NAME="${PROJECT_NAME:-dataops-test}" wp13_require_command docker docker version >/dev/null docker compose version >/dev/null [[ -f "${COMPOSE_FILE}" ]] || wp13_die "compose file not found: ${COMPOSE_FILE}" if [[ -n "${ENV_FILE}" ]]; then [[ -r "${ENV_FILE}" ]] || wp13_die "environment file is not readable: ${ENV_FILE}" fi wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" config --quiet wp13_log "Compose configuration resolved" while IFS= read -r image; do [[ -n "${image}" ]] || continue docker image inspect "${image}" >/dev/null 2>&1 \ || wp13_die "required offline image is missing: ${image}" done < <( wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ config --images | LC_ALL=C sort -u ) wp13_log "all default-profile images are available locally" expected_head="$(wp13_expected_migration_head "${BUNDLE_ROOT}")" if [[ -n "${expected_head}" ]]; then wp13_require_command python3 backend_image="$( wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ config --format json \ | python3 -c 'import json,sys; print(json.load(sys.stdin)["services"]["backend"]["image"])' )" image_head="$( docker run --rm --network none --read-only --entrypoint alembic "${backend_image}" \ -c alembic.ini heads \ | awk 'NF {print $1; exit}' )" [[ -n "${image_head}" && "${image_head}" == "${expected_head}" ]] \ || wp13_die "image migration head does not match release manifest: image=${image_head:-unknown}, release=${expected_head}" wp13_log "target image migration head matches release: ${image_head}" fi if [[ -n "$(wp13_project_running_ids "${PROJECT_NAME}")" ]]; then current="$( wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ exec -T backend alembic -c alembic.ini current \ | awk 'NF {print $1; exit}' )" head="$( wp13_compose "${COMPOSE_FILE}" "${PROJECT_NAME}" "${ENV_FILE}" \ exec -T backend alembic -c alembic.ini heads \ | awk 'NF {print $1; exit}' )" [[ -n "${current}" && "${current}" == "${head}" ]] \ || wp13_die "database migration is not at head: current=${current:-unknown}, head=${head:-unknown}" wp13_log "running database is self-consistent at migration: ${current}; target=${expected_head:-unversioned}" else wp13_log "project is not running; migration will be checked after first start" fi available_kb="$(df -Pk "${BUNDLE_ROOT}" | awk 'NR==2 {print $4}')" [[ "${available_kb:-0}" -ge 10485760 ]] \ || wp13_die "less than 10 GiB free space is available" wp13_log "preflight passed for project ${PROJECT_NAME}"