#!/usr/bin/env python3 """ hermes-doc-enrich — Hermes inspecte le cluster (LECTURE SEULE) et ajoute une section "## État vérifié" à chaque doc admin/. Jamais de suppression ni de réécriture — ajout uniquement. Commit local dans hermes/daily-work + rag-ingest Qdrant à la fin. """ import argparse import json import subprocess from datetime import datetime from pathlib import Path REPO_DIR = Path("/srv/data/lab/Funk-lab") BRANCH = "hermes/daily-work" ADMIN_DIR = REPO_DIR / "admin" RAG_DOCS = Path("/srv/data/rag/docs") STATE_FILE = Path("/var/lib/hermes-auto-improve/enrich-state.json") MAX_FILES = 3 SECTION_MARKER = "## État vérifié" # ── Commandes read-only par fichier (clé = substring du chemin relatif) ────── CONTEXT_MAP = { "ia/litellm": [ "systemctl status litellm --no-pager -n 5", "cat /etc/litellm/config.yaml", ], "ia/hermes.md": [ "systemctl status hermes --no-pager -n 5", "cat /srv/data/hermes/config.yaml 2>/dev/null | head -40", ], "ia/hermes-souls": [ "ls /srv/data/hermes/ 2>/dev/null", "cat /srv/data/hermes/config.yaml 2>/dev/null | grep -A3 'profile' | head -20", ], "ia/hermes-voice": [ "systemctl status hermes-voice 2>/dev/null --no-pager -n 5 || echo 'service hermes-voice absent'", ], "ia/llama_server": [ "systemctl cat llama-server 2>/dev/null | grep -E 'ExecStart|Environment|Restart' | head -10", "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'systemctl status llama-server --no-pager -n 5' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "ia/rag": [ "systemctl status qdrant --no-pager -n 5", "ls /srv/data/rag/docs/ 2>/dev/null | wc -l", "/usr/local/bin/rag-query 'hermes' 2>&1 | head -10", ], "ia/rocm": [ "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'uname -r; rocm-smi 2>/dev/null | head -10' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "ia/alertmanager-webhook": [ "systemctl status alertmanager-webhook --no-pager -n 5", "cat /etc/alertmanager-webhook/*.yaml 2>/dev/null | head -30", ], "infra/dnsmasq": [ "systemctl status dnsmasq --no-pager -n 5", "dig @127.0.0.1 compute-01.lab.local +short 2>/dev/null", "dig @127.0.0.1 openwebui.lab.local +short 2>/dev/null", ], "infra/nfs": [ "systemctl status nfs-server --no-pager -n 5", "exportfs -v 2>/dev/null", ], "infra/reseau": [ "ip addr show | grep 'inet '", "nft list ruleset 2>/dev/null | grep -E 'chain|policy|iif|oif|dport' | head -30", ], "infra/ssh": [ "sshd -T 2>/dev/null | grep -E 'permitrootlogin|passwordauth|pubkeyauth|^port '", ], "infra/email": [ "systemctl status postfix --no-pager -n 5", "postconf -n 2>/dev/null | grep -vi 'pass' | grep -E 'relay|tls|sasl'", ], "install/storage-01": [ "hostnamectl | grep -E 'Hostname|OS|Kernel'", "df -h | grep -v tmpfs", "free -h", "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$'", ], "install/gpu-01": [ "ssh -o StrictHostKeyChecking=no -o ConnectTimeout=5 gpu-01 'hostnamectl | grep -E \"Hostname|OS|Kernel\"; df -h | grep -v tmpfs; systemctl list-units --state=failed --no-pager 2>/dev/null' 2>/dev/null || echo 'gpu-01 inaccessible'", ], "install/kubernetes": [ "kubectl get nodes -o wide 2>/dev/null", "kubectl get pods -A --field-selector=status.phase!=Running 2>/dev/null | head -10", ], "k8s/argocd": [ "kubectl get applications -n argocd -o wide 2>/dev/null", "kubectl get pods -n argocd 2>/dev/null", ], "k8s/monitoring": [ "kubectl get pods -n monitoring 2>/dev/null", "kubectl get ingress -n monitoring 2>/dev/null", ], "k8s/n8n": [ "kubectl get all -n ai -l app=n8n 2>/dev/null", "kubectl get ingress -n ai 2>/dev/null", ], "k8s/open-webui": [ "kubectl get all -n ai 2>/dev/null", "kubectl get ingress -n ai 2>/dev/null", ], "k8s/talos": [ "kubectl get nodes 2>/dev/null", "talosctl version 2>/dev/null | head -4", ], "k8s/k9s": [ "k9s version 2>/dev/null || echo 'k9s non installé sur storage-01'", "kubectl config get-contexts 2>/dev/null", ], "ops/ansible": [ "ansible --version 2>/dev/null | head -3", "ls /srv/data/lab/Funk-lab/ansible/roles/", ], "ops/cluster": [ "kubectl get nodes -o wide 2>/dev/null", "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -10", ], "ops/systeme": [ "uname -r", "uptime", "df -h | grep -v tmpfs", "free -h", "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -10", ], "README": [ "systemctl is-active litellm hermes dnsmasq nfs-server qdrant postfix 2>/dev/null", ], } DEFAULT_COMMANDS = [ "date", "systemctl list-units --state=failed --no-pager 2>/dev/null | grep -v '^$' | head -5", ] # ── Git ─────────────────────────────────────────────────────────────────────── def git(args): return subprocess.run( ["git", "-C", str(REPO_DIR)] + args, capture_output=True, text=True ) def git_sync(): git(["config", "user.email", "hermes@funk.lab"]) git(["config", "user.name", "Hermes Bot"]) git(["fetch", "origin"]) r = git(["checkout", BRANCH]) if r.returncode != 0: git(["checkout", "-b", BRANCH, f"origin/{BRANCH}"]) git(["pull", "--rebase", "origin", BRANCH]) # ── Cluster inspection (read-only) ─────────────────────────────────────────── def run_cmd(cmd): try: r = subprocess.run( cmd, shell=True, capture_output=True, text=True, timeout=30 ) out = (r.stdout + r.stderr).strip() return out[:1500] if out else "(aucune sortie)" except subprocess.TimeoutExpired: return "(timeout)" except Exception as e: return f"(erreur: {e})" def get_context_cmds(rel_path): for key, cmds in CONTEXT_MAP.items(): if key in rel_path: return cmds return DEFAULT_COMMANDS def collect_cluster_state(cmds): parts = [] for cmd in cmds: out = run_cmd(cmd) parts.append(f"$ {cmd}\n{out}") return "\n\n".join(parts) # ── Hermes ──────────────────────────────────────────────────────────────────── def ask_hermes(prompt): safe = prompt.replace("'", "'\\''") cmd = [ "sudo", "-i", "-u", "hermes", "bash", "-c", f"HERMES_HOME=/srv/data/hermes hermes --profile funk-ai -z '{safe}'" ] r = subprocess.run(cmd, capture_output=True, text=True, timeout=300) return r.stdout.strip() def build_verified_section(fpath, cluster_state, today): rel = str(fpath.relative_to(REPO_DIR)) content = fpath.read_text(encoding="utf-8", errors="replace") prompt = f"""Tu es l'agent de documentation du homelab Funk. MISSION : écrire UNIQUEMENT une nouvelle section à ajouter à la fin de ce document. CONTRAINTE ABSOLUE : ne touche pas au contenu existant — ajout uniquement. Fichier concerné : {rel} --- ÉTAT RÉEL DU CLUSTER (commandes read-only) --- {cluster_state[:3000]} --- FIN --- Écris une section Markdown intitulée exactement : ## État vérifié — {today} Cette section doit contenir : - État des services concernés (actif/inactif/dégradé) - Versions observées si pertinent - Toute divergence notable entre la doc et l'état réel - Tout élément opérationnel utile observé (ex: pods crashés, export NFS manquant, config inattendue) RÈGLES : - Sois concis (5-15 lignes max) - Ne reproduis pas ce qui est déjà dans le document - Si tout est conforme, dis-le en une phrase - Réponds UNIQUEMENT avec le contenu Markdown de la section, rien d'autre""" result = ask_hermes(prompt) # S'assure que la section commence bien par le bon titre if SECTION_MARKER not in result: result = f"{SECTION_MARKER} — {today}\n\n{result}" return result, content def enrich_file(fpath, today): rel = str(fpath.relative_to(REPO_DIR)) cmds = get_context_cmds(rel) print(f" Collecte état cluster ({len(cmds)} cmd)...", flush=True) cluster_state = collect_cluster_state(cmds) print(f" Hermes génère la section...", flush=True) new_section, original_content = build_verified_section(fpath, cluster_state, today) if not new_section or len(new_section) < 30: return False, f"section trop courte ({len(new_section)} chars)" # Retire l'ancienne section "État vérifié" si elle existe déjà if SECTION_MARKER in original_content: # Coupe au premier SECTION_MARKER et remplace par la nouvelle section idx = original_content.index(SECTION_MARKER) base = original_content[:idx].rstrip() else: base = original_content.rstrip() updated = base + "\n\n---\n\n" + new_section.strip() + "\n" fpath.write_text(updated, encoding="utf-8") return True, None # ── Qdrant re-ingest ────────────────────────────────────────────────────────── def rag_ingest(): subprocess.run( ["rsync", "-a", "--delete", str(ADMIN_DIR) + "/", str(RAG_DOCS) + "/"], check=True ) r = subprocess.run( ["/usr/local/bin/rag-ingest"], capture_output=True, text=True, timeout=300 ) return (r.stdout + r.stderr).strip()[-300:] # ── State ───────────────────────────────────────────────────────────────────── def load_state(): if STATE_FILE.exists(): return json.loads(STATE_FILE.read_text()) return {"enriched": [], "errors": []} def save_state(state): STATE_FILE.parent.mkdir(parents=True, exist_ok=True) STATE_FILE.write_text(json.dumps(state, indent=2, ensure_ascii=False)) # ── Main ────────────────────────────────────────────────────────────────────── def main(): parser = argparse.ArgumentParser() parser.add_argument("--all", action="store_true", help="Traiter tous les fichiers en une passe") parser.add_argument("--reset", action="store_true", help="Réinitialiser l'état de progression") parser.add_argument("--dry-run", action="store_true", help="Afficher le plan sans modifier les fichiers") parser.add_argument("--subdir", default=None, help="Sous-dossier admin/ (ex: ia, k8s)") parser.add_argument("--no-rag", action="store_true", help="Ne pas relancer rag-ingest à la fin") args = parser.parse_args() today = datetime.now().strftime("%Y-%m-%d") now = datetime.now().strftime("%Y-%m-%d_%H-%M") print(f"hermes-doc-enrich — {now}", flush=True) print(f"Branche : {BRANCH} (local, pas de push)", flush=True) if not args.dry_run: git_sync() state = load_state() if args.reset: state = {"enriched": [], "errors": []} save_state(state) print("État réinitialisé.\n") scan_root = ADMIN_DIR / args.subdir if args.subdir else ADMIN_DIR all_md = [ f for f in sorted(scan_root.rglob("*.md")) if "hermes/builtin" not in str(f) ] if args.all: batch = all_md else: todo = [f for f in all_md if str(f) not in state["enriched"]] if not todo: state["enriched"] = [] save_state(state) todo = all_md print("Rotation complète — tous les fichiers repassés.") batch = todo[:MAX_FILES] print(f"\n{len(batch)} fichiers à enrichir\n{'─'*50}", flush=True) changed = [] for fpath in batch: rel = str(fpath.relative_to(REPO_DIR)) print(f"\n→ {rel}", flush=True) if args.dry_run: cmds = get_context_cmds(rel) print(f" [dry-run] {len(cmds)} commandes :") for c in cmds: print(f" {c}") continue ok, err = enrich_file(fpath, today) if not ok: print(f" ✗ {err}", flush=True) state["errors"].append(rel) continue state["enriched"].append(str(fpath)) changed.append(rel) print(f" ✓ Section ajoutée", flush=True) git(["add", rel]) if git(["diff", "--cached", "--quiet"]).returncode != 0: git(["commit", "-m", f"docs(hermes): état vérifié {Path(rel).name} — {today}\n\n" f"Section '## État vérifié' ajoutée/mise à jour.\n" f"Inspection read-only du cluster, aucune modification de service.\n\n" "Co-Authored-By: Hermes "]) print(f" ✓ Commit local", flush=True) else: print(f" — Aucun changement détecté (section déjà à jour)", flush=True) if not args.dry_run: save_state(state) # ── Qdrant re-ingest ────────────────────────────────────────────────────── if not args.dry_run and changed and not args.no_rag: print(f"\n⏳ Qdrant re-ingest ({len(changed)} docs mis à jour)...", flush=True) try: out = rag_ingest() print(f" ✓ {out}", flush=True) except Exception as e: print(f" ✗ rag-ingest échoué : {e}", flush=True) print(f"\n{'─'*50}") print(f"Enrichissements : {len(changed)}/{len(batch)}") if changed: print("Fichiers mis à jour :") for f in changed: print(f" • {f}") print(f"\nBranche : {BRANCH} (local, pas de push)") print("Pour créer la PR : hermes-auto-improve --daily-pr") if __name__ == "__main__": main()