#!/usr/bin/env bash # ansipa-install-modules.sh — apply setup modules to this host based on # FreeIPA host group membership. # # Host groups follow the naming convention: # dev_mod_ e.g. dev_mod_docker, dev_mod_ollama # # When this host is a member of such a group, the corresponding module # script in /usr/local/lib/ansipa-modules/.sh is executed once. # Completion is tracked via a stamp file in STATE_DIR and reflected in # a JSON manifest at STATE_DIR/manifest.json. # # Guard: the script exits immediately if the FreeIPA client is not enrolled # (/etc/ipa/default.conf must exist and the ipa command must be available). # # Configuration: /etc/ansipa-modules.conf # ANSIPA_USER= non-root user for AUR helper (yay) # MODULES_DIR=/usr/local/lib/ansipa-modules # STATE_DIR=/var/lib/ansipa-modules set -euo pipefail CONFIG=/etc/ansipa-modules.conf [[ -f "$CONFIG" ]] && source "$CONFIG" ANSIPA_USER="${ANSIPA_USER:-}" MODULES_DIR="${MODULES_DIR:-/usr/local/lib/ansipa-modules}" STATE_DIR="${STATE_DIR:-/var/lib/ansipa-modules}" MANIFEST="$STATE_DIR/manifest.json" PREFIX="dev_mod_" LOG_TAG="ansipa-modules" log() { echo "[$LOG_TAG] $*"; logger -t "$LOG_TAG" "$*" 2>/dev/null || true; } warn() { echo "[$LOG_TAG][WARN] $*" >&2; logger -t "$LOG_TAG" "WARN: $*" 2>/dev/null || true; } # ── Guard: only proceed if the ansipa/FreeIPA client is enrolled ────────────── # /etc/ipa/default.conf is written by ipa-client-install on successful enrollment. # Without it, there is no domain to query and no host groups to check. if [[ ! -f /etc/ipa/default.conf ]]; then log "FreeIPA client not enrolled (/etc/ipa/default.conf absent) — skipping." exit 0 fi if ! command -v ipa &>/dev/null; then warn "ipa command not found — FreeIPA packages not installed. Exiting." exit 0 fi # ── State directories ───────────────────────────────────────────────────────── mkdir -p "$STATE_DIR" HOST_FQDN=$(hostname -f 2>/dev/null || hostname) # ── Manifest helpers (python3) ──────────────────────────────────────────────── # The manifest is a JSON file that records the status and timestamps for every # module this host has encountered. It is updated atomically (tmp + rename). # Stamp files (.done) remain the authoritative "is this installed" check; # the manifest is derived from them and used for reporting/auditing. _manifest_update() { # Args: status ∈ {installed, failed, pending} local module="$1" status="$2" local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") python3 - "$MANIFEST" "$module" "$status" "$HOST_FQDN" "$ts" <<'PYEOF' import json, sys, os manifest_path, module, status, hostname, ts = sys.argv[1:6] try: with open(manifest_path) as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError): data = {} data["hostname"] = hostname data.setdefault("modules", {}) data["last_run"] = ts entry = data["modules"].setdefault(module, {}) entry["status"] = status if status == "installed": # Preserve original installed_at if already recorded entry.setdefault("installed_at", ts) else: entry["last_attempt"] = ts tmp = manifest_path + ".tmp" with open(tmp, "w") as f: json.dump(data, f, indent=2) f.write("\n") os.rename(tmp, manifest_path) PYEOF } _manifest_touch_run() { # Update last_run and hostname without touching any module entries local ts; ts=$(date -u +"%Y-%m-%dT%H:%M:%SZ") python3 - "$MANIFEST" "$HOST_FQDN" "$ts" <<'PYEOF' import json, sys, os manifest_path, hostname, ts = sys.argv[1:4] try: with open(manifest_path) as f: data = json.load(f) except (FileNotFoundError, json.JSONDecodeError): data = {} data["hostname"] = hostname data.setdefault("modules", {}) data["last_run"] = ts tmp = manifest_path + ".tmp" with open(tmp, "w") as f: json.dump(data, f, indent=2) f.write("\n") os.rename(tmp, manifest_path) PYEOF } # Reconcile: scan existing .done stamps into the manifest. # Handles modules installed before the manifest feature was introduced. _manifest_reconcile() { local stamp_file mod for stamp_file in "$STATE_DIR"/*.done; do [[ -f "$stamp_file" ]] || continue mod="${stamp_file%.done}" mod="${mod##*/}" _manifest_update "$mod" "installed" done } # ── Resolve ANSIPA_USER ─────────────────────────────────────────────────────── if [[ -z "$ANSIPA_USER" ]]; then ANSIPA_USER=$(awk -F: '($3>=1000 && $7!~/nologin|false/) {print $1; exit}' /etc/passwd) fi if [[ -z "$ANSIPA_USER" ]]; then warn "Cannot determine ANSIPA_USER. Set it in $CONFIG." exit 1 fi log "Running as root, AUR helper delegated to user: $ANSIPA_USER" # ── Create a yay wrapper so module scripts can call 'yay' as non-root ──────── YAY_BIN=$(command -v yay 2>/dev/null || true) WRAP_DIR=$(mktemp -d /tmp/ansipa-wrap.XXXXXX) trap 'rm -rf "$WRAP_DIR"' EXIT if [[ -n "$YAY_BIN" ]]; then cat > "$WRAP_DIR/yay" </dev/null || true # Record this run in the manifest and reconcile any pre-existing stamps _manifest_touch_run _manifest_reconcile # ── Discover which dev_mod_* host groups this host belongs to ───────────────── RAW_GROUPS=$(ipa host-show "$HOST_FQDN" --all 2>/dev/null \ | grep -i "Member of host-groups:" | sed 's/.*: //' || true) if [[ -z "$RAW_GROUPS" ]]; then log "Host '$HOST_FQDN' is not a member of any host groups — nothing to do." exit 0 fi # Parse comma-separated list, keep only dev_mod_* entries WANTED_MODULES=() while IFS=',' read -ra GRP_ARRAY; do for g in "${GRP_ARRAY[@]}"; do g="${g// /}" if [[ "$g" == ${PREFIX}* ]]; then WANTED_MODULES+=("${g#$PREFIX}") fi done done <<< "$RAW_GROUPS" if [[ ${#WANTED_MODULES[@]} -eq 0 ]]; then log "No ${PREFIX}* host groups found for '$HOST_FQDN'." exit 0 fi log "Modules requested for this host: ${WANTED_MODULES[*]}" # ── Apply each module ───────────────────────────────────────────────────────── for MODULE in "${WANTED_MODULES[@]}"; do STAMP="$STATE_DIR/${MODULE}.done" SCRIPT="$MODULES_DIR/${MODULE}.sh" if [[ -f "$STAMP" ]]; then log "Module '$MODULE' already applied (stamp: $STAMP) — skipping." continue fi if [[ ! -f "$SCRIPT" ]]; then warn "Module script not found: $SCRIPT — skipping '$MODULE'." _manifest_update "$MODULE" "pending" continue fi log "Applying module: $MODULE" _manifest_update "$MODULE" "pending" if env PATH="$WRAP_DIR:$PATH" bash "$SCRIPT" >>"$STATE_DIR/${MODULE}.log" 2>&1; then touch "$STAMP" _manifest_update "$MODULE" "installed" log "Module '$MODULE' applied successfully." else _manifest_update "$MODULE" "failed" warn "Module '$MODULE' failed — see $STATE_DIR/${MODULE}.log" fi done