Files
gitea-migration/preflight.sh
S f4a6b04d14 feat: rework runner config to INI format with full field support
Replace pipe-delimited runners.conf with INI-style sections supporting
host resolution, container images, repo-scoped tokens, resource limits,
capacity, and SSH key passthrough. All defaults pulled from .env.

- Add INI parsing helpers (ini_list_sections, ini_get, ini_set) to common.sh
- Add SSH key support (UNRAID_SSH_KEY, FEDORA_SSH_KEY) to ssh_exec/scp_to
- Add .env vars: RUNNER_DEFAULT_IMAGE, RUNNER_DEFAULT_CAPACITY,
  RUNNER_DEFAULT_DATA_PATH, LOCAL_RUNNER_DATA_PATH, LOCAL_REGISTRY
- Rewrite manage_runner.sh with host/image/token resolution and resource limits
- Rewrite configure_runners.sh wizard for INI format with all 9 fields
- Update phase3 scripts to use ini_list_sections instead of pipe parsing
- Add runners.conf INI validation to preflight.sh (check 5b)
- Update templates to use resolved labels, capacity, and deploy resources

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-28 23:14:46 -05:00

606 lines
23 KiB
Bash
Executable File

#!/usr/bin/env bash
set -euo pipefail
# shellcheck disable=SC2329
# =============================================================================
# preflight.sh — Validate everything before running migration phases
# Installs nothing. Exits 0 only if ALL checks pass.
#
# Usage:
# ./preflight.sh # Run all checks
# ./preflight.sh --skip-port-checks # Skip port-free checks (for --start-from)
# =============================================================================
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/lib/common.sh"
SKIP_PORT_CHECKS=false
for arg in "$@"; do
case "$arg" in
--skip-port-checks) SKIP_PORT_CHECKS=true ;;
esac
done
log_info "=== Preflight Checks ==="
PASS_COUNT=0
FAIL_COUNT=0
# ---------------------------------------------------------------------------
# Check helper — runs a check function, tracks pass/fail count.
# Intentionally does NOT exit on failure — we want to run ALL checks
# so the user sees every issue at once, not one at a time.
# ---------------------------------------------------------------------------
check() {
local num="$1" description="$2"
shift 2
if "$@" 2>/dev/null; then
log_success "[${num}] ${description}"
PASS_COUNT=$((PASS_COUNT + 1))
else
log_error "[${num}] FAIL: ${description}"
FAIL_COUNT=$((FAIL_COUNT + 1))
fi
}
# ---------------------------------------------------------------------------
# Check 1: Local machine is macOS (control plane uses brew, launchctl, macOS sed)
# ---------------------------------------------------------------------------
check_local_os() {
[[ "$(uname -s)" == "Darwin" ]]
}
check 1 "Local machine is macOS (control plane)" check_local_os
if ! check_local_os 2>/dev/null; then
log_error " → This toolkit is designed to run from macOS. Detected: $(uname -s)"
fi
# ---------------------------------------------------------------------------
# Check 2: Unraid is Linux (via SSH)
# ---------------------------------------------------------------------------
check_unraid_os() {
local remote_os
remote_os="$(ssh_exec UNRAID "uname -s" 2>/dev/null)" || return 1
[[ "$remote_os" == "Linux" ]]
}
# ---------------------------------------------------------------------------
# Check 3: Fedora is Linux with dnf (RPM-based)
# ---------------------------------------------------------------------------
check_fedora_os() {
local remote_os
remote_os="$(ssh_exec FEDORA "uname -s" 2>/dev/null)" || return 1
[[ "$remote_os" == "Linux" ]] && ssh_exec FEDORA "command -v dnf" &>/dev/null
}
# ---------------------------------------------------------------------------
# Check 4: .env exists
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_env_exists() {
[[ -f "${SCRIPT_DIR}/.env" ]]
}
check 4 ".env file exists" check_env_exists
if [[ ! -f "${SCRIPT_DIR}/.env" ]]; then
log_error " → .env not found. Copy .env.example to .env and fill in values."
log_error " → Or run: setup/configure_env.sh"
# Can't continue without .env — run remaining checks but they'll mostly fail
fi
# ---------------------------------------------------------------------------
# Check 5: runners.conf exists
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_runners_conf() {
[[ -f "${SCRIPT_DIR}/runners.conf" ]]
}
check 5 "runners.conf file exists" check_runners_conf
if [[ ! -f "${SCRIPT_DIR}/runners.conf" ]]; then
log_error " → runners.conf not found. Copy runners.conf.example to runners.conf."
fi
# ---------------------------------------------------------------------------
# Check 5b: runners.conf INI format validation
# Validates required fields, enum values, and format per runner section.
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_runners_ini_format() {
local conf="${SCRIPT_DIR}/runners.conf"
[[ -f "$conf" ]] || return 1
local errors=0
local section
while IFS= read -r section; do
[[ -z "$section" ]] && continue
# Required fields
local host type data_path labels capacity repos
host=$(ini_get "$conf" "$section" "host" "")
type=$(ini_get "$conf" "$section" "type" "")
data_path=$(ini_get "$conf" "$section" "data_path" "")
labels=$(ini_get "$conf" "$section" "labels" "")
capacity=$(ini_get "$conf" "$section" "capacity" "")
repos=$(ini_get "$conf" "$section" "repos" "all")
# host: must be unraid, fedora, local, or custom
case "$host" in
unraid|fedora|local|custom) ;;
"")
log_error " → [$section] host is empty (must be unraid, fedora, local, or custom)"
errors=$((errors + 1))
;;
*)
log_error " → [$section] host='$host' (must be unraid, fedora, local, or custom)"
errors=$((errors + 1))
;;
esac
# type: must be docker or native
case "$type" in
docker|native) ;;
"")
log_error " → [$section] type is empty (must be docker or native)"
errors=$((errors + 1))
;;
*)
log_error " → [$section] type='$type' (must be docker or native)"
errors=$((errors + 1))
;;
esac
# data_path: must start with / or ~/
# shellcheck disable=SC2088 # tilde intentionally stored as literal string
if [[ -z "$data_path" ]]; then
log_error " → [$section] data_path is empty"
errors=$((errors + 1))
elif [[ "$data_path" != /* ]] && [[ "$data_path" != "~/"* ]] && [[ "$data_path" != "~" ]]; then
log_error " → [$section] data_path='$data_path' (must start with / or ~/)"
errors=$((errors + 1))
fi
# labels: non-empty
if [[ -z "$labels" ]]; then
log_error " → [$section] labels is empty"
errors=$((errors + 1))
fi
# capacity: positive integer >= 1
if [[ -n "$capacity" ]] && ! [[ "$capacity" =~ ^[1-9][0-9]*$ ]]; then
log_error " → [$section] capacity='$capacity' (must be positive integer >= 1)"
errors=$((errors + 1))
fi
# repos: must be "all" or a known REPO_*_NAME
if [[ "$repos" != "all" ]] && [[ -n "$repos" ]]; then
local repos_valid=false
for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do
if [[ "$repos" == "${!var:-}" ]]; then repos_valid=true; break; fi
done
if ! $repos_valid; then
log_error " → [$section] repos='$repos' (must be 'all' or a known repo name)"
errors=$((errors + 1))
fi
fi
# docker runner: default_image should be set
if [[ "$type" == "docker" ]]; then
local default_image
default_image=$(ini_get "$conf" "$section" "default_image" "")
if [[ -z "$default_image" ]] && [[ -z "${RUNNER_DEFAULT_IMAGE:-}" ]]; then
log_error " → [$section] default_image is empty and RUNNER_DEFAULT_IMAGE not set in .env"
errors=$((errors + 1))
fi
fi
# custom host: extra SSH keys required
if [[ "$host" == "custom" ]]; then
local ssh_host ssh_user
ssh_host=$(ini_get "$conf" "$section" "ssh_host" "")
ssh_user=$(ini_get "$conf" "$section" "ssh_user" "")
if [[ -z "$ssh_host" ]]; then
log_error " → [$section] ssh_host is empty (required for host=custom)"
errors=$((errors + 1))
fi
if [[ -z "$ssh_user" ]]; then
log_error " → [$section] ssh_user is empty (required for host=custom)"
errors=$((errors + 1))
fi
fi
done < <(ini_list_sections "$conf")
[[ $errors -eq 0 ]]
}
if [[ -f "${SCRIPT_DIR}/runners.conf" ]]; then
check 5b "runners.conf INI format validation" check_runners_ini_format
fi
# ---------------------------------------------------------------------------
# Load env for remaining checks (may fail if .env missing)
# ---------------------------------------------------------------------------
if [[ -f "${SCRIPT_DIR}/.env" ]]; then
load_env
fi
# ---------------------------------------------------------------------------
# Check 6: Required .env vars
# ---------------------------------------------------------------------------
REQUIRED_VARS=(
UNRAID_IP UNRAID_SSH_USER UNRAID_GITEA_DATA_PATH
FEDORA_IP FEDORA_SSH_USER FEDORA_GITEA_DATA_PATH
GITEA_ADMIN_USER GITEA_ADMIN_PASSWORD GITEA_ADMIN_EMAIL
GITEA_ORG_NAME GITEA_INSTANCE_NAME
GITEA_DOMAIN GITEA_INTERNAL_URL
GITEA_BACKUP_INTERNAL_URL BACKUP_STORAGE_PATH
GITHUB_USERNAME GITHUB_TOKEN
REPO_1_NAME REPO_2_NAME REPO_3_NAME
RUNNER_DEFAULT_IMAGE RUNNER_DEFAULT_DATA_PATH LOCAL_RUNNER_DATA_PATH
GITHUB_MIRROR_TOKEN
NGINX_CONTAINER_NAME NGINX_CONF_PATH SSL_MODE
)
# shellcheck disable=SC2329
check_required_vars() {
local missing=0
for var in "${REQUIRED_VARS[@]}"; do
if [[ -z "${!var:-}" ]]; then
log_error " → Missing required var: $var"
missing=1
fi
done
# SSL vars are conditional on SSL_MODE:
# - letsencrypt => SSL_EMAIL is required
# - existing => SSL_CERT_PATH + SSL_KEY_PATH are required
case "${SSL_MODE:-}" in
letsencrypt)
if [[ -z "${SSL_EMAIL:-}" ]]; then
log_error " → Missing required var: SSL_EMAIL (required when SSL_MODE=letsencrypt)"
missing=1
fi
;;
existing)
if [[ -z "${SSL_CERT_PATH:-}" ]]; then
log_error " → Missing required var: SSL_CERT_PATH (required when SSL_MODE=existing)"
missing=1
fi
if [[ -z "${SSL_KEY_PATH:-}" ]]; then
log_error " → Missing required var: SSL_KEY_PATH (required when SSL_MODE=existing)"
missing=1
fi
;;
*)
log_error " → Invalid SSL_MODE='${SSL_MODE:-<empty>}' (must be 'letsencrypt' or 'existing')"
missing=1
;;
esac
return "$missing"
}
check 6 "All required .env vars are set" check_required_vars
# ---------------------------------------------------------------------------
# Check 6b: .env value format validation (IPs, ports, emails, booleans, etc.)
# ---------------------------------------------------------------------------
check 6b ".env values pass format validation" validate_env
# ---------------------------------------------------------------------------
# Check 7: SSH to Unraid
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_ssh_unraid() {
ssh_check UNRAID
}
check 7 "SSH to Unraid (${UNRAID_IP:-<not set>})" check_ssh_unraid
if ! ssh_check UNRAID 2>/dev/null; then
log_error " → Cannot SSH to Unraid. Run setup/unraid.sh or check SSH config."
fi
# ---------------------------------------------------------------------------
# Check 8: SSH to Fedora
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_ssh_fedora() {
ssh_check FEDORA
}
check 8 "SSH to Fedora (${FEDORA_IP:-<not set>})" check_ssh_fedora
if ! ssh_check FEDORA 2>/dev/null; then
log_error " → Cannot SSH to Fedora. Run setup/fedora.sh or check SSH config."
fi
# ---------------------------------------------------------------------------
# Checks 2-3: Remote OS checks (deferred until after SSH is confirmed)
# These are numbered 2-3 in the output but run after SSH checks because
# they require SSH connectivity to `uname -s` on the remote machines.
# ---------------------------------------------------------------------------
check 2 "Unraid is Linux" check_unraid_os
if ! check_unraid_os 2>/dev/null; then
log_error " → UNRAID_IP points to a non-Linux machine. Check your .env."
fi
check 3 "Fedora is Linux with dnf (RPM-based)" check_fedora_os
if ! check_fedora_os 2>/dev/null; then
log_error " → FEDORA_IP points to a machine that isn't RPM-based Linux. Check your .env."
fi
# ---------------------------------------------------------------------------
# Check 9: Docker on Unraid
# ---------------------------------------------------------------------------
check_docker_unraid() {
ssh_exec UNRAID "docker --version" &>/dev/null
}
check 9 "Docker available on Unraid" check_docker_unraid
if ! check_docker_unraid 2>/dev/null; then
log_error " → Docker not found on Unraid. Run setup/unraid.sh."
fi
# ---------------------------------------------------------------------------
# Check 10: Docker on Fedora
# ---------------------------------------------------------------------------
check_docker_fedora() {
ssh_exec FEDORA "docker --version" &>/dev/null
}
check 10 "Docker available on Fedora" check_docker_fedora
if ! check_docker_fedora 2>/dev/null; then
log_error " → Docker not found on Fedora. Run setup/fedora.sh."
fi
# ---------------------------------------------------------------------------
# Check 11: docker-compose on Unraid
# ---------------------------------------------------------------------------
check_compose_unraid() {
ssh_exec UNRAID "docker compose version" &>/dev/null || ssh_exec UNRAID "docker-compose --version" &>/dev/null
}
check 11 "docker-compose available on Unraid" check_compose_unraid
if ! check_compose_unraid 2>/dev/null; then
log_error " → docker-compose not found on Unraid. Run setup/unraid.sh."
fi
# ---------------------------------------------------------------------------
# Check 12: docker-compose on Fedora
# ---------------------------------------------------------------------------
check_compose_fedora() {
ssh_exec FEDORA "docker compose version" &>/dev/null || ssh_exec FEDORA "docker-compose --version" &>/dev/null
}
check 12 "docker-compose available on Fedora" check_compose_fedora
if ! check_compose_fedora 2>/dev/null; then
log_error " → docker-compose not found on Fedora. Run setup/fedora.sh."
fi
# ---------------------------------------------------------------------------
# Check 13: Port free on Unraid
# Uses ss (socket statistics) to check if any process is listening on the port.
# The ! negates the grep — we PASS if the port is NOT found in use.
# Skipped when --skip-port-checks is set (e.g. resuming with --start-from
# after phases 1-2 have Gitea already running on these ports).
# ---------------------------------------------------------------------------
if [[ "$SKIP_PORT_CHECKS" == "true" ]]; then
log_info "[13] Port ${UNRAID_GITEA_PORT:-3000} free on Unraid — SKIPPED (--skip-port-checks)"
log_info "[14] Port ${FEDORA_GITEA_PORT:-3000} free on Fedora — SKIPPED (--skip-port-checks)"
else
check_port_unraid() {
local port="${UNRAID_GITEA_PORT:-3000}"
local ss_output
local grep_rc
# Fail closed on SSH/remote command errors.
ss_output=$(ssh_exec UNRAID "ss -tlnp" 2>/dev/null) || return 1
# grep exit codes:
# 0 => match found (port in use) => FAIL check
# 1 => no match (port free) => PASS check
# >1 => grep error => FAIL check
if printf '%s\n' "$ss_output" | grep -q ":${port} "; then
grep_rc=0
else
grep_rc=$?
fi
case "$grep_rc" in
0) return 1 ;;
1) return 0 ;;
*) return 1 ;;
esac
}
check 13 "Port ${UNRAID_GITEA_PORT:-3000} free on Unraid" check_port_unraid
if ! check_port_unraid 2>/dev/null; then
log_error " → Port check failed or port ${UNRAID_GITEA_PORT:-3000} is already in use on Unraid."
fi
# ---------------------------------------------------------------------------
# Check 14: Port free on Fedora
# ---------------------------------------------------------------------------
check_port_fedora() {
local port="${FEDORA_GITEA_PORT:-3000}"
local ss_output
local grep_rc
# Fail closed on SSH/remote command errors.
ss_output=$(ssh_exec FEDORA "ss -tlnp" 2>/dev/null) || return 1
# grep exit codes:
# 0 => match found (port in use) => FAIL check
# 1 => no match (port free) => PASS check
# >1 => grep error => FAIL check
if printf '%s\n' "$ss_output" | grep -q ":${port} "; then
grep_rc=0
else
grep_rc=$?
fi
case "$grep_rc" in
0) return 1 ;;
1) return 0 ;;
*) return 1 ;;
esac
}
check 14 "Port ${FEDORA_GITEA_PORT:-3000} free on Fedora" check_port_fedora
if ! check_port_fedora 2>/dev/null; then
log_error " → Port check failed or port ${FEDORA_GITEA_PORT:-3000} is already in use on Fedora."
fi
fi
# ---------------------------------------------------------------------------
# Check 15: DNS resolves
# ---------------------------------------------------------------------------
check_dns() {
# Fail closed when required values are missing.
[[ -n "${GITEA_DOMAIN:-}" ]] || return 1
[[ -n "${UNRAID_IP:-}" ]] || return 1
local resolved
resolved=$(dig +short "${GITEA_DOMAIN}" A 2>/dev/null | sed '/^[[:space:]]*$/d') || return 1
[[ -n "$resolved" ]] || return 1
# Pass only if one of the domain's A records exactly matches UNRAID_IP.
if printf '%s\n' "$resolved" | grep -Fxq "${UNRAID_IP}"; then
return 0
fi
return 1
}
check 15 "DNS: ${GITEA_DOMAIN:-<not set>} resolves to ${UNRAID_IP:-<not set>}" check_dns
if ! check_dns 2>/dev/null; then
log_error "${GITEA_DOMAIN:-GITEA_DOMAIN} does not resolve to ${UNRAID_IP:-UNRAID_IP}."
fi
# ---------------------------------------------------------------------------
# Check 16: GitHub token valid
# ---------------------------------------------------------------------------
check_github_token() {
[[ -n "${GITHUB_TOKEN:-}" ]] && curl -sf -H "Authorization: token ${GITHUB_TOKEN}" https://api.github.com/user -o /dev/null
}
check 16 "GitHub token valid" check_github_token
if ! check_github_token 2>/dev/null; then
log_error " → GitHub token invalid. Check GITHUB_TOKEN in .env."
fi
# ---------------------------------------------------------------------------
# Check 17: GitHub repos exist
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_github_repos() {
local all_ok=0
if [[ -z "${GITHUB_USERNAME:-}" ]]; then
log_error " → GITHUB_USERNAME is empty"
return 1
fi
for var in REPO_1_NAME REPO_2_NAME REPO_3_NAME; do
local repo="${!var:-}"
if [[ -z "$repo" ]]; then
log_error "${var} is empty"
all_ok=1
continue
fi
if ! curl -sf -H "Authorization: token ${GITHUB_TOKEN:-}" "https://api.github.com/repos/${GITHUB_USERNAME}/${repo}" -o /dev/null 2>/dev/null; then
log_error " → GitHub repo ${repo} not found under ${GITHUB_USERNAME}"
all_ok=1
fi
done
return "$all_ok"
}
check 17 "All GitHub repos exist" check_github_repos
# ---------------------------------------------------------------------------
# Check 18: Nginx running on Unraid
# ---------------------------------------------------------------------------
check_nginx() {
local status
status=$(ssh_exec UNRAID "docker ps --filter name=${NGINX_CONTAINER_NAME:-nginx} --format '{{.Status}}'" 2>/dev/null)
[[ "$status" == *"Up"* ]]
}
check 18 "Nginx container '${NGINX_CONTAINER_NAME:-<not set>}' running on Unraid" check_nginx
if ! check_nginx 2>/dev/null; then
log_error " → Nginx container '${NGINX_CONTAINER_NAME:-}' not running on Unraid."
fi
# ---------------------------------------------------------------------------
# Check 19: Nginx conf dir writable
# ---------------------------------------------------------------------------
check_nginx_conf() {
ssh_exec UNRAID "test -w '${NGINX_CONF_PATH:-/nonexistent}'" 2>/dev/null
}
check 19 "Nginx config path writable (${NGINX_CONF_PATH:-<not set>})" check_nginx_conf
if ! check_nginx_conf 2>/dev/null; then
log_error " → Nginx config path ${NGINX_CONF_PATH:-} not writable on Unraid."
fi
# ---------------------------------------------------------------------------
# Check 20: Local tool minimum versions
# Validates that tools on the MacBook meet minimum requirements.
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_local_versions() {
local fail=0
check_min_version "jq" "jq --version" "1.6" || fail=1
check_min_version "curl" "curl --version" "7.70" || fail=1
check_min_version "git" "git --version" "2.30" || fail=1
return "$fail"
}
check 20 "Local tool minimum versions (jq>=1.6, curl>=7.70, git>=2.30)" check_local_versions
# ---------------------------------------------------------------------------
# Check 21: Unraid tool minimum versions
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_unraid_versions() {
local fail=0
check_remote_min_version "UNRAID" "docker" "docker --version" "20.0" || fail=1
check_remote_min_version "UNRAID" "docker-compose" "docker compose version 2>/dev/null || docker-compose --version" "2.0" || fail=1
check_remote_min_version "UNRAID" "jq" "jq --version" "1.6" || fail=1
return "$fail"
}
check 21 "Unraid tool minimum versions (docker>=20, compose>=2, jq>=1.6)" check_unraid_versions
# ---------------------------------------------------------------------------
# Check 22: Fedora tool minimum versions
# ---------------------------------------------------------------------------
# shellcheck disable=SC2329
check_fedora_versions() {
local fail=0
check_remote_min_version "FEDORA" "docker" "docker --version" "20.0" || fail=1
check_remote_min_version "FEDORA" "docker-compose" "docker compose version" "2.0" || fail=1
check_remote_min_version "FEDORA" "jq" "jq --version" "1.6" || fail=1
return "$fail"
}
check 22 "Fedora tool minimum versions (docker>=20, compose>=2, jq>=1.6)" check_fedora_versions
# ---------------------------------------------------------------------------
# Check 23: Unraid can SSH to Fedora (required for backup transfer)
# ---------------------------------------------------------------------------
check_unraid_to_fedora_ssh() {
ssh_exec UNRAID "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${FEDORA_SSH_PORT:-22}' '${FEDORA_SSH_USER}@${FEDORA_IP}' true" &>/dev/null
}
check 23 "Unraid can SSH to Fedora (host-to-host backup path)" check_unraid_to_fedora_ssh
if ! check_unraid_to_fedora_ssh 2>/dev/null; then
log_error " → Unraid cannot SSH to Fedora with key auth (needed by backup/backup_primary.sh)."
log_error " → Configure SSH keys so Unraid can run: ssh ${FEDORA_SSH_USER}@${FEDORA_IP}"
fi
# ---------------------------------------------------------------------------
# Check 24: Fedora can SSH to Unraid (required for restore transfer)
# ---------------------------------------------------------------------------
check_fedora_to_unraid_ssh() {
ssh_exec FEDORA "ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=accept-new -o BatchMode=yes -p '${UNRAID_SSH_PORT:-22}' '${UNRAID_SSH_USER}@${UNRAID_IP}' true" &>/dev/null
}
check 24 "Fedora can SSH to Unraid (host-to-host restore path)" check_fedora_to_unraid_ssh
if ! check_fedora_to_unraid_ssh 2>/dev/null; then
log_error " → Fedora cannot SSH to Unraid with key auth (needed by backup/restore_to_primary.sh)."
log_error " → Configure SSH keys so Fedora can run: ssh ${UNRAID_SSH_USER}@${UNRAID_IP}"
fi
# ---------------------------------------------------------------------------
# Summary
# ---------------------------------------------------------------------------
TOTAL_CHECKS=$((PASS_COUNT + FAIL_COUNT))
printf '\n'
log_info "Results: ${PASS_COUNT} passed, ${FAIL_COUNT} failed (out of ${TOTAL_CHECKS} checks)"
if [[ $FAIL_COUNT -gt 0 ]]; then
log_error "Preflight FAILED — fix the issues above before proceeding."
exit 1
else
log_success "All preflight checks passed. Ready to run migration phases."
exit 0
fi