Ampel / ampel (push) Successful in 21s
Kernel- und libc-Updates werden erst nach einem Neustart wirksam. Das faellt
niemandem auf: am 27.08.2026 lief die Box seit 7 Wochen auf Kernel 7.0.0-27,
waehrend -28, -29 und -30 ungenutzt installiert dalagen.
Der Neustart haengt am BESTEHENDEN Sonntagslauf (Hermes-Cron 30 4 * * 0 ->
sonntags-update.sh -> autoupdate.sh), nicht an einem neuen Timer - ein Ort fuer
alle Automatik, genau die Lehre vom 20.08. Ausgeloest wird er nur, wenn Ubuntu
ihn selbst anfordert (/var/run/reboot-required).
Drei Sicherungen, alle im Trockenlauf geprueft:
1. linger MUSS aktiv sein. Ohne linger startet systemd die User-Dienste nach
einem Neustart nicht - die Box kaeme ohne Steuerpult, Gateway und Waechter
hoch und waere aus der Ferne nicht mehr erreichbar. Das ist die wichtigste
Zeile der Funktion.
2. Kein laufender Job wird abgewuergt (Zaehlung ueber /api/jobs).
3. mission-control-2, mc2-gateway und mc2-steward muessen enabled sein.
Greift eine Sicherung, meldet die Funktion den Grund per Telegram und Stimme
und startet NICHT neu.
Der Aufruf steht bewusst NACH der Abschlussmeldung - davor haette der Neustart
den Wochenbericht verschluckt. Abschaltbar mit MC_AUTOUPDATE_REBOOT=0.
Im Trockenlauf gefunden und mitbehoben: die Funktion las $USER, das in systemd-
und Cron-Umgebungen nicht gesetzt ist. Mit dem set -u des Skripts haette das den
GANZEN Update-Lauf abgebrochen, nicht nur den Neustart. Jetzt id -un.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
244 lines
13 KiB
Bash
244 lines
13 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; }
|
|
|
|
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 <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) 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
|