128 lines
6.6 KiB
Bash
128 lines
6.6 KiB
Bash
#!/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 "$@"
|