#!/usr/bin/env bash # # homelab-infrastructure-review.sh # Triggers a full, intelligent, context-aware reconciliation audit. # set -euo pipefail # ── Configuration & Logging ────────────────────────────────────────────────── AUDIT_CONFIG_FILE="/srv/configs/ai/skills/homelab-infrastructure-review/scripts/audit_config.yaml" INFRA_FILE="/srv/docs/public/projects/homelab/homelab_Infra.md" LOCK_FILE="/srv/configs/ai/.audit.lock" log_info() { echo "[INFO] $(date '+%Y-%m-%d %H:%M:%S') $*"; } log_error() { echo "[ERROR] $(date '+%Y-%m-%d %H:%M:%S') $*" >&2; } # ── Pre-flight & Lock ──────────────────────────────────────────────────────── acquire_lock() { if [ -f "$LOCK_FILE" ]; then local pid; pid=$(cat "$LOCK_FILE" 2>/dev/null) if [ -n "$pid" ] && kill -0 "$pid" 2>/dev/null; then log_error "Audit already running (PID $pid). Aborting."; exit 1 fi log_info "Stale lock file found. Cleaning up."; rm -f "$LOCK_FILE" fi echo $$ > "$LOCK_FILE"; trap 'rm -f "$LOCK_FILE"' EXIT } preflight_checks() { if [ ! -f "$AUDIT_CONFIG_FILE" ]; then log_error "audit_config.yaml not found. Aborting."; exit 1; fi log_info "All pre-flight checks passed." } # ── Data Sources ───────────────────────────────────────────────────────────── get_infra_docs() { local in_services_section=false while IFS= read -r line || [ -n "$line" ]; do if [[ "$line" =~ ^##[[:space:]]+(Docker|Truenas)[[:space:]]+services ]]; then in_services_section=true elif [[ "$line" =~ ^##[[:space:]] && ! "$line" =~ ^##[[:space:]]+(Docker|Truenas)[[:space:]]+services ]]; then in_services_section=false fi if $in_services_section && [[ "$line" =~ ^\|[[:space:]]*\*\*([a-zA-Z0-9_-]+)\*\* ]]; then IFS='|' read -r _ name _ <<< "$line"; printf '%s\n' "$(echo "$name" | tr -d '*' | xargs)" fi done < "$INFRA_FILE" } get_live_docker_containers() { while IFS= read -r line; do local name; name=$(echo "$line" | jq -r '.Names') local health; health=$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}no_health_check{{end}}' "$name" 2>/dev/null || echo "error") local status; case "$health" in healthy) status="healthy";; unhealthy) status="UNHEALTHY";; *) status="no_health_check";; esac printf '%s|%s\n' "$name" "$status" done <<< "$(docker ps --format '{{json .}}')" } get_mcp_nodes() { 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 curl -s -H "X-MCP-Service-Key: $service_key" "http://localhost:9445/api/v1/nodes" | jq -r '.[]? | .label // .name // "unknown"' } # ── Reconciliation Engine ──────────────────────────────────────────────────── if [ -f "$AUDIT_CONFIG_FILE" ]; then source <(python3 /srv/configs/ai/skills/homelab-infrastructure-review/scripts/load_config.py "$AUDIT_CONFIG_FILE") fi reconcile() { local doc_services=(); while IFS= read -r name; do doc_services+=("$(echo "$name"|tr '[:upper:]' '[:lower:]')"); done < <(get_infra_docs) local live_services=(); local untracked_report=""; local unhealthy_report="" while IFS='|' read -r name status; do local lower_name; lower_name=$(echo "$name" | tr '[:upper:]' '[:lower:]') live_services+=("$lower_name") if [[ " ${IGNORE_CONTAINERS} " == *" ${lower_name} "* ]]; then continue; fi local found=false; for doc in "${doc_services[@]}"; do [ "$lower_name" = "$doc" ] && found=true && break; done local alias_var="SERVICE_ALIAS_${lower_name//-/_}" if ! $found && [[ -v $alias_var ]]; then local target="${!alias_var}"; for doc in "${doc_services[@]}"; do [ "$target" = "$doc" ] && found=true && break; done fi if ! $found; then untracked_report+=" ⚠ **$name** is running but not documented.\n"; fi if [[ "$status" == "UNHEALTHY" ]]; then unhealthy_report+=" ✗ **$name** is **UNHEALTHY**.\n"; fi done < <(get_live_docker_containers) local missing_report="" for doc in "${doc_services[@]}"; do if [ "$doc" = "dev" ] || [ "$doc" = "calibre" ]; then continue; fi # Exceptions local found=false; for live in "${live_services[@]}"; do [ "$doc" = "$live" ] && found=true && break; done if ! $found; then missing_report+=" ✗ **$doc** is documented but not running.\n"; fi done local mcp_nodes=(); while IFS= read -r label; do mcp_nodes+=("$(echo "$label"|tr '[:upper:]' '[:lower:]'|tr ' _-' ' ')"); done < <(get_mcp_nodes) local canvas_report="" for name in "${live_services[@]}"; do if [[ " ${IGNORE_CONTAINERS} " == *" ${name} "* ]]; then continue; fi local found=false; for node in "${mcp_nodes[@]}"; do [[ "$node" == *"$name"* || "$name" == *"$node"* ]] && found=true && break; done local alias_var="CANVAS_ALIAS_${name//-/_}" if ! $found && [[ -v $alias_var ]]; then local target="${!alias_var}"; for node in "${mcp_nodes[@]}"; do [ "$target" = "$node" ] && found=true && break; done fi if ! $found; then canvas_report+=" ⚠ **$name** is running but not on the canvas.\n"; fi done printf "## Homelab Audit Report\nGenerated: $(date)\n\n" printf "### Docker vs. Documentation\n" if [ -z "$untracked_report$missing_report$unhealthy_report" ]; then printf " ✅ All services are aligned and healthy.\n" else printf "%s%s%s" "$untracked_report" "$missing_report" "$unhealthy_report" fi printf "\n### Docker vs. Canvas\n" if [ -z "$canvas_report" ]; then printf " ✅ All services are mapped.\n"; else printf "%s" "$canvas_report"; fi } # ── Main Execution ─────────────────────────────────────────────────────────── main() { log_info "Starting Homelab Infrastructure Audit" acquire_lock preflight_checks log_info "Running reconciliation..." reconcile } main "$@"