Files
mission-control-v2/deploy/autoupdate.sh
T
HitonabiandClaude Opus 5.5 589c14d5e9 phase2d: gemeinsames Ziel-Modell, KI-Box als erster Adapter, strukturierter Update-Verlauf
- kern/ziele.py: Ziel -> Bausteine mit Stand (neu/aktuell/unbekannt/festgehalten/wird-geprueft),
  Versionen und dem Knopf, der das Update anstoesst; gleiches Modell fuer Box und Homelab
- services/box_updates.py: Update-Zwischenspeicher aus dem Router geholt, ki_box_ziel() als
  Box-Adapter; GET /api/ziele
- Update-Verlauf strukturiert: autoupdate.sh und die Update-Knoepfe schreiben je Baustein eine
  JSON-Zeile (mc2-update-verlauf.jsonl); die Meldungstexte bleiben gleich, aeltere Laeufe kommen
  weiter aus dem Meldeprotokoll. Updates per Knopf erscheinen jetzt auch im Verlauf.
- jobengine: Abschluss-Haken fuer jedes Ende (neben der Nacharbeit fuer den Erfolg)
- Sonntags-Lauf setzt seinen Anlass; Hermes-Job aus dem Lauf schreibt nicht doppelt
- Vertragstest Bash-Schreiber gegen Python-Leser (laeuft auf der Box mit jq)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 18:14:42 +02:00

281 lines
16 KiB
Bash

#!/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; }
# Dringend = geht auch nachts sofort raus (sonst sammelt notify.sh bis zur Morgenmeldung um 07:00).
notify_dringend(){ bash "$SRC_DIR/notify.sh" -d -s "[Box-Update]" "$1" || true; }
SUMMARY=() # eine Zeile je Ebene für die Abschluss-Meldung
# ── Strukturierter Verlauf (seit 24.09.2026) ─────────────────────────────────
# Je Baustein eine JSON-Zeile; die Oberfläche liest den Update-Verlauf daraus statt aus den
# Telegram-Texten (services/update_verlauf.py). Die Meldungen selbst bleiben, wie sie sind.
VERLAUF="${MC_UPDATE_VERLAUF:-/srv/models/mc2-update-verlauf.jsonl}"
LAUF="$(date -Iseconds)"
ANLASS="${MC_UPDATE_ANLASS:-Update von Hand}"
verlauf(){ # $1 baustein (swap|engine|hermes|neustart|lauf) $2 ergebnis $3 text
jq -cn --arg lauf "$LAUF" --arg anlass "$ANLASS" --arg ts "$(date -Iseconds)" \
--arg b "$1" --arg e "$2" --arg t "$3" \
'{lauf: $lauf, anlass: $anlass, ts: $ts, baustein: $b, ergebnis: $e, text: $t}' \
>> "$VERLAUF" 2>/dev/null || true
}
# Ergebnis eines Bausteins: eine Zeile für die Abschlussmeldung und eine für den Verlauf.
ergebnis(){ # $1 baustein $2 anzeigename $3 ergebnis $4 text
SUMMARY+=("$2: $4")
verlauf "$1" "$3" "$4"
}
# ── 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 <cmd> 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) ergebnis "$comp" "$name" eingespielt "$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"
ergebnis "$comp" "$name" zurueckgerollt "$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." ;;
*) ergebnis "$comp" "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!"
notify_dringend "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."
ergebnis "$comp" "$name" festgehalten "gepinnt, übersprungen."
return
fi
det="$(details "$kind")" || { say "$name: Update-Check nicht erreichbar."; ergebnis "$comp" "$name" offen "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')."
ergebnis "$comp" "$name" offen "Versions-Check unklar, nichts getan."
return
fi
if [ "$latest" -le "$installed" ] 2>/dev/null; then
say "$name: aktuell ($installed)."
ergebnis "$comp" "$name" aktuell "aktuell ($installed)."
return
fi
if ! sudo_ok "$script"; then
say "$name: Update $installed → $latest verfügbar, aber sudo-Freischaltung fehlt."
ergebnis "$comp" "$name" offen "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."
ergebnis hermes "$name" festgehalten "gepinnt, übersprungen."
return
fi
det="$(details hermes)" || { ergebnis hermes "$name" offen "Check fehlgeschlagen."; return; }
behind="$(jq -r '.behind // 0' <<<"$det")"
if [ "${behind:-0}" -eq 0 ] 2>/dev/null; then
say "$name: aktuell."
ergebnis hermes "$name" aktuell "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?verlauf=0")" || { ergebnis hermes "$name" offen "Job-Start fehlgeschlagen."; return; }
job_id="$(jq -r '.job_id // empty' <<<"$resp")"
if [ -z "$job_id" ]; then
say "$name: kein Job gestartet: $resp"
ergebnis hermes "$name" offen "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
ergebnis hermes "$name" eingespielt "$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
ergebnis hermes "$name" eingespielt "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"
ergebnis hermes "$name" zurueckgerollt "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
ergebnis hermes "$name" fehler "KRITISCH — Update UND Rollback fehlgeschlagen!"
notify_dringend "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.
# Ausserhalb des Wartungsfensters erzwingen (von Hand): MC_AUTOUPDATE_REBOOT_JETZT=1.
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 '\n' ' ')"
# Sicherung 0 (24.09.2026): neu gestartet wird nur im Wartungsfenster, sonntags 04:00-06:59.
# Anlass: Ein nachgeholter Lauf (Persistent=true beim Einschalten des Timers) startete die Box
# an einem Donnerstagnachmittag neu. Laeufe ausserhalb des Fensters melden den Neustart nur an.
local tag stunde
tag="$(date +%u)"; stunde="$((10#$(date +%H)))"
if [ "${MC_AUTOUPDATE_REBOOT_JETZT:-0}" != "1" ] && { [ "$tag" != "7" ] || [ "$stunde" -lt 4 ] || [ "$stunde" -ge 7 ]; }; then
notify "Neustart steht an (${pakete:-Kernel/libc}), passiert aber erst im Wartungsfenster am Sonntag frueh."
verlauf neustart offen "Neustart steht an (${pakete:-Kernel/libc}), erst im Wartungsfenster am Sonntag früh."
return 0
fi
# 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}"
verlauf neustart offen "Neustart nicht ausgeführt: Linger fehlt."
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."
verlauf neustart offen "Neustart verschoben: es liefen noch $laufend Aufträge."
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}"
verlauf neustart offen "Neustart nicht ausgeführt: Dienste nicht im Autostart ($fehlend)."
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})."
verlauf neustart neustart "Die Box startet jetzt neu (${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}"
verlauf neustart fehler "Neustart fehlgeschlagen: sudo systemctl reboot wurde abgelehnt."
}
}
# ── 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."
verlauf lauf fehler "Abgebrochen: MC2 war nicht erreichbar."
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