#!/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 name from REPO_NAMES if [[ "$repos" != "all" ]] && [[ -n "$repos" ]]; then local repos_valid=false local _rn for _rn in ${REPO_NAMES:-}; do if [[ "$repos" == "$_rn" ]]; then repos_valid=true; break; fi done if ! $repos_valid; then log_error " → [$section] repos='$repos' (must be 'all' or a name from REPO_NAMES)" errors=$((errors + 1)) fi fi # boot: must be "true" or "false" if present (native runners only) local boot boot=$(ini_get "$conf" "$section" "boot" "") if [[ -n "$boot" ]] && [[ "$boot" != "true" ]] && [[ "$boot" != "false" ]]; then log_error " → [$section] boot='$boot' (must be 'true' or 'false')" errors=$((errors + 1)) fi if [[ "$boot" == "true" ]] && [[ "$type" == "docker" ]]; then log_warn " → [$section] boot=true has no effect on docker runners (ignored)" 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 UNRAID_MACVLAN_PARENT UNRAID_MACVLAN_SUBNET UNRAID_MACVLAN_GATEWAY UNRAID_MACVLAN_IP_RANGE UNRAID_GITEA_IP UNRAID_CADDY_IP FEDORA_MACVLAN_PARENT FEDORA_MACVLAN_SUBNET FEDORA_MACVLAN_GATEWAY FEDORA_MACVLAN_IP_RANGE FEDORA_GITEA_IP 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_NAMES RUNNER_DEFAULT_IMAGE RUNNER_DATA_BASE_PATH LOCAL_RUNNER_DATA_BASE_PATH TLS_MODE CADDY_DOMAIN CADDY_DATA_PATH ) # 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 # DB vars are conditional on GITEA_DB_TYPE (required when NOT sqlite3) if [[ "${GITEA_DB_TYPE:-sqlite3}" != "sqlite3" ]]; then for var in GITEA_DB_HOST GITEA_DB_PORT GITEA_DB_NAME GITEA_DB_USER GITEA_DB_PASSWD; do if [[ -z "${!var:-}" ]]; then log_error " → Missing required var: $var (required when GITEA_DB_TYPE=${GITEA_DB_TYPE})" missing=1 fi done fi # TLS vars are conditional on TLS_MODE: # - cloudflare => CLOUDFLARE_API_TOKEN is required # - existing => SSL_CERT_PATH + SSL_KEY_PATH are required case "${TLS_MODE:-}" in cloudflare) if [[ -z "${CLOUDFLARE_API_TOKEN:-}" ]]; then log_error " → Missing required var: CLOUDFLARE_API_TOKEN (required when TLS_MODE=cloudflare)" missing=1 fi ;; existing) if [[ -z "${SSL_CERT_PATH:-}" ]]; then log_error " → Missing required var: SSL_CERT_PATH (required when TLS_MODE=existing)" missing=1 fi if [[ -z "${SSL_KEY_PATH:-}" ]]; then log_error " → Missing required var: SSL_KEY_PATH (required when TLS_MODE=existing)" missing=1 fi ;; *) log_error " → Invalid TLS_MODE='${TLS_MODE:-}' (must be 'cloudflare' 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:-})" 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:-})" 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: Container IPs not already in use # Ping the requested macvlan IPs to verify they're available. # Skipped when --skip-port-checks is set (containers may already be running). # --------------------------------------------------------------------------- if [[ "$SKIP_PORT_CHECKS" == "true" ]]; then log_info "[13] Container IP availability — SKIPPED (--skip-port-checks)" else # shellcheck disable=SC2329 check_ips_available() { local fail=0 for ip_var in UNRAID_GITEA_IP UNRAID_CADDY_IP FEDORA_GITEA_IP; do local ip="${!ip_var:-}" [[ -z "$ip" ]] && continue # ping -c1 -W1: one packet, 1-second timeout if ping -c1 -W1 "$ip" &>/dev/null; then log_warn " → $ip_var ($ip) is already responding to ping (may be in use)" fail=1 fi done return "$fail" } check 13 "Container IPs not already in use" check_ips_available 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 # Use python3 (bundled with macOS) for DNS — avoids dependency on dig/host/nslookup resolved=$(python3 -c "import socket; print('\n'.join(r[4][0] for r in socket.getaddrinfo('${GITEA_DOMAIN}', None, socket.AF_INET)))" 2>/dev/null | sort -u) || 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:-} resolves to ${UNRAID_IP:-}" 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 local repo for repo in ${REPO_NAMES:-}; do 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: Caddy data path writable on Unraid # --------------------------------------------------------------------------- check_caddy_path() { local caddy_parent caddy_parent=$(dirname "${CADDY_DATA_PATH:-/nonexistent}") ssh_exec UNRAID "test -d '${CADDY_DATA_PATH}' && test -w '${CADDY_DATA_PATH}'" 2>/dev/null \ || ssh_exec UNRAID "test -w '${caddy_parent}'" 2>/dev/null } check 18 "Caddy data path writable (${CADDY_DATA_PATH:-})" check_caddy_path if ! check_caddy_path 2>/dev/null; then log_error " → Caddy data path ${CADDY_DATA_PATH:-} not writable on Unraid (or parent dir doesn't exist)." 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