#!/usr/bin/env bash # Wöchentlicher Auto-Update-Lauf der Box (Autonomie E2). Läuft als User via # mc2-autoupdate.timer (So 04:30, nach dem 03:30-Backup) oder manuell. # # Prinzip „Haushaltsgerät": Fremd-Software (Router → Engine → Hermes) updatet sich # selbst über die BESTEHENDEN Pfade (update-swap.sh / update-engine.sh / hermes-update-Job). # Jede Ebene: Update-Check → Update → Postcheck. Rot ⇒ Rollback + SELBST-PINNING im # Pin-Register + Telegram-Meldung. Grün ⇒ Telegram „eingespielt X→Y". # Gepinnte Ebenen werden übersprungen, bis der Pin manuell gelöst wird. # # OS-Ebene: bewusst NICHT hier — unattended-upgrades (nur Security) wird in der # einmaligen sudo-Session eingerichtet (siehe deploy/sudoers-mc2-autonomie). # Modelle: NIE automatisch (User entscheidet nach Bench, Etappe E5). # # Router/Engine laufen als root: braucht die NOPASSWD-Regel aus # deploy/sudoers-mc2-autonomie. Fehlt sie, wird die Ebene übersprungen + gemeldet. set -uo pipefail # bewusst KEIN -e: jede Ebene wird kontrolliert abgearbeitet API="${MC_API:-http://127.0.0.1:9001}" PINS="${MC_PINS_FILE:-/srv/models/mc2-pins.json}" SRC_DIR="$(cd "$(dirname "$0")" && pwd)" HERMES_DIR="${HERMES_AGENT_DIR:-$HOME/.hermes/hermes-agent}" BACKUP_DIR="${MC_BACKUP_DIR:-/srv/models/mc2-backups}" JOB_TIMEOUT="${MC_AUTOUPDATE_JOB_TIMEOUT:-1200}" # Sekunden für den Hermes-Job export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}" say(){ echo "[autoupdate] $*"; } notify(){ bash "$SRC_DIR/notify.sh" -s "[Box-Update]" "$1" || true; } SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung # ── Pin-Register ───────────────────────────────────────────────────────────── pins_init(){ [ -f "$PINS" ] || echo '{}' > "$PINS"; } is_pinned(){ jq -e --arg k "$1" '.[$k].pinned == true' "$PINS" >/dev/null 2>&1; } pin_info(){ jq -r --arg k "$1" '.[$k] | "\(.version // "?") seit \(.datum // "?") (\(.grund // "?"))"' "$PINS" 2>/dev/null; } set_pin(){ # $1 komponente $2 version(known-good) $3 grund local tmp; tmp="$(mktemp)" jq --arg k "$1" --arg v "$2" --arg g "$3" --arg d "$(date +%F)" \ '.[$k] = {pinned: true, version: $v, grund: $g, datum: $d}' "$PINS" > "$tmp" && mv "$tmp" "$PINS" } # ── Helfer ─────────────────────────────────────────────────────────────────── details(){ curl -sf --max-time 60 "$API/api/maintenance/update-details?kind=$1"; } # „darf ich das PASSWORTLOS?" — sudo -n -l reicht nicht (Exit 0 auch wenn nur # MIT Passwort erlaubt); deshalb explizit die NOPASSWD-Zeilen der Whitelist greppen. sudo_ok(){ sudo -n -l 2>/dev/null | grep -F "NOPASSWD" | grep -qF "$1"; } # Router und Engine teilen denselben Ablauf: root-Skript mit eingebautem # Postcheck+Rollback (Exit 0 grün / 1 zurückgerollt / 2 kaputt). run_root_update(){ # $1 komponente(swap|engine) $2 skript $3 anzeigename $4 installed $5 latest local comp="$1" script="$2" name="$3" installed="$4" latest="$5" rc say "$name: Update $installed → $latest wird eingespielt…" sudo -n /usr/bin/bash "$SRC_DIR/$script"; rc=$? case "$rc" in 0) SUMMARY+=("$name: $installed → $latest eingespielt, Stack-Check grün.") notify "$name aktualisiert: $installed → $latest. Stack-Check grün, alles läuft." ;; 1) set_pin "$comp" "$installed" "Update auf $latest zerschoss den Stack-Check; Rollback grün" SUMMARY+=("$name: $latest FEHLGESCHLAGEN → zurückgerollt auf $installed + GEPINNT.") notify "$name-Update auf $latest fehlgeschlagen (Stack-Check rot). Automatisch zurückgerollt auf $installed — läuft wieder. Ebene ist jetzt GEPINNT, künftige Läufe überspringen sie." ;; *) SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!") notify "KRITISCH: $name-Update auf $latest UND Rollback fehlgeschlagen — Stack möglicherweise kaputt. Bitte melden, ich brauche Hilfe (Backup liegt bereit, restore.sh existiert)." ;; esac } check_layer_root(){ # $1 komponente $2 skript $3 anzeigename $4 details-kind local comp="$1" script="$2" name="$3" kind="$4" det installed latest if is_pinned "$comp"; then say "$name: GEPINNT ($(pin_info "$comp")) — übersprungen." SUMMARY+=("$name: gepinnt, übersprungen.") return fi det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; SUMMARY+=("$name: Check fehlgeschlagen."); return; } installed="$(jq -r '.installed_build // empty' <<<"$det")" latest="$(jq -r '.latest_build // empty' <<<"$det")" if [ -z "$installed" ] || [ -z "$latest" ]; then say "$name: Versionen nicht ermittelbar (installed='$installed' latest='$latest')." SUMMARY+=("$name: Versions-Check unklar, nichts getan.") return fi if [ "$latest" -le "$installed" ] 2>/dev/null; then say "$name: aktuell ($installed)." SUMMARY+=("$name: aktuell ($installed).") return fi if ! sudo_ok "$script"; then say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt." SUMMARY+=("$name: Update $installed → $latest wartet — sudo-Freischaltung fehlt (einmalig deploy/sudoers-mc2-autonomie installieren).") return fi run_root_update "$comp" "$script" "$name" "$installed" "$latest" } # ── Hermes-Agent (User-Space, via MC2-Job + Polling; Rollback machen WIR) ──── check_hermes(){ local name="Hermes-Agent" det behind old_head resp job_id state t if is_pinned hermes; then say "$name: GEPINNT ($(pin_info hermes)) — übersprungen." SUMMARY+=("$name: gepinnt, übersprungen.") return fi det="$(details hermes)" || { SUMMARY+=("$name: Check fehlgeschlagen."); return; } behind="$(jq -r '.behind // 0' <<<"$det")" if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then say "$name: aktuell." SUMMARY+=("$name: aktuell.") return fi old_head="$(git -C "$HERMES_DIR" rev-parse HEAD 2>/dev/null)" say "$name: $behind Commits hinterher — Update-Job startet (Backup→update→doctor→Restart→Gehirn-Check)…" resp="$(curl -sf --max-time 30 -X POST "$API/api/maintenance/hermes-update")" || { SUMMARY+=("$name: Job-Start fehlgeschlagen."); return; } job_id="$(jq -r '.job_id // empty' <<<"$resp")" if [ -z "$job_id" ]; then say "$name: kein Job gestartet: $resp" SUMMARY+=("$name: Job-Start abgelehnt ($(jq -r '.error // .detail // "unbekannt"' <<<"$resp" 2>/dev/null)).") return fi t=0 while [ "$t" -lt "$JOB_TIMEOUT" ]; do state="$(curl -sf --max-time 15 "$API/api/jobs" | jq -r --arg id "$job_id" '.jobs[] | select(.id==$id) | .state' 2>/dev/null)" case "$state" in done|failed|canceled) break ;; esac sleep 10; t=$((t + 10)) done if [ "$state" = "done" ]; then SUMMARY+=("$name: $behind Commits eingespielt, Gehirn-Check grün.") notify "Hermes-Agent aktualisiert ($behind Commits). Gehirn-Check (Mem0, Tools, Voice) grün — alles läuft." return fi # Rot/Timeout: ERST Selbstreparatur versuchen (Config-Bruch selbst ziehen, neue Version behalten) — # Autonomie 0g. Nur Config, reversibel, hart gegatet; scheitert es, fällt es sauber in den Rollback. say "$name: Job endete '$state' — versuche Selbstreparatur (Config) vor dem Rollback…" local sr_out sr_rc sugg="" sr_out="$(bash "$SRC_DIR/self-repair.sh" 2>&1)"; sr_rc=$? echo "$sr_out" if [ "$sr_rc" -eq 0 ]; then SUMMARY+=("$name: Config-Bruch nach Update SELBST repariert — neue Version läuft (kein Rollback).") return fi sugg="$(echo "$sr_out" | sed -n 's/^SELFREPAIR_SUGGESTION=//p' | tail -1)" # Rollback: Code auf alten Stand, Config aus dem frischen Backup, Neustart, Gehirn-Check. say "$name: Selbstreparatur griff nicht — ROLLBACK auf $old_head…" if [ -n "$old_head" ]; then git -C "$HERMES_DIR" reset --hard "$old_head" >/dev/null 2>&1 local bak stage bak="$(ls -1t "$BACKUP_DIR"/mc2-state-*.tar.gz 2>/dev/null | head -1)" if [ -n "$bak" ]; then stage="$(mktemp -d)" tar -xzf "$bak" -C "$stage" ./hermes 2>/dev/null || tar -xzf "$bak" -C "$stage" hermes 2>/dev/null [ -f "$stage/hermes/config.yaml" ] && cp -a "$stage/hermes/config.yaml" "$HOME/.hermes/config.yaml" [ -f "$stage/hermes/.env" ] && cp -a "$stage/hermes/.env" "$HOME/.hermes/.env" rm -rf "$stage" fi systemctl --user restart hermes-gateway; sleep 6 if bash "$SRC_DIR/hermes-postcheck.sh" >/dev/null 2>&1; then set_pin hermes "$old_head" "Update ($behind Commits) riss den Gehirn-Check; Rollback grün" SUMMARY+=("$name: Update FEHLGESCHLAGEN → zurückgerollt + GEPINNT auf ${old_head:0:9}.") notify "Hermes-Update fehlgeschlagen (Gehirn-Check rot). Selbstreparatur der Config griff nicht, deshalb automatisch zurückgerollt auf ${old_head:0:9} — läuft wieder. Hermes ist jetzt GEPINNT.${sugg:+ Wahrscheinliche Ursache/Fix (Box-Diagnose): $sugg}" return fi fi SUMMARY+=("$name: KRITISCH — Update UND Rollback fehlgeschlagen!") notify "KRITISCH: Hermes-Update UND Rollback fehlgeschlagen — Gehirn-Check bleibt rot. Bitte melden. (Voll-Restore: deploy/restore.sh mit dem letzten Backup.)" } # ── Lauf ───────────────────────────────────────────────────────────────────── say "Auto-Update-Lauf startet ($(date '+%F %H:%M'))." pins_init if ! curl -sf --max-time 20 "$API/api/health" >/dev/null; then notify "Auto-Update abgebrochen: MC2-API ($API) nicht erreichbar — bitte Box prüfen." exit 1 fi check_layer_root swap update-swap.sh "Router (llama-swap)" swap check_layer_root engine update-engine.sh "Engine (llama.cpp)" engine check_hermes notify "Commander, die Wochenpflege der Box ist durch — kurz für dich: $(printf '• %s\n' "${SUMMARY[@]}")" say "Fertig."