#!/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 (Patches, 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.)" } # ── Neustart, wenn das OS einen verlangt ───────────────────────────────────── # Kernel- und libc-Updates werden erst nach einem Neustart wirksam; bis dahin # laeuft die Box weiter auf dem alten Kernel. Das faellt niemandem auf — am # 27.08.2026 lag die Box 7 Wochen mit drei ungenutzten Kerneln da. # # Der Neustart haengt bewusst am WOECHENTLICHEN Lauf (So 04:30) und nicht an # einem eigenen Timer: ein Ort fuer alle Automatik (Lehre vom 20.08.). # Er passiert NUR, wenn Ubuntu ihn selbst anfordert (/var/run/reboot-required). # # WICHTIG: Die Linger-Pruefung ist die entscheidende Zeile hier. Ohne Linger=yes # startet systemd die User-Dienste nach einem Neustart NICHT — die Box kaeme ohne # Steuerpult, Gateway und Waechter hoch, und niemand koennte sie aus der Ferne # wieder anschalten. Im Zweifel lieber nicht neustarten. # # Abschalten: MC_AUTOUPDATE_REBOOT=0 in der Umgebung des Cron-Jobs. reboot_wenn_noetig(){ [ "${MC_AUTOUPDATE_REBOOT:-1}" = "1" ] || { say "Neustart per Env abgeschaltet."; return 0; } [ -f /var/run/reboot-required ] || { say "Kein Neustart noetig."; return 0; } local pakete; pakete="$(sort -u /var/run/reboot-required.pkgs 2>/dev/null | tr " " " ")" # Sicherung 1: kommen die Dienste ohne Login von selbst wieder hoch? # $USER ist in systemd-/Cron-Umgebungen NICHT gesetzt — mit set -u waere das ein # sofortiger Abbruch des ganzen Update-Laufs. id -un funktioniert immer. local nutzer; nutzer="$(id -un)" if [ "$(loginctl show-user "$nutzer" -p Linger --value 2>/dev/null)" != "yes" ]; then notify "Neustart NICHT ausgefuehrt: die Box laeuft ohne linger — nach einem Neustart wuerden Steuerpult, Gateway und Waechter nicht von selbst starten. Erst 'loginctl enable-linger $nutzer' setzen, dann von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}" return 0 fi # Sicherung 2: kein halbfertiges Update mitten im Neustart abwuergen. local laufend laufend="$(curl -sf --max-time 20 "$API/api/jobs" | jq -r '[.jobs[]? | select(.state=="running")] | length' 2>/dev/null || echo 0)" if [ "${laufend:-0}" -gt 0 ]; then notify "Neustart verschoben: es laufen noch $laufend Job(s). Ausstehend: ${pakete:-Kernel/libc}. Naechster Versuch beim Lauf in einer Woche." return 0 fi # Sicherung 3: die Dienste muessen fuer den Autostart vorgemerkt sein. local fehlend="" for dienst in mission-control-2 mc2-gateway mc2-steward; do systemctl --user is-enabled "$dienst" >/dev/null 2>&1 || fehlend="$fehlend $dienst" done if [ -n "$fehlend" ]; then notify "Neustart NICHT ausgefuehrt: diese Dienste sind nicht fuer den Autostart eingetragen —$fehlend. Erst 'systemctl --user enable' nachholen. Ausstehend: ${pakete:-Kernel/libc}" return 0 fi notify "Die Box startet jetzt neu — das OS verlangt es nach dem Update (${pakete:-Kernel/libc}). Sie ist ein paar Minuten weg und meldet sich, sobald alles wieder laeuft. Die Modelle muessen danach neu geladen werden, die erste Anfrage dauert also laenger." say "Neustart wird ausgeloest (ausstehend: ${pakete:-Kernel/libc})." sleep 20 # der Meldung Zeit lassen, rauszugehen sudo -n systemctl reboot || { notify "Neustart FEHLGESCHLAGEN: 'sudo systemctl reboot' wurde abgelehnt. Bitte von Hand neustarten. Ausstehend: ${pakete:-Kernel/libc}" } } # ── 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." # Ganz zuletzt: der Neustart wuerde die Abschlussmeldung oben sonst verschlucken. # Die Funktion meldet in jedem Fall selbst, ob sie neustartet oder warum nicht. reboot_wenn_noetig