219 lines
7.4 KiB
Bash
Executable File
219 lines
7.4 KiB
Bash
Executable File
#!/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_<name> 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/<name>.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=<username> 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: <module> <status> 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" <<EOF
|
|
#!/bin/bash
|
|
exec sudo -u "$ANSIPA_USER" "$YAY_BIN" "\$@"
|
|
EOF
|
|
chmod +x "$WRAP_DIR/yay"
|
|
fi
|
|
|
|
# ── Kinit with host keytab so IPA commands work from the service context ──────
|
|
kinit -k "host/$HOST_FQDN" &>/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
|