#!/usr/bin/env bash # # homelab-infrastructure-review.sh # Triggers a full 3-way infrastructure reconciliation audit. # Audits Infra.md ↔ Live Docker ↔ Homelable Canvas ↔ Homepage Dashboard. # Triggered ONLY by explicit, manual user request. No cron or automation. # # Usage: bash /srv/configs/ai/skills/homelab-infrastructure-review/scripts/homelab-infrastructure-review.sh [--dry-run] # set -euo pipefail CONF_DIR="/srv/configs/ai" INFRA_FILE="/srv/docs/public/projects/homelab/homelab_Infra.md" MCP_ENDPOINT="http://localhost:9445/api/v1" ALIAS_FILE="/srv/configs/.bash_aliases" LOCK_FILE="/srv/configs/ai/.audit.lock" HOMEPAGE_FILE="/srv/configs/docker_compose/homepage/config/services.yaml" DRY_RUN=false if [[ "${1:-}" == "--dry-run" ]]; then DRY_RUN=true fi # ── Logging helpers ────────────────────────────────────────────────────────── log_info() { local msg="[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*" echo "$msg" } log_error() { local msg="[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" echo "$msg" >&2 } # ── Lock (prevent concurrent runs) ─────────────────────────────────────────── acquire_lock() { if [ -f "$LOCK_FILE" ]; then local lock_pid lock_pid=$(cat "$LOCK_FILE" 2>/dev/null || echo "") if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then log_error "Another audit process is running (PID $lock_pid). Aborting." exit 1 fi log_info "Stale lock file found (PID $lock_pid). Cleaning up." rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE" trap 'rm -f "$LOCK_FILE"' EXIT } # ── Port listening check helper ────────────────────────────────────────────── is_port_active() { local target_port="$1" # 1. Check if the port is in ss -tulnp if ss -tulnp 2>/dev/null | grep -q -E ":${target_port}[[:space:]]"; then return 0 fi # 2. Check if the port is mapped in running docker containers if docker ps --format "{{.Ports}}" | grep -q -E "(^|[^0-9])${target_port}->"; then return 0 fi return 1 } # ── Pre-flight: Port 9445 availability ─────────────────────────────────────── check_port_8001() { # Port 9445 SHOULD be listening (homelable-backend port-mapped on host) if ! ss -tulnp 2>/dev/null | grep -q ':9445[[:space:]]'; then log_error "Port 9445 is not listening — homelable-backend is not port-mapped. Cannot proceed. Aborting." exit 1 fi # Parse the service key if present local service_key="" if [ -f "/srv/configs/docker_compose/homelabel/.env" ]; then service_key=$(grep -E "^MCP_SERVICE_KEY=" "/srv/configs/docker_compose/homelabel/.env" | cut -d'=' -f2-) fi # Verify the backend endpoint is actually responding via HTTP local http_code http_code=$(curl -s -H "X-MCP-Service-Key: $service_key" -o /dev/null -w '%{http_code}' --max-time 5 "http://localhost:9445/api/v1/health" 2>/dev/null) || http_code="000" # 000 = connection failure = server unreachable if [ "$http_code" = "000" ]; then log_error "Backend endpoint http://localhost:9445/api/v1/health unresponsive on port 9445. May be container crash or network issue. Aborting." exit 1 fi log_info "Port 9445 confirmed active for homelable-backend (HTTP $http_code)." } # ── Pre-flight: Source file existence ──────────────────────────────────────── check_sources() { if [ ! -f "$ALIAS_FILE" ]; then log_error "Alias file $ALIAS_FILE not found. Cannot proceed. Aborting." exit 1 fi if [ ! -f "$INFRA_FILE" ]; then log_error "Infra doc $INFRA_FILE not found. Cannot proceed. Aborting." exit 1 fi if [ ! -f "$HOMEPAGE_FILE" ]; then log_error "Homepage config $HOMEPAGE_FILE not found. Cannot proceed. Aborting." exit 1 fi } # ── Source of Truth: Infra.md ──────────────────────────────────────────────── get_infra_docs() { # Extract container/service entries from homelab_Infra.md # Support both 4-column and 5-column structures dynamically local current_section="" while IFS= read -r line || [ -n "$line" ]; do # Detect section headers if [[ "$line" =~ ^##[[:space:]]+(.+)$ ]]; then current_section="${BASH_REMATCH[1]}" fi # Check if line is a markdown table row (must start with | and contain a bold name) if [[ "$line" =~ ^\|[[:space:]]*\*\*([a-zA-Z0-9_-]+)\*\*[[:space:]]*\| ]]; then # Count the pipes to deduce the number of columns local pipe_count pipe_count=$(echo -n "$line" | tr -cd '|' | wc -c) if [ "$pipe_count" -eq 5 ]; then # Original 4 columns: | Name | Mapped Ports | Access | Desc | local name ports access desc IFS='|' read -r _blank name ports access desc _blank_end <<< "$line" # Clean up whitespaces & bold marks name=$(echo "$name" | tr -d '*' | xargs) ports=$(echo "$ports" | xargs) access=$(echo "$access" | xargs) desc=$(echo "$desc" | xargs) # Output: name|ports|section|desc|proxy|access printf '%s|%s|%s|%s|%s|%s\n' "$name" "$ports" "$current_section" "$desc" "none" "$access" elif [ "$pipe_count" -eq 6 ]; then # Restructured 5 columns: | Name | Mapped Ports | Access | Proxy | Desc | local name ports access proxy desc IFS='|' read -r _blank name ports access proxy desc _blank_end <<< "$line" name=$(echo "$name" | tr -d '*' | xargs) ports=$(echo "$ports" | xargs) access=$(echo "$access" | xargs) proxy=$(echo "$proxy" | xargs) desc=$(echo "$desc" | xargs) # Output: name|ports|section|desc|proxy|access printf '%s|%s|%s|%s|%s|%s\n' "$name" "$ports" "$current_section" "$desc" "$proxy" "$access" fi fi done < "$INFRA_FILE" } # ── Source of Truth: Live Docker ───────────────────────────────────────────── get_live_docker() { # Use _docker-table alias logic inline if ! docker info > /dev/null 2>&1; then log_error "Docker daemon is not running. Aborting." exit 1 fi docker ps --format "{{.Names}}|{{.Status}}|{{.Ports}}" | while IFS='|' read -r name status ports; do cleaned_ports="" if [ -n "$ports" ]; then IFS=',' read -r -a port_array <<< "$ports" for p in "${port_array[@]}"; do p=$(echo "$p" | xargs --no-run-if-empty) # Strip to the right of -> (e.g., "0.0.0.0:80->80/tcp") host_port="${p##*->}" # Strip protocol suffix (tcp/udp) for dedup host_port="${host_port%%/*}" [[ "$host_port" == */tcp ]] && host_port="${host_port%/tcp}" [[ "$host_port" == */udp ]] && host_port="${host_port%/udp}" if [[ -n "$host_port" ]]; then local found=false local existing for existing in $cleaned_ports; do [ "$existing" = "$host_port" ] && found=true done $found || cleaned_ports="${cleaned_ports:+$cleaned_ports, }$host_port" fi done fi if [ -z "$cleaned_ports" ]; then cleaned_ports="internal" fi printf '%s|%s|%s\n' "$name" "$status" "$cleaned_ports" done } # ── Source of Truth: Homelable Backend Nodes ───────────────────────────────── get_mcp_nodes() { # Parse the service key if present local service_key="" if [ -f "/srv/configs/docker_compose/homelabel/.env" ]; then service_key=$(grep -E "^MCP_SERVICE_KEY=" "/srv/configs/docker_compose/homelabel/.env" | cut -d'=' -f2-) fi # Fetch nodes from the backend REST API local payload payload=$(curl -s -H "X-MCP-Service-Key: $service_key" --max-time 10 "${MCP_ENDPOINT}/nodes" 2>/dev/null) if [ -z "$payload" ]; then log_error "Homelable Backend returned empty response. Aborting." exit 1 fi # Parse and output nodes: label|type|ip|status echo "$payload" | jq -r '.[]? | "\(.label // .name // "unknown")|\(.type // "node")|\(.ip // "n/a")|\(.status // "unknown")"' 2>/dev/null } # ── Source of Truth: Nginx Proxies (Stale-Filtering Enabled) ───────────────── get_nginx_proxies() { local conf_dir="/app/nginxproxy/data/nginx/proxy_host" if [ ! -d "$conf_dir" ]; then return 0 fi # Format: PORT|DOMAINS grep -E "set .port|server_name" "$conf_dir"/*.conf 2>/dev/null \ | sed 's/^[ \t]*//; s/.*conf://' \ | paste - - \ | sed 's/set \$port //; s/server_name //; s/;//g' \ | awk '{$1=$1; print}' \ | sort -n \ | while read -r port domains; do # STALE CONFIG ELIMINATION: Only report proxies if the mapped port is actively listening if is_port_active "$port"; then printf '%s|%s\n' "$port" "$domains" fi done } # ── Source of Truth: Homepage Config ───────────────────────────────────────── get_homepage_services() { # Reads services.yaml, stripping carriage returns, and outputs simple mappings: key_name|href|category local current_cat="" local service_name="" local href="" while IFS= read -r raw_line || [ -n "$raw_line" ]; do local line line=$(echo -n "$raw_line" | tr -d '\r') # Detect category (e.g., "- Devices & Network:" or "- Arr:") if [[ "$line" =~ ^-[[:space:]]+([^:]+):$ ]]; then # If we had an unprinted service, print it before changing categories if [ -n "$service_name" ]; then printf '%s|%s|%s\n' "$service_name" "$href" "$current_cat" service_name="" href="" fi current_cat="${BASH_REMATCH[1]}" # Detect service element (e.g., " - Proxmox:") elif [[ "$line" =~ ^[[:space:]]+-[[:space:]]+([^:]+):$ ]]; then if [ -n "$service_name" ]; then printf '%s|%s|%s\n' "$service_name" "$href" "$current_cat" fi service_name="${BASH_REMATCH[1]}" href="none" # Detect href (e.g., " href: \"http://10.0.0.142:8006/\"") elif [[ "$line" =~ href:[[:space:]]*\"([^\"]+)\" || "$line" =~ href:[[:space:]]*(https?://[^\s]+) ]]; then local val="${BASH_REMATCH[1]}" val="${val%\"}" # Remove trailing quotes val="${val#\"}" # Remove leading quotes href="$val" fi done < "$HOMEPAGE_FILE" # Print the last service if [ -n "$service_name" ]; then printf '%s|%s|%s\n' "$service_name" "$href" "$current_cat" fi } # ── Source of Truth: Proxmox VMs / LXCs ────────────────────────────────────── get_infra_proxmox_nodes() { # Extract Proxmox VM/LXC entries from homelab_Infra.md # Format: | ID | Type | Hostname | IP | Role | while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^\|[[:space:]]*([0-9_-]+|-)[[:space:]]*\|[[:space:]]*(LXC|VM)[[:space:]]*\|[[:space:]]*\*\*([a-zA-Z0-9_-]+)\*\*[[:space:]]*\|[[:space:]]*([0-9.]+)[[:space:]]*\| ]]; then local type="${BASH_REMATCH[2]}" local hostname="${BASH_REMATCH[3]}" local ip="${BASH_REMATCH[4]}" printf '%s|%s|%s\n' "$hostname" "$type" "$ip" fi done < "$INFRA_FILE" } # ── 3-Way Reconciliation Engine ────────────────────────────────────────────── reconcile() { local infra_count docker_count mcp_count proxy_count hp_count infra_count=$(get_infra_docs | wc -l) docker_count=$(get_live_docker | wc -l) mcp_count=$(get_mcp_nodes | wc -l) proxy_count=$(get_nginx_proxies | wc -l) hp_count=$(get_homepage_services | wc -l) log_info "Infra.md: $infra_count | Live Docker: $docker_count | MCP Nodes: $mcp_count | Proxies (Active): $proxy_count | Homepage Dashboard Cards: $hp_count" local diff_report="" diff_report+="## Homelab Infrastructure Reconciliation Report\n" diff_report+="Generated: $(date '+%Y-%m-%d %H:%M:%S')\n\n" # ── Comparison 1: Documentation vs Live Docker Drift ── diff_report+="### 1. Documentation vs. Live Docker Drift\n\n" local doc_names=() while IFS='|' read -r name _ports _section _desc _proxy _access; do # Only compare Docker Services section if [[ "$_section" == "Docker services" ]]; then doc_names+=("$name") fi done < <(get_infra_docs) local live_names=() while IFS='|' read -r name _status _ports; do live_names+=("$name") done < <(get_live_docker) # Missing in Docker local missing=0 for doc in "${doc_names[@]}"; do local found=false for live in "${live_names[@]}"; do [ "$doc" = "$live" ] && found=true && break done # Exceptions if [ "$doc" = "dev" ] || [ "$doc" = "calibre" ]; then found=true fi $found || { diff_report+=" ✗ **${doc}** — listed in Infra.md but NOT running in Docker\n" ((missing++)) || true } done [ $missing -eq 0 ] && diff_report+=" ✅ All documented container services are running\n" # Untracked in Documentation local untracked=0 for live in "${live_names[@]}"; do local found=false for doc in "${doc_names[@]}"; do [ "$live" = "$doc" ] && found=true && break done # Map group names or sub-services if [[ "$live" == "paperless-webserver-1" && " ${doc_names[*]} " == *" paperless-ngx "* ]]; then found=true; fi if [[ "$live" == "paperless-broker-1" && " ${doc_names[*]} " == *" paperless-broker "* ]]; then found=true; fi if [[ "$live" == "paperless-db-1" && " ${doc_names[*]} " == *" paperless-db "* ]]; then found=true; fi if [[ "$live" == "slopsmith-web" && " ${doc_names[*]} " == *" slopsmith-web "* ]]; then found=true; fi if [[ "$live" == "musicbrainz_picard" && " ${doc_names[*]} " == *" musicbrainz_picard "* ]]; then found=true; fi $found || { local status=$(get_live_docker | grep "^${live}|" | head -1 | cut -d'|' -f2) local ports=$(get_live_docker | grep "^${live}|" | head -1 | cut -d'|' -f3) diff_report+=" ⚠ **${live}** — running (Status: ${status}, Ports: ${ports}) but NOT in Infra.md\n" ((untracked++)) || true } done [ $untracked -eq 0 ] && diff_report+=" ✅ No untracked containers running\n" # Unhealthy Containers local unhealthy=0 while IFS='|' read -r name status _ports; do if [[ ! "$status" =~ "(healthy)" && "$status" =~ "Up" ]]; then diff_report+=" ⚠ **${name}** — Up but NOT marked healthy (${status})\n" ((unhealthy++)) || true fi done < <(get_live_docker) [ $unhealthy -eq 0 ] && diff_report+=" ✅ All active containers are running healthy\n" # ── Comparison 2: Nginx Proxy Manager Live Mappings vs Doc ── diff_report+="\n### 2. Live Nginx Proxy Mappings vs. Documentation\n\n" local proxy_drift=0 while IFS='|' read -r port domains; do # Cross reference the port against documented services in Infra.md local doc_name="unknown" local doc_proxy="none" while IFS='|' read -r name d_ports _section _desc d_proxy _access; do # Check if port matches any documented port if [[ " $d_ports " == *"$port"* ]]; then doc_name="$name" doc_proxy="$d_proxy" break fi done < <(get_infra_docs) if [ "$doc_name" != "unknown" ]; then # Clean domains to simplify matching local first_domain first_domain=$(echo "$domains" | awk '{print $1}') if [ "$doc_proxy" = "none" ] || [ "$doc_proxy" = "-" ]; then diff_report+=" ⚠ Port **$port** (**$doc_name**) has proxy domains **($domains)** but has NO proxy domain documented in Infra.md\n" ((proxy_drift++)) || true elif [[ "$doc_proxy" != *"$first_domain"* ]]; then diff_report+=" ⚠ Port **$port** (**$doc_name**) proxy drift! Doc says: \`$doc_proxy\`, Live proxy is: \`$domains\`\n" ((proxy_drift++)) || true fi fi done < <(get_nginx_proxies) [ $proxy_drift -eq 0 ] && diff_report+=" ✅ All active proxy definitions align with documentation proxy routes\n" # ── Comparison 3: Live Docker vs MCP Canvas Nodes ── diff_report+="\n### 3. Live Docker vs. Homelable Canvas Drift\n\n" local canvas_drift=0 # Check if active containers exist on canvas while IFS='|' read -r name _status _ports; do if [[ "$name" == *"postgres"* || "$name" == *"redis"* || "$name" == *"db"* || "$name" == *"broker"* ]]; then continue fi local found=false while IFS='|' read -r label type ip status; do # Case insensitive comparison local clean_label clean_label=$(echo "$label" | tr '[:upper:]' '[:lower:]' | tr ' _-' ' ') local clean_name clean_name=$(echo "$name" | tr '[:upper:]' '[:lower:]' | tr ' _-' ' ') # Custom mappings for neko and hugo public/private if [[ "$clean_name" == "docs-public" && "$clean_label" == *"hugo"* && "$clean_label" == *"public"* ]]; then found=true; fi if [[ "$clean_name" == "docs-private" && "$clean_label" == *"hugo"* && "$clean_label" == *"pivate"* ]]; then found=true; fi if [[ "$clean_name" == "guacamole" && "$clean_label" == *"quacamole"* ]]; then found=true; fi if [[ "$clean_label" == *"$clean_name"* || "$clean_name" == *"$clean_label"* ]]; then found=true break fi done < <(get_mcp_nodes) $found || { diff_report+=" ⚠ **${name}** — container is running, but NOT mapped on the Homelable canvas topology\n" ((canvas_drift++)) || true } done < <(get_live_docker) [ $canvas_drift -eq 0 ] && diff_report+=" ✅ All primary running services are mapped on the network canvas\n" # ── Comparison 4: Homepage Configuration Audit ── diff_report+="\n### 4. Homepage Dashboard Audit (services.yaml)\n\n" local hp_drift=0 local hp_services=() while IFS='|' read -r s_name s_href s_cat; do hp_services+=("$s_name|$s_href|$s_cat") done < <(get_homepage_services) # Cross reference living proxied ports vs Homepage links while IFS='|' read -r port domains; do # Check if any alias in domains string is a substring of any card href local found=false local alias_item for alias_item in $domains; do alias_item="${alias_item%;}" # strip semicolon for card in "${hp_services[@]}"; do IFS='|' read -r c_name c_href c_cat <<< "$card" # Case insensitive comparison local clean_alias clean_alias=$(echo "$alias_item" | tr '[:upper:]' '[:lower:]') local clean_href clean_href=$(echo "$c_href" | tr '[:upper:]' '[:lower:]') local clean_name clean_name=$(echo "$c_name" | tr '[:upper:]' '[:lower:]') if [[ "$clean_href" == *"$clean_alias"* || "$clean_name" == *"$clean_alias"* ]]; then found=true break 2 fi done done # Proxmox remains as is pointing directly to its IP, ignore in mapping drift if [[ "$domains" == *"proxmox"* ]]; then found=true fi $found || { # Skip internal system components local first_domain first_domain=$(echo "$domains" | awk '{print $1}') if [[ "$first_domain" != "nginx" && "$first_domain" != "homelable" && "$first_domain" != "openai" && "$first_domain" != "private" ]]; then # Classify the service correctly local category="Docker Containers" if [[ "$port" == "8006" || "$port" == "8010" ]]; then category="Physical Devices & Networking related" elif [[ "$port" == "32015" || "$port" == "30025" || "$port" == "30027" || "$port" == "30014" || "$port" == "30045" || "$port" == "30046" || "$port" == "30050" ]]; then category="Truenas Containers" fi diff_report+=" ⚠ **$first_domain** (Port $port) — active proxy domain is missing from Homepage dashboard! Recommend adding under **$category**.\n" ((hp_drift++)) || true fi } done < <(get_nginx_proxies) [ $hp_drift -eq 0 ] && diff_report+=" ✅ All active proxied domain targets are mapped on the Homepage dashboard\n" # ── Comparison 5: Proxmox Virtualization Node Live Audit (LXC / VMs) ── diff_report+="\n### 5. Proxmox Virtualization Drift (LXC / VMs)\n\n" local proxmox_drift=0 local doc_proxmox=() while IFS='|' read -r h_name h_type h_ip; do proxmox_proxmox_item=$(echo "$h_name" | tr '[:upper:]' '[:lower:]') doc_proxmox+=("$proxmox_proxmox_item") done < <(get_infra_proxmox_nodes) # Fetch live running LXCs/VMs from Proxmox VE API using dynamically loaded homepage token local pve_token="" if [ -f "/srv/configs/docker_compose/homepage/.env" ]; then pve_token=$(grep -E "^HOMEPAGE_VAR_proxmox_password=" "/srv/configs/docker_compose/homepage/.env" | cut -d'=' -f2- | tr -d '\r') # Strip leading/trailing double quotes if present pve_token="${pve_token%\"}" pve_token="${pve_token#\"}" fi if [ -n "$pve_token" ]; then # Query Proxmox API directly (SSL check bypassed with -k) local pve_resources pve_resources=$(curl -k -s -H "Authorization: PVEAPIToken=root@pam!dash=$pve_token" "https://10.0.0.142:8006/api2/json/cluster/resources?type=vm" 2>/dev/null) || pve_resources="" if [ -n "$pve_resources" ]; then while read -r pve_node; do local label status type label=$(echo "$pve_node" | cut -d'|' -f1) type=$(echo "$pve_node" | cut -d'|' -f2) # qemu (VM) or lxc status=$(echo "$pve_node" | cut -d'|' -f3) # Only check active/running nodes if [ "$status" = "running" ] && [ "$label" != "docker" ]; then local clean_label clean_label=$(echo "$label" | tr '[:upper:]' '[:lower:]' | tr ' _-' ' ') local found=false for doc_node in "${doc_proxmox[@]}"; do if [[ "$clean_label" == *"$doc_node"* || "$doc_node" == *"$clean_label"* ]]; then found=true break fi done $found || { local friendly_type="VM" [ "$type" = "lxc" ] && friendly_type="LXC" diff_report+=" ⚠ **${label}** — active Proxmox **${friendly_type}** is running, but NOT documented under Proxmox Nodes in Infra.md\n" ((proxmox_drift++)) || true } fi done < <(echo "$pve_resources" | jq -r '.data[]? | "\(.name)|\(.type)|\(.status)"' 2>/dev/null) else diff_report+=" ⚠ Unable to query Proxmox VE API (connection failure). Skipping live VM/LXC verification.\n" ((proxmox_drift++)) || true fi else diff_report+=" ⚠ Proxmox token missing from homepage/.env. Skipping live VM/LXC verification.\n" ((proxmox_drift++)) || true fi [ $proxmox_drift -eq 0 ] && diff_report+=" ✅ All active Proxmox virtualization nodes are documented\n" # ── Comparison 6: TrueNAS SCALE Node Services Live Audit ── diff_report+="\n### 6. TrueNAS SCALE Node Services Verification\n\n" local truenas_drift=0 # Audit each documented TrueNAS service by performing active port checks directly on truenas (10.0.0.2) while IFS='|' read -r name d_ports section desc proxy access; do if [[ "$section" == "Truenas Services" ]]; then # Verify port is listening over /dev/tcp network socket if timeout 1 bash -c "cat < /dev/null > /dev/tcp/10.0.0.2/${d_ports}" 2>/dev/null; then # Port is active true else diff_report+=" ✗ **${name}** — documented on TrueNAS port **${d_ports}** but service is NOT listening (down/offline)\n" ((truenas_drift++)) || true fi fi done < <(get_infra_docs) [ $truenas_drift -eq 0 ] && diff_report+=" ✅ All documented TrueNAS Services are actively running on port 10.0.0.2\n" cppt_output=$(echo -e "$diff_report") echo "$cppt_output" } # ── Main Execution Pipeline ────────────────────────────────────────────────── main() { log_info "═══════════════════════════════════════════════════════" log_info "Starting Homelab Infrastructure Reconciliation Audit" log_info "═══════════════════════════════════════════════════════" acquire_lock check_port_8001 check_sources log_info "All pre-flight checks passed." if $DRY_RUN; then log_info "DRY RUN — gathering sources without full reconciliation" log_info "--- Infra.md entries ---" get_infra_docs | while IFS='|' read -r name ports section desc proxy access; do echo " $name (section: $section, ports: $ports, proxy: $proxy)" done log_info "--- Live Docker containers ---" get_live_docker | while IFS='|' read -r name status ports; do echo " $name | $status | $ports" done log_info "--- Homelable MCP check ---" local mcp_result mcp_result=$(curl -s --max-time 5 "${MCP_ENDPOINT}" 2>/dev/null || echo "unreachable") if [ "$mcp_result" = "unreachable" ]; then log_info " ✗ MCP unreachable" else log_info " ✓ MCP reachable (first 100 chars: ${mcp_result:0:100})" fi return 0 fi log_info "Fetching live states..." get_infra_docs > /dev/null get_live_docker > /dev/null get_mcp_nodes > /dev/null log_info "All sources fetched." log_info "Running reconciliation..." reconcile } main "$@"