From 2e0ad70a854e9d1c12dbb3763d8094018c843d05 Mon Sep 17 00:00:00 2001 From: Hitonabi Date: Thu, 24 Sep 2026 20:41:33 +0200 Subject: [PATCH] sicherung: PBS-Sicherung zurueck ins Repo (ohne mem0), monatliche Probe-Wiederherstellung - PBS: deploy/pbs-backup.sh, pbs-backup.service und .timer (User-Units, 03:50) aus der Box uebernommen. Die Sicherung lief vom 09.07. bis 20.08. taeglich gruen und brach am 21.08. ab, weil ihr erster Quellordner /srv/models/mem0 (altes Gedaechtnis) fehlte; danach im KISS-Umbau abgeschaltet. Das neue Skript sichert ~/.hermes und /etc/llama-swap (Pflicht) und ~/wissens-vault (optional), prueft Zugangsdatei und Client, und liest den Zugang erst direkt vor dem Aufruf ein. deploy.sh spielt die Units aus, schaltet sie aber nicht ein (Einschalten von Hand, docs/BETRIEB.md). Der Waechter zeigt einen gescheiterten Lauf gelb. - Probe-Wiederherstellung: services/probe_wiederherstellung.py packt die juengste Sicherung in /var/tmp/mc2-probe-* aus (ohne Geheimnisse und Verknuepfungen), prueft Alter (<= 48 h), Lesbarkeit und Pflichtinhalte (Gedaechtnis und SOUL.md auch gegen den lebenden Stand, Skills, Cron-Skripte und -Jobs, Hermes-Config, .env, llama-swap-Config, Box-Wart-Zustand, Units) und loescht den Ordner wieder. Ergebnis in /srv/models/mc2-probe-wiederherstellung.json, bei Rot Meldung "[Sicherung]" ueber notify.sh in normaler Dringlichkeit. Timer mc2-probe-wiederherstellung.timer: erster Montag im Monat 05:15 (nie sonntags, lange nach 03:30), Persistent=true; deploy.sh aktiviert ihn. - Waechter: gelb, wenn die letzte Probe rot war, aelter als 40 Tage ist oder (mit Timer) noch nie lief; Knoepfe "Jetzt pruefen" und "Protokoll" (Allowlist in services.maintenance). - Tests: Probe gruen/rot je Pflichtinhalt, Abgleich, kaputte und alte Sicherung, Meldung, Waechter-Hinweis, Unit-Listen in deploy.sh, Takt, LF, pbs-backup.sh mit Client-Attrappe. Trockenlauf gegen die echte Sicherung der Box (nur in /tmp): alle Pflichtinhalte gruen, 0,4 s. Co-Authored-By: Claude Opus 5.5 --- backend/services/maintenance.py | 3 +- backend/services/probe_wiederherstellung.py | 415 ++++++++++++++++++ backend/services/waechter.py | 42 +- backend/tests/test_deploy_units.py | 118 +++++ backend/tests/test_probe_wiederherstellung.py | 252 +++++++++++ deploy/deploy.sh | 12 +- deploy/mc2-probe-wiederherstellung.service | 23 + deploy/mc2-probe-wiederherstellung.timer | 13 + deploy/pbs-backup.service | 17 + deploy/pbs-backup.sh | 46 ++ deploy/pbs-backup.timer | 12 + 11 files changed, 947 insertions(+), 6 deletions(-) create mode 100644 backend/services/probe_wiederherstellung.py create mode 100644 backend/tests/test_deploy_units.py create mode 100644 backend/tests/test_probe_wiederherstellung.py create mode 100644 deploy/mc2-probe-wiederherstellung.service create mode 100644 deploy/mc2-probe-wiederherstellung.timer create mode 100644 deploy/pbs-backup.service create mode 100644 deploy/pbs-backup.sh create mode 100644 deploy/pbs-backup.timer diff --git a/backend/services/maintenance.py b/backend/services/maintenance.py index 2ec781d..3b49526 100644 --- a/backend/services/maintenance.py +++ b/backend/services/maintenance.py @@ -25,7 +25,8 @@ SYSTEM_SERVICES = {"llama-swap"} # erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py). USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console", "hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", - "projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate"} + "projekte-sync", "mc2-backup", "mc2-radar", "mc2-morgenmeldung", "mc2-autoupdate", + "pbs-backup", "mc2-probe-wiederherstellung"} # Warum eine Update-Prüfung nicht klappte (None = geprüft). Bis 24.09.2026 verschluckten die # Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann diff --git a/backend/services/probe_wiederherstellung.py b/backend/services/probe_wiederherstellung.py new file mode 100644 index 0000000..e7b0ed8 --- /dev/null +++ b/backend/services/probe_wiederherstellung.py @@ -0,0 +1,415 @@ +""" +Monatliche Probe-Wiederherstellung (seit 24.09.2026): Eine Sicherung ist erst eine, wenn sie sich zurückspielen lässt. + +Packt die jüngste Sicherung (deploy/backup.sh → /srv/models/mc2-backups) probeweise in einen Tmp-Ordner aus, prüft die +Pflichtinhalte und räumt den Ordner wieder weg. Lebende Dateien fasst sie nie an; geschrieben werden nur der Tmp-Ordner +und die Zustandsdatei ZUSTAND_PATH. Die liest der Wächter (Rolle box): gelb, wenn die letzte Probe rot war oder älter +als WARN_TAGE ist. Bei Rot geht zusätzlich eine Meldung über deploy/notify.sh raus, in normaler Dringlichkeit — nachts +sammelt notify.sh sie für die Morgenmeldung um 07:00. + +Pflichtinhalte (was restore.sh zurückspielt oder was ohne Sicherung verloren wäre): + • Lucys Gedächtnis (hermes/memories) — dazu ein Abgleich mit dem lebenden Gedächtnis: Was schon vor der Sicherung so + dastand, muss darin unverändert stehen. Ebenso SOUL.md. + • Skills (SKILL.md) und Cron-Skripte (hermes/scripts), die Hermes-Cron-Jobs (cron/jobs.json) + • Hermes-Config (config.yaml lesbar; .env vorhanden) und die llama-swap-Config (Modelle eingetragen) + • Box-Wart-Zustand (box-wart/mc2-*.json lesbar) und die systemd-Units (samt llama-swap-Drop-ins) +Geheimnisse (.env, Token-Dateien) und Verknüpfungen werden nicht ausgepackt, nur ihr Vorhandensein geprüft. + +Aufruf: mc2-probe-wiederherstellung.service (Timer: erster Montag im Monat, 05:15) oder von Hand auf der Box: + cd ~/mission-control-v2/backend && .venv/bin/python -m services.probe_wiederherstellung +Exit 0 = grün, 1 = rot. +""" + +import json +import logging +import os +import re +import shutil +import subprocess +import sys +import tarfile +import tempfile +import time +import zlib +from datetime import datetime +from pathlib import Path + +from config import HERMES_HOME +from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ + +from services import backup as sicherung + +log = logging.getLogger(__name__) + +EINHEIT = "mc2-probe-wiederherstellung" +ZUSTAND_PATH = Path(os.environ.get("MC_PROBE_ZUSTAND", + str(einstellungen().daten_dir / "mc2-probe-wiederherstellung.json"))) +SICHERUNGEN_DIR = sicherung.BACKUP_DIR +# Die Sicherung läuft täglich um 03:30. Ist die jüngste älter, steht sie — das ist ein roter Befund. +MAX_ALTER_H = float(os.environ.get("MC_PROBE_MAX_ALTER_H", "48")) +# Der Wächter zeigt gelb, wenn die letzte Probe älter ist (monatlicher Takt plus Spielraum). +WARN_TAGE = 40 +# Lebender Stand, gegen den verglichen wird (Tests setzen eigene Pfade). +HERMES_LIVE = HERMES_HOME +DATEN_LIVE = einstellungen().daten_dir +LLAMA_SWAP_DROPINS = Path("/etc/systemd/system/llama-swap.service.d") +NOTIFY_SH = Path(__file__).resolve().parents[2] / "deploy" / "notify.sh" + +# Nicht auspacken: Geheimnisse. Geprüft wird nur, dass sie in der Sicherung stehen. +_GEHEIM = {".env", "desktop-gateway-token", "session-token.conf"} +_PRAEFIX = "mc2-probe-" +_NAME_ZEIT = re.compile(r"mc2-state-(\d{8}-\d{6})\.tar\.gz$") +# Eine Datei, die bis so kurz vor dem Start der Sicherung geändert wurde, zählt als „danach geändert“. +_SPIELRAUM_S = 5.0 + + +# --- Hilfen ------------------------------------------------------------------------------------ + +def _tmp_basis() -> str | None: + """Wohin ausgepackt wird: MC_PROBE_TMP, sonst /var/tmp (Platte; /tmp ist auf der Box ein RAM-Laufwerk).""" + if (basis := os.environ.get("MC_PROBE_TMP", "").strip()): + return basis + return "/var/tmp" if os.path.isdir("/var/tmp") else None + + +def _alte_tmp_ordner_entfernen() -> None: + """Reste einer abgebrochenen Probe (nur eigene Ordner mit unserem Präfix, älter als ein Tag).""" + basis = Path(_tmp_basis() or tempfile.gettempdir()) + try: + kandidaten = list(basis.glob(f"{_PRAEFIX}*")) + except OSError: + return + for ordner in kandidaten: + try: + if ordner.is_dir() and not ordner.is_symlink() and time.time() - ordner.stat().st_mtime > 86400: + _entfernen(ordner) + except OSError: + pass + + +def _entfernen(ordner: Path) -> bool: + """Tmp-Ordner löschen, auch wenn die Sicherung schreibgeschützte Ordner mitbrachte. True = er ist weg.""" + def schreibbar_machen(funktion, pfad, _fehler) -> None: + try: + os.chmod(os.path.dirname(pfad), 0o700) + os.chmod(pfad, 0o700) + funktion(pfad) + except OSError: + pass + + shutil.rmtree(ordner, onexc=schreibbar_machen) + return not ordner.exists() + + +def _rel(name: str) -> str: + """Name im Archiv ohne führendes „./“ („./hermes/SOUL.md“ → „hermes/SOUL.md“).""" + while name.startswith("./"): + name = name[2:] + return name.rstrip("/") + + +def _dateien(ordner: Path) -> list[Path]: + if not ordner.is_dir(): + return [] + return sorted(p for p in ordner.rglob("*") if p.is_file() and "__pycache__" not in p.parts) + + +def _geaendert(pfad: Path) -> float: + """Letzte Änderung einer lebenden Datei. ctime zählt mit: Wer eine Datei samt alter mtime auspackt (Skill-Paket, + Restore), erzeugt sie trotzdem neu — sie kann dann gar nicht in einer älteren Sicherung stehen.""" + st = pfad.stat() + return max(st.st_mtime, st.st_ctime) + + +def sicherungs_zeit(tarball: Path) -> float: + """Wann die Sicherung begann: Zeitstempel im Namen (Ortszeit der Box), sonst die Dateizeit.""" + if (m := _NAME_ZEIT.search(tarball.name)): + try: + return datetime.strptime(m.group(1), "%Y%m%d-%H%M%S").replace(tzinfo=LOCAL_TZ).timestamp() + except ValueError: + pass + return tarball.stat().st_mtime + + +def juengste_sicherung(ordner: Path | None = None) -> Path | None: + ordner = SICHERUNGEN_DIR if ordner is None else ordner + try: + liste = sorted(ordner.glob("mc2-state-*.tar.gz"), key=lambda p: p.name, reverse=True) + except OSError: + return None + return liste[0] if liste else None + + +def _yaml(pfad: Path): + from ruamel.yaml import YAML + return YAML(typ="safe").load(pfad.read_text(encoding="utf-8")) + + +# --- Die Prüfung --------------------------------------------------------------------------------- + +def _sammelzeile(bereich: str, namen: list[str], einzeln: str, mehrere: str) -> str: + """Eine Fehlerzeile je Art und Bereich, auch wenn hunderte Dateien betroffen sind.""" + if len(namen) == 1: + return f"{bereich}: {namen[0]} {einzeln}." + beispiele = ", ".join(namen[:3]) + (" …" if len(namen) > 3 else "") + return f"{bereich}: {len(namen)} Dateien {mehrere} ({beispiele})." + + +def _pruefe_abgleich(bereich: str, kopie: Path, live: Path | None, beginn: float, fehler: list[str], + inhalt_gleich: bool = False) -> None: + """Jede lebende Datei, die schon vor Beginn der Sicherung so dastand, muss in der Sicherung stehen + (bei inhalt_gleich auch mit demselben Inhalt). Leere Dateien und Sperrdateien (*.lock) zählen nicht.""" + if live is None or not live.exists(): + return + fehlend: list[str] = [] + anders: list[str] = [] + unlesbar: list[str] = [] + for datei in [live] if live.is_file() else _dateien(live): + name = datei.name if live.is_file() else datei.relative_to(live).as_posix() + try: + # Verknüpfungen packt die Probe nicht aus (backup.sh sichert sie als Verknüpfung) — nicht vergleichen. + if (datei.is_symlink() or datei.name.endswith(".lock") or datei.stat().st_size == 0 + or _geaendert(datei) >= beginn - _SPIELRAUM_S): + continue + gegenstueck = kopie if live.is_file() else kopie / datei.relative_to(live) + if not gegenstueck.is_file(): + fehlend.append(name) + elif inhalt_gleich and gegenstueck.read_bytes() != datei.read_bytes(): + anders.append(name) + except OSError: + unlesbar.append(name) + if fehlend: + fehler.append(_sammelzeile(bereich, fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung")) + if anders: + fehler.append(_sammelzeile(bereich, anders, "steht in der Sicherung anders als auf der Box", + "stehen in der Sicherung anders als auf der Box")) + if unlesbar: + fehler.append(_sammelzeile(bereich, unlesbar, "ließ sich nicht vergleichen", "ließen sich nicht vergleichen")) + + +def _pruefe_inhalt(wurzel: Path, mitglieder: dict[str, tarfile.TarInfo], beginn: float, + geprueft: list[str], fehler: list[str]) -> None: + """Die Pflichtinhalte im ausgepackten Ordner (Geheimnisse nur über die Liste der Mitglieder).""" + hermes = wurzel / "hermes" + + # Lucys Gedächtnis: das Wertvollste, deshalb auch Inhalt gegen den lebenden Stand. + gedaechtnis = [p for p in _dateien(hermes / "memories") if p.stat().st_size > 0 and not p.name.endswith(".lock")] + if gedaechtnis: + geprueft.append(f"Gedächtnis: {len(gedaechtnis)} Dateien ({', '.join(p.name for p in gedaechtnis[:4])})") + else: + fehler.append("Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.") + _pruefe_abgleich("Gedächtnis", hermes / "memories", HERMES_LIVE / "memories", beginn, fehler, inhalt_gleich=True) + + seele = hermes / "SOUL.md" + if seele.is_file() and seele.stat().st_size > 0: + geprueft.append("SOUL.md") + else: + fehler.append("SOUL.md fehlt in der Sicherung oder ist leer.") + _pruefe_abgleich("SOUL.md", seele, HERMES_LIVE / "SOUL.md", beginn, fehler, inhalt_gleich=True) + + skills = [p for p in _dateien(hermes / "skills") if p.name == "SKILL.md"] + if skills: + geprueft.append(f"Skills: {len(skills)}") + else: + fehler.append("Keine Skills (hermes/skills/…/SKILL.md) in der Sicherung.") + _pruefe_abgleich("Skills", hermes / "skills", HERMES_LIVE / "skills", beginn, fehler) + + skripte = _dateien(hermes / "scripts") + if skripte: + geprueft.append(f"Cron-Skripte: {len(skripte)}") + else: + fehler.append("Keine Cron-Skripte (hermes/scripts) in der Sicherung.") + _pruefe_abgleich("Cron-Skripte", hermes / "scripts", HERMES_LIVE / "scripts", beginn, fehler) + + try: + jobs = json.loads((hermes / "cron" / "jobs.json").read_text(encoding="utf-8")) + liste = jobs.get("jobs", jobs) if isinstance(jobs, dict) else jobs + geprueft.append(f"Hermes-Cron-Jobs: {len(liste)}") + except (OSError, ValueError, TypeError) as exc: + fehler.append(f"Die Hermes-Cron-Jobs (cron/jobs.json) fehlen oder sind unlesbar ({exc.__class__.__name__}).") + + try: + cfg = _yaml(hermes / "config.yaml") + if not isinstance(cfg, dict) or not cfg: + raise ValueError("leer") + geprueft.append("Hermes-Config lesbar") + except Exception as exc: + fehler.append(f"Die Hermes-Config (config.yaml) fehlt oder ist unlesbar ({exc.__class__.__name__}).") + env = mitglieder.get("hermes/.env") + if env is not None and env.isfile() and env.size > 0: + geprueft.append("Zugangsdaten (.env) enthalten (nicht ausgepackt)") + else: + fehler.append("Die Zugangsdaten (hermes/.env) fehlen in der Sicherung.") + + try: + modelle = (_yaml(wurzel / "llama-swap" / "config.yaml") or {}).get("models") + if not isinstance(modelle, dict) or not modelle: + raise ValueError("ohne Modelle") + geprueft.append(f"llama-swap-Config: {len(modelle)} Modelle") + except Exception as exc: + fehler.append(f"Die llama-swap-Config fehlt oder ist unbrauchbar ({exc.__class__.__name__}: {exc})"[:200]) + + zustand = sorted((wurzel / "box-wart").glob("mc2-*.json")) if (wurzel / "box-wart").is_dir() else [] + kaputt = [] + for datei in zustand: + try: + json.loads(datei.read_text(encoding="utf-8")) + except (OSError, ValueError): + kaputt.append(datei.name) + if not zustand: + fehler.append("Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt in der Sicherung.") + elif kaputt: + fehler.append(f"Box-Wart-Zustand unlesbar: {', '.join(kaputt)}") + else: + geprueft.append(f"Box-Wart-Zustand: {len(zustand)} Dateien") + fehlend = [] + for datei in sorted(DATEN_LIVE.glob("mc2-*.json")) if DATEN_LIVE.is_dir() else []: + try: + vorher = _geaendert(datei) < beginn - _SPIELRAUM_S and datei.stat().st_size > 0 + except OSError: + continue + if vorher and not (wurzel / "box-wart" / datei.name).is_file(): + fehlend.append(datei.name) + if fehlend: + fehler.append(_sammelzeile("Box-Wart-Zustand", fehlend, "fehlt in der Sicherung", "fehlen in der Sicherung")) + + units = sorted(p.name for p in (wurzel / "systemd" / "user").glob("*.service")) \ + if (wurzel / "systemd" / "user").is_dir() else [] + if "mission-control-2.service" in units: + geprueft.append(f"systemd-Units: {len(units)} Dienste") + else: + fehler.append("Die systemd-Units (systemd/user, mindestens mission-control-2.service) fehlen in der Sicherung.") + if LLAMA_SWAP_DROPINS.is_dir() and not (wurzel / "systemd" / LLAMA_SWAP_DROPINS.name).is_dir(): + fehler.append(f"Die llama-swap-Drop-ins ({LLAMA_SWAP_DROPINS.name}) fehlen in der Sicherung.") + + +def pruefe_sicherung(tarball: Path) -> tuple[list[str], list[str]]: + """Eine Sicherung probeweise auspacken und prüfen. Rückgabe (Geprüftes, Fehler); der Tmp-Ordner ist danach weg.""" + geprueft: list[str] = [] + fehler: list[str] = [] + beginn = sicherungs_zeit(tarball) + try: + with tarfile.open(tarball, "r:gz") as tar: + alle = tar.getmembers() # liest das ganze Archiv: ein abgeschnittenes fällt hier auf + mitglieder = {_rel(m.name): m for m in alle} + auspacken = [m for m in alle if _rel(m.name) and (m.isfile() or m.isdir()) + and Path(_rel(m.name)).name not in _GEHEIM] + tmp = Path(tempfile.mkdtemp(prefix=_PRAEFIX, dir=_tmp_basis())) + try: + tar.extractall(tmp, members=auspacken, filter="data") + geprueft.append(f"ausgepackt: {sum(1 for m in auspacken if m.isfile())} Dateien") + _pruefe_inhalt(tmp, mitglieder, beginn, geprueft, fehler) + finally: + if not _entfernen(tmp): + fehler.append(f"Der Tmp-Ordner {tmp} ließ sich nicht entfernen.") + except (tarfile.TarError, EOFError, zlib.error, OSError) as exc: + fehler.append(f"Die Sicherung lässt sich nicht auspacken: {exc.__class__.__name__}: {exc}"[:240]) + return geprueft, fehler + + +def probe() -> dict: + """Die ganze Probe: jüngste Sicherung finden, Alter prüfen, auspacken und prüfen, Ergebnis ablegen, bei Rot melden.""" + start = time.time() + geprueft: list[str] = [] + fehler: list[str] = [] + tarball = None + try: + _alte_tmp_ordner_entfernen() + tarball = juengste_sicherung() + if tarball is None: + fehler.append(f"Keine Sicherung gefunden ({SICHERUNGEN_DIR}).") + else: + alter_h = (time.time() - tarball.stat().st_mtime) / 3600 + if alter_h > MAX_ALTER_H: + fehler.append(f"Die jüngste Sicherung ist {alter_h / 24:.0f} Tage alt: Die tägliche Sicherung " + "(mc2-backup.timer) läuft nicht.") + else: + geprueft.append(f"jüngste Sicherung {alter_h:.0f} Stunden alt") + g, f = pruefe_sicherung(tarball) + geprueft += g + fehler += f + except Exception as exc: # die Probe selbst darf nie still scheitern + log.exception("Probe-Wiederherstellung abgestürzt") + fehler.append(f"Die Probe ist abgestürzt: {exc.__class__.__name__}: {exc}"[:240]) + stand = { + "version": 1, + "zeit": start, + "datum": datetime.fromtimestamp(start, LOCAL_TZ).isoformat(timespec="seconds"), + "ergebnis": "rot" if fehler else "gruen", + "sicherung": tarball.name if tarball else None, + "dauer_s": round(time.time() - start, 1), + "geprueft": geprueft, + "fehler": fehler, + } + speichere(stand) + if fehler: + melden(stand) + return stand + + +# --- Ablage und Meldung ------------------------------------------------------------------------------ + +def speichere(stand: dict) -> None: + try: + ZUSTAND_PATH.parent.mkdir(parents=True, exist_ok=True) + tmp = ZUSTAND_PATH.with_suffix(".json.tmp") + tmp.write_text(json.dumps(stand, ensure_ascii=False, indent=1), encoding="utf-8") + os.replace(tmp, ZUSTAND_PATH) + except OSError: + log.warning("Probe-Wiederherstellung: %s nicht schreibbar", ZUSTAND_PATH, exc_info=True) + + +def lese_stand() -> dict | None: + """Letztes Ergebnis für den Wächter. None = noch nie gelaufen (oder Datei unlesbar).""" + try: + daten = json.loads(ZUSTAND_PATH.read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + if not isinstance(daten, dict) or not isinstance(daten.get("zeit"), (int, float)): + return None + return daten + + +def meldetext(stand: dict) -> str: + fehler = stand.get("fehler") or [] + text = (f"Die Probe-Wiederherstellung ist rot ({stand.get('sicherung') or 'keine Sicherung'}): " + + " ".join(fehler[:3])) + if len(fehler) > 3: + text += f" (und {len(fehler) - 3} weitere)" + return text + " Der Wächter zeigt es auch im Cockpit." + + +def _bash() -> str | None: + """bash für notify.sh; auf Windows (Entwicklung) gibt es keinen Meldeweg.""" + return shutil.which("bash") if os.name == "posix" else None + + +def melden(stand: dict) -> None: + """Rot → Meldung über notify.sh in normaler Dringlichkeit: kein -d, kein „Alarm“ im Betreff — nachts sammelt + notify.sh sie für die Morgenmeldung um 07:00.""" + if not (bash := _bash()): + log.warning("Probe-Wiederherstellung rot, aber hier gibt es keinen Meldeweg: %s", meldetext(stand)) + return + try: + subprocess.run([bash, str(NOTIFY_SH), "-s", "[Sicherung]", meldetext(stand)], timeout=120, check=False, + stdin=subprocess.DEVNULL, capture_output=True) + except (OSError, subprocess.SubprocessError): + log.warning("Probe-Wiederherstellung: Meldung ging nicht raus", exc_info=True) + + +def main() -> int: + logging.basicConfig(level=os.environ.get("MC_LOG_LEVEL", "INFO").upper(), + format="%(asctime)s %(levelname)-7s %(name)s: %(message)s") + stand = probe() + print(f"Probe-Wiederherstellung von {stand['sicherung'] or '–'}: " + f"{'grün' if stand['ergebnis'] == 'gruen' else 'ROT'} ({stand['dauer_s']} s)") + for zeile in stand["geprueft"]: + print(f" ok {zeile}") + for zeile in stand["fehler"]: + print(f" ! {zeile}") + return 0 if stand["ergebnis"] == "gruen" else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/backend/services/waechter.py b/backend/services/waechter.py index 80c441b..0a8fd8c 100644 --- a/backend/services/waechter.py +++ b/backend/services/waechter.py @@ -11,6 +11,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin: • Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP • Platte Füllstand des Modell-Laufwerks • Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält + • Probe monatliche Probe-Wiederherstellung der Sicherung (rot oder zu alt = gelb) Jeder Befund wird zum Hinweis mit Stufe (rot = jetzt, gelb = bei Gelegenheit), Beginn und Knöpfen. Einfaches behebt er selbst (User-Entscheid 23.09.): Ein ABGESTÜRZTER Dienst @@ -45,8 +46,9 @@ import psutil from config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL from kern import partner from kern.einstellungen import einstellungen +from kern.zeit import LOCAL_TZ -from services import announce, llamaswap, maintenance, update_verlauf +from services import announce, llamaswap, maintenance, probe_wiederherstellung, update_verlauf log = logging.getLogger(__name__) ROLLE = einstellungen().rolle @@ -98,6 +100,9 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = { "mc2-morgenmeldung": ("Morgenmeldung", True), # Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu). "mc2-autoupdate": ("Updates am Sonntag", True), + # Seit 24.09.2026 im Repo (vom 21.08. an aus, weil sie rot lief und niemand es sah). Gelb: Die tägliche + # Sicherung (mc2-backup) bleibt die erste Schicht, der PBS ist die Kopie auf dem QNAP. Ausgeschaltet kein Befund. + "pbs-backup": ("Sicherung auf den PBS", False), } HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json" @@ -465,6 +470,39 @@ def pruefe_platte() -> list[Befund]: quelle="platte", sofort=True)] +def pruefe_probe_wiederherstellung() -> list[Befund]: + """Monatliche Probe-Wiederherstellung (services/probe_wiederherstellung.py, seit 24.09.2026): gelb, wenn die + letzte Probe rot war oder älter als WARN_TAGE ist. Gab es noch keine, erst, wenn ihr Timer eingerichtet ist.""" + einheit = probe_wiederherstellung.EINHEIT + aktionen = [_aktion("neustart", "Jetzt prüfen", dienst=einheit), _aktion("protokoll", "Protokoll", dienst=einheit)] + stand = probe_wiederherstellung.lese_stand() + if stand is None: + z = _systemctl_show(f"{einheit}.timer", False) + if not z or z.get("LoadState") in ("not-found", ""): + return [] + return [Befund(id="probe:wiederherstellung", stufe="gelb", + titel="Die Sicherung wurde noch nie probeweise ausgepackt", + text=("Die Probe läuft am ersten Montag im Monat um 05:15. „Jetzt prüfen“ startet sie sofort; " + "sie braucht Sekunden und fasst nichts Lebendes an."), + quelle=einheit, aktionen=aktionen, sofort=True)] + datum = datetime.fromtimestamp(float(stand["zeit"]), LOCAL_TZ).strftime("%d.%m.%Y") + if stand.get("ergebnis") != "gruen": + fehler = [str(f) for f in stand.get("fehler") or []] or ["ohne Begründung"] + weitere = f" (und {len(fehler) - 1} weitere)" if len(fehler) > 1 else "" + return [Befund(id="probe:wiederherstellung", stufe="gelb", + titel="Die letzte Probe-Wiederherstellung war rot", + text=f"Probe vom {datum} ({stand.get('sicherung') or 'keine Sicherung'}): {fehler[0]}{weitere}"[:400], + quelle=einheit, aktionen=aktionen, sofort=True)] + tage = (time.time() - float(stand["zeit"])) / 86400 + if tage > probe_wiederherstellung.WARN_TAGE: + return [Befund(id="probe:wiederherstellung", stufe="gelb", + titel=f"Die Sicherung wurde seit {int(tage)} Tagen nicht mehr probeweise ausgepackt", + text=(f"Die letzte Probe am {datum} war grün. Sie soll am ersten Montag im Monat laufen — " + f"ist {einheit}.timer eingeschaltet?"), + quelle=einheit, aktionen=aktionen, sofort=True)] + return [] + + def pruefe_festgehalten() -> list[Befund]: """Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt die Box Motor und Hermes fest, ohne dass es jemand sah — elf Tage ohne Updates.""" @@ -520,7 +558,7 @@ def pruefe_gaeste() -> list[Befund]: # den Ausführer auf dem Proxmox-Host und seine Gäste. PRUEFUNGEN = { "box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, - pruefe_festgehalten, pruefe_partner), + pruefe_festgehalten, pruefe_partner, pruefe_probe_wiederherstellung), "homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste), }[ROLLE] BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), diff --git a/backend/tests/test_deploy_units.py b/backend/tests/test_deploy_units.py new file mode 100644 index 0000000..fd22da5 --- /dev/null +++ b/backend/tests/test_deploy_units.py @@ -0,0 +1,118 @@ +"""Units, die deploy.sh ausspielt (24.09.2026): Probe-Wiederherstellung und PBS-Sicherung. + +Prüft die Listen in deploy.sh gegen die Dateien in deploy/, den Takt der Probe (nie sonntags) und das PBS-Skript mit +einer Attrappe statt proxmox-backup-client — ohne Box, ohne PBS.""" + +import os +import re +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest + +DEPLOY = Path(__file__).resolve().parents[2] / "deploy" +DEPLOY_SH = (DEPLOY / "deploy.sh").read_text(encoding="utf-8") +BASH = shutil.which("bash") + + +def _liste(name: str) -> list[str]: + m = re.search(rf'^{name}="([^"]*)"', DEPLOY_SH, re.MULTILINE) + assert m, f"{name} fehlt in deploy.sh" + return m.group(1).split() + + +def test_jede_unit_aus_deploy_sh_liegt_im_repo(): + units, aktiv = _liste("UNITS"), _liste("AKTIV") + assert [u for u in units if not (DEPLOY / u).is_file()] == [] + assert set(aktiv) <= set(units) + + +def test_probe_timer_laeuft_mit_pbs_nur_von_hand(): + units, aktiv = _liste("UNITS"), _liste("AKTIV") + assert {"mc2-probe-wiederherstellung.service", "mc2-probe-wiederherstellung.timer", + "pbs-backup.service", "pbs-backup.timer"} <= set(units) + assert "mc2-probe-wiederherstellung.timer" in aktiv + assert "pbs-backup.timer" not in aktiv # Einschalten ist ein Schritt des Leads (docs/BETRIEB.md) + start = re.search(r"systemctl --user start (mc2-radar\.timer(?:[^\n]*\\\n)*[^\n]*)", DEPLOY_SH) + assert start and "mc2-probe-wiederherstellung.timer" in start.group(1) + + +def _timer(name: str) -> dict[str, str]: + werte = {} + for zeile in (DEPLOY / name).read_text(encoding="utf-8").splitlines(): + if "=" in zeile and not zeile.startswith("#"): + k, v = zeile.split("=", 1) + werte[k.strip()] = v.strip() + return werte + + +def test_probe_monatlich_nie_sonntags_und_nicht_um_die_sicherung(): + t = _timer("mc2-probe-wiederherstellung.timer") + assert t["OnCalendar"] == "Mon *-*-01..07 05:15:00" # erster Montag im Monat + assert t["Persistent"] == "true" + assert _timer("mc2-backup.timer")["OnCalendar"].endswith("03:30:00") + assert _timer("mc2-autoupdate.timer")["OnCalendar"].startswith("Sun ") + + +def test_units_und_skripte_haben_lf(): + dateien = [*DEPLOY.glob("*.service"), *DEPLOY.glob("*.timer"), *DEPLOY.glob("*.sh")] + assert [p.name for p in dateien if b"\r" in p.read_bytes()] == [] + + +# --- pbs-backup.sh mit Attrappe -------------------------------------------------------------------------- + +# Unter Windows kann „bash“ die WSL-Hülle sein (System32 oder WindowsApps), die Windows-Pfade nicht versteht. +pytestmark_bash = pytest.mark.skipif( + BASH is None or (sys.platform == "win32" and any(s in BASH.lower() for s in ("system32", "windowsapps"))), + reason="braucht eine bash (Git-Bash oder Linux)", +) + + +@pytest.fixture +def pbs(tmp_path): + (tmp_path / "hermes").mkdir() + (tmp_path / "lswap").mkdir() + env = tmp_path / "pbs.env" + env.write_text("PBS_REPOSITORY=aibox@pbs@192.0.2.1:8007:qnap\nPBS_PASSWORD=nur-ein-test\n", encoding="utf-8") + client = tmp_path / "client" + client.write_text('#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "$STUB_ARGS"\n' + 'printf "%s\\n" "${PBS_REPOSITORY:-}" > "$STUB_ENV"\n', encoding="utf-8", newline="\n") + client.chmod(0o755) + umgebung = {**os.environ, "MC_PBS_ENV": env.as_posix(), "MC_PBS_CLIENT": client.as_posix(), + "HERMES_HOME": (tmp_path / "hermes").as_posix(), "MC_PBS_LLAMASWAP_DIR": (tmp_path / "lswap").as_posix(), + "MC_PBS_VAULT_DIR": (tmp_path / "vault").as_posix(), + "STUB_ARGS": (tmp_path / "args.txt").as_posix(), "STUB_ENV": (tmp_path / "env.txt").as_posix()} + return tmp_path, umgebung + + +def _pbs(umgebung: dict) -> subprocess.CompletedProcess: + return subprocess.run([BASH, (DEPLOY / "pbs-backup.sh").as_posix()], env=umgebung, + capture_output=True, text=True, encoding="utf-8", timeout=60) + + +@pytestmark_bash +def test_pbs_sichert_ohne_mem0_und_ueberspringt_fehlende_sammlung(pbs): + tmp, umgebung = pbs + r = _pbs(umgebung) + assert r.returncode == 0, r.stdout + r.stderr + args = (tmp / "args.txt").read_text(encoding="utf-8").splitlines() + assert args[0] == "backup" and args[-2:] == ["--backup-id", "aibox"] + assert [a.split(":", 1)[0] for a in args[1:-2]] == ["hermes.pxar", "llamaswap.pxar"] # kein mem0.pxar mehr + assert "wird übersprungen" in r.stdout + assert (tmp / "env.txt").read_text(encoding="utf-8").strip() == "aibox@pbs@192.0.2.1:8007:qnap" + + (tmp / "vault").mkdir() + assert _pbs(umgebung).returncode == 0 + assert "vault.pxar" in (tmp / "args.txt").read_text(encoding="utf-8") + + +@pytestmark_bash +def test_pbs_bricht_ohne_zugang_oder_pflichtordner_ab(pbs): + tmp, umgebung = pbs + r = _pbs({**umgebung, "MC_PBS_ENV": (tmp / "fehlt.env").as_posix()}) + assert r.returncode == 1 and "! Zugangsdatei fehlt" in r.stdout + r = _pbs({**umgebung, "HERMES_HOME": (tmp / "weg").as_posix()}) + assert r.returncode == 1 and "! Ordner fehlt" in r.stdout + assert not (tmp / "args.txt").exists() diff --git a/backend/tests/test_probe_wiederherstellung.py b/backend/tests/test_probe_wiederherstellung.py new file mode 100644 index 0000000..bebbac2 --- /dev/null +++ b/backend/tests/test_probe_wiederherstellung.py @@ -0,0 +1,252 @@ +"""Monatliche Probe-Wiederherstellung (services/probe_wiederherstellung.py, 24.09.2026) und ihr Wächter-Hinweis. + +Die Tests bauen Sicherungen so, wie deploy/backup.sh sie schreibt (Mitglieder „./hermes/…“, absolute Verknüpfungen +unter systemd/user), mit einem „lebenden“ Hermes-Ordner daneben — alles im Temp-Ordner, ohne Box und ohne Telegram.""" + +import io +import json +import os +import tarfile +import time +from datetime import datetime, timedelta +from pathlib import Path + +import pytest +from services import probe_wiederherstellung as pw +from services import waechter + +GEDAECHTNIS = {"MEMORY.md": b"Tobi mag kurze Antworten.\n", "USER.md": b"Name: Tobi\n"} + + +def _lebend(wurzel: Path) -> Path: + """Der lebende Stand: Hermes-Ordner und Datenordner.""" + hermes = wurzel / "live" / "hermes" + for name, inhalt in GEDAECHTNIS.items(): + (hermes / "memories").mkdir(parents=True, exist_ok=True) + (hermes / "memories" / name).write_bytes(inhalt) + (hermes / "memories" / "USER.md.lock").write_bytes(b"") + (hermes / "SOUL.md").write_bytes(b"Du bist Lucy.\n") + (hermes / "skills" / "news").mkdir(parents=True) + (hermes / "skills" / "news" / "SKILL.md").write_bytes(b"# News\n") + (hermes / "scripts").mkdir() + (hermes / "scripts" / "stack-ist.sh").write_bytes(b"#!/bin/bash\n") + (wurzel / "daten").mkdir() + (wurzel / "daten" / "mc2-waechter.json").write_text('{"hinweise": {}}', encoding="utf-8") + return hermes + + +def _inhalt(hermes: Path, wurzel: Path) -> dict[str, bytes]: + """Was backup.sh aus dem lebenden Stand einpacken würde.""" + inhalt = {f"hermes/memories/{n}": b for n, b in GEDAECHTNIS.items()} + inhalt.update({ + "hermes/memories/USER.md.lock": b"", + "hermes/SOUL.md": (hermes / "SOUL.md").read_bytes(), + "hermes/skills/news/SKILL.md": b"# News\n", + "hermes/scripts/stack-ist.sh": b"#!/bin/bash\n", + "hermes/cron/jobs.json": json.dumps({"jobs": [{"id": "a", "name": "Daily News Report"}]}).encode(), + "hermes/config.yaml": b"model:\n default: hermes\n", + "hermes/.env": b"TELEGRAM_BOT_TOKEN=geheim\n", + "hermes/desktop-gateway-token": b"geheim\n", + "llama-swap/config.yaml": b"models:\n Qwen3.6-35B-A3B:\n cmd: llama-server\n", + "box-wart/mc2-waechter.json": (wurzel / "daten" / "mc2-waechter.json").read_bytes(), + "systemd/user/mission-control-2.service": b"[Service]\n", + "MANIFEST.txt": b"mc2-state backup\n", + }) + return inhalt + + +def _sicherung(ordner: Path, inhalt: dict[str, bytes | None], beginn: datetime | None = None) -> Path: + """Ein Tarball wie von backup.sh: Name mit Startzeit, Mitglieder „./…“, dazu eine absolute Verknüpfung.""" + beginn = beginn or datetime.now(pw.LOCAL_TZ) + timedelta(minutes=10) # „nach“ dem lebenden Stand + ordner.mkdir(parents=True, exist_ok=True) + tarball = ordner / f"mc2-state-{beginn:%Y%m%d-%H%M%S}.tar.gz" + with tarfile.open(tarball, "w:gz") as tar: + for rel, daten in inhalt.items(): + if daten is None: + continue + info = tarfile.TarInfo(f"./{rel}") + info.size, info.mode, info.mtime = len(daten), 0o600, time.time() + tar.addfile(info, io.BytesIO(daten)) + link = tarfile.TarInfo("./systemd/user/default.target.wants/mission-control-2.service") + link.type, link.linkname = tarfile.SYMTYPE, "/home/hitonabi/.config/systemd/user/mission-control-2.service" + tar.addfile(link) + return tarball + + +@pytest.fixture +def box(tmp_path, monkeypatch): + hermes = _lebend(tmp_path) + monkeypatch.setattr(pw, "SICHERUNGEN_DIR", tmp_path / "backups") + monkeypatch.setattr(pw, "ZUSTAND_PATH", tmp_path / "daten" / "mc2-probe-wiederherstellung.json") + monkeypatch.setattr(pw, "HERMES_LIVE", hermes) + monkeypatch.setattr(pw, "DATEN_LIVE", tmp_path / "daten") + monkeypatch.setattr(pw, "LLAMA_SWAP_DROPINS", tmp_path / "gibt-es-hier-nicht") + (tmp_path / "tmp").mkdir() + monkeypatch.setenv("MC_PROBE_TMP", str(tmp_path / "tmp")) + meldungen: list[dict] = [] + monkeypatch.setattr(pw, "melden", meldungen.append) + return tmp_path, hermes, meldungen + + +def test_gruene_probe_packt_aus_prueft_und_raeumt_auf(box, monkeypatch): + tmp, hermes, meldungen = box + _sicherung(tmp / "backups", _inhalt(hermes, tmp)) + gesehen: dict = {} + echt = pw._pruefe_inhalt + + def spion(wurzel, *args): + gesehen.update(wurzel=wurzel, env=(wurzel / "hermes" / ".env").exists(), + token=(wurzel / "hermes" / "desktop-gateway-token").exists(), + seele=(wurzel / "hermes" / "SOUL.md").is_file()) + return echt(wurzel, *args) + + monkeypatch.setattr(pw, "_pruefe_inhalt", spion) + stand = pw.probe() + assert stand["ergebnis"] == "gruen", stand["fehler"] + assert meldungen == [] + assert gesehen["seele"] and not gesehen["env"] and not gesehen["token"] # Geheimnisse bleiben im Archiv + assert not gesehen["wurzel"].exists() and list((tmp / "tmp").iterdir()) == [] # Tmp-Ordner ist weg + assert any(z.startswith("Gedächtnis: 2 Dateien") for z in stand["geprueft"]) + assert "Zugangsdaten (.env) enthalten (nicht ausgepackt)" in stand["geprueft"] + assert pw.lese_stand()["ergebnis"] == "gruen" + + +def test_fehlendes_gedaechtnis_ist_rot_und_wird_gemeldet(box): + tmp, hermes, meldungen = box + inhalt = {k: v for k, v in _inhalt(hermes, tmp).items() if not k.startswith("hermes/memories/")} + _sicherung(tmp / "backups", inhalt) + stand = pw.probe() + assert stand["ergebnis"] == "rot" + assert any("Lucys Gedächtnis (hermes/memories) fehlt" in f for f in stand["fehler"]) + # Abgleich mit dem lebenden Stand: eine Zeile je Bereich, auch bei vielen Dateien. + assert "Gedächtnis: 2 Dateien fehlen in der Sicherung (MEMORY.md, USER.md)." in stand["fehler"] + assert len(meldungen) == 1 and pw.lese_stand()["ergebnis"] == "rot" + + +def test_viele_fehlende_dateien_ergeben_eine_zeile(box): + tmp, hermes, _ = box + for i in range(40): + (hermes / "skills" / f"s{i:02d}").mkdir() + (hermes / "skills" / f"s{i:02d}" / "SKILL.md").write_bytes(b"# x\n") + _sicherung(tmp / "backups", _inhalt(hermes, tmp)) + fehler = pw.probe()["fehler"] + assert fehler == ["Skills: 40 Dateien fehlen in der Sicherung (s00/SKILL.md, s01/SKILL.md, s02/SKILL.md …)."] + + +def test_abweichendes_gedaechtnis_faellt_auf(box): + """Stand eine Datei schon vor der Sicherung so da, muss die Sicherung sie unverändert enthalten.""" + tmp, hermes, _ = box + inhalt = _inhalt(hermes, tmp) + inhalt["hermes/memories/USER.md"] = b"Name: jemand anderes\n" + _sicherung(tmp / "backups", inhalt) + fehler = pw.probe()["fehler"] + assert fehler == ["Gedächtnis: USER.md steht in der Sicherung anders als auf der Box."] + + +def test_nach_der_sicherung_geaenderte_dateien_zaehlen_nicht(box): + """Die Sicherung begann vor dem lebenden Stand: Abweichungen sind dann neuere Erinnerungen, kein Fehler.""" + tmp, hermes, _ = box + inhalt = _inhalt(hermes, tmp) + inhalt["hermes/memories/USER.md"] = b"alter Stand\n" + _sicherung(tmp / "backups", inhalt, beginn=datetime.now(pw.LOCAL_TZ) - timedelta(hours=2)) + assert pw.probe()["ergebnis"] == "gruen" + + +@pytest.mark.parametrize("weg, text", [ + ("hermes/SOUL.md", "SOUL.md fehlt in der Sicherung"), + ("hermes/skills/news/SKILL.md", "Keine Skills"), + ("hermes/scripts/stack-ist.sh", "Keine Cron-Skripte"), + ("hermes/config.yaml", "Die Hermes-Config (config.yaml) fehlt"), + ("hermes/.env", "Die Zugangsdaten (hermes/.env) fehlen"), + ("box-wart/mc2-waechter.json", "Der Box-Wart-Zustand (box-wart/mc2-*.json) fehlt"), + ("systemd/user/mission-control-2.service", "Die systemd-Units"), + ("llama-swap/config.yaml", "Die llama-swap-Config fehlt"), + ("hermes/cron/jobs.json", "Die Hermes-Cron-Jobs"), +]) +def test_jeder_pflichtinhalt_zaehlt(box, weg, text): + tmp, hermes, _ = box + inhalt = _inhalt(hermes, tmp) + inhalt[weg] = None + _sicherung(tmp / "backups", inhalt) + stand = pw.probe() + assert stand["ergebnis"] == "rot" + assert any(text in f for f in stand["fehler"]), stand["fehler"] + + +def test_unlesbarer_zustand_und_alte_sicherung(box): + tmp, hermes, _ = box + inhalt = _inhalt(hermes, tmp) + inhalt["box-wart/mc2-radar.json"] = b"{kaputt" + tarball = _sicherung(tmp / "backups", inhalt) + alt = time.time() - 3 * 86400 + os.utime(tarball, (alt, alt)) + fehler = pw.probe()["fehler"] + assert any("Die jüngste Sicherung ist 3 Tage alt" in f for f in fehler) + assert "Box-Wart-Zustand unlesbar: mc2-radar.json" in fehler + + +def test_kaputte_und_fehlende_sicherung(box): + tmp, hermes, meldungen = box + assert pw.probe()["fehler"] == [f"Keine Sicherung gefunden ({tmp / 'backups'})."] + tarball = _sicherung(tmp / "backups", _inhalt(hermes, tmp)) + tarball.write_bytes(tarball.read_bytes()[:200]) # abgeschnitten wie nach einer vollen Platte + stand = pw.probe() + assert stand["ergebnis"] == "rot" and stand["fehler"][0].startswith("Die Sicherung lässt sich nicht auspacken") + assert list((tmp / "tmp").iterdir()) == [] and len(meldungen) == 2 + + +def test_meldung_geht_in_normaler_dringlichkeit_raus(monkeypatch): + """Rot → notify.sh mit Betreff [Sicherung], ohne -d: nachts sammelt notify.sh sie für die Morgenmeldung.""" + aufrufe: list[list[str]] = [] + monkeypatch.setattr(pw, "_bash", lambda: "bash") + monkeypatch.setattr(pw.subprocess, "run", lambda befehl, **kw: aufrufe.append(befehl)) + pw.melden({"sicherung": "mc2-state-x.tar.gz", "fehler": ["eins", "zwei", "drei", "vier"]}) + befehl = aufrufe[0] + assert befehl[:2] == ["bash", str(pw.NOTIFY_SH)] and befehl[2:4] == ["-s", "[Sicherung]"] + assert "-d" not in befehl and "Alarm" not in befehl[3] and not befehl[4].startswith("KRITISCH") + assert "eins zwei drei (und 1 weitere)" in befehl[4] + + +# --- Wächter ------------------------------------------------------------------------------------------ + +def _stand(ergebnis: str, tage: float, fehler: list[str] | None = None) -> dict: + return {"zeit": time.time() - tage * 86400, "ergebnis": ergebnis, "sicherung": "mc2-state-x.tar.gz", + "fehler": fehler or []} + + +def test_waechter_zeigt_gelb_bei_rot_oder_zu_alt(monkeypatch): + stand: dict = {} + monkeypatch.setattr(waechter.probe_wiederherstellung, "lese_stand", lambda: stand.get("x")) + monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "loaded"}) + + stand["x"] = _stand("gruen", 3) + assert waechter.pruefe_probe_wiederherstellung() == [] + + stand["x"] = _stand("rot", 3, ["Lucys Gedächtnis (hermes/memories) fehlt in der Sicherung oder ist leer.", "b"]) + (b,) = waechter.pruefe_probe_wiederherstellung() + assert (b.id, b.stufe, b.sofort) == ("probe:wiederherstellung", "gelb", True) + assert "Lucys Gedächtnis" in b.text and "(und 1 weitere)" in b.text + assert {a["id"] for a in b.aktionen} == {"neustart", "protokoll"} + assert all(a["dienst"] == "mc2-probe-wiederherstellung" for a in b.aktionen) + + stand["x"] = _stand("gruen", 41) + (b,) = waechter.pruefe_probe_wiederherstellung() + assert b.stufe == "gelb" and "seit 41 Tagen" in b.titel + + +def test_waechter_vor_der_ersten_probe(monkeypatch): + """Noch nie gelaufen: kein Hinweis, solange der Timer fehlt (PC, frische Box) — danach gelb mit „Jetzt prüfen“.""" + monkeypatch.setattr(waechter.probe_wiederherstellung, "lese_stand", lambda: None) + monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "not-found"}) + assert waechter.pruefe_probe_wiederherstellung() == [] + monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"LoadState": "loaded"}) + (b,) = waechter.pruefe_probe_wiederherstellung() + assert b.stufe == "gelb" and b.aktionen[0]["label"] == "Jetzt prüfen" + + +def test_knoepfe_des_hinweises_sind_erlaubt(): + """„Jetzt prüfen“ und „Protokoll“ laufen über die Allowlist in services.maintenance.""" + from services import maintenance + assert {"mc2-probe-wiederherstellung", "pbs-backup"} <= maintenance.USER_SERVICES + assert "pbs-backup" in waechter.TIMER_DIENSTE and waechter.TIMER_DIENSTE["pbs-backup"][1] is False + assert waechter.pruefe_probe_wiederherstellung in waechter.PRUEFUNGEN diff --git a/deploy/deploy.sh b/deploy/deploy.sh index f697b75..1b70c6e 100644 --- a/deploy/deploy.sh +++ b/deploy/deploy.sh @@ -24,13 +24,18 @@ DEPLOY_LOG="/srv/models/mc2-deploy.log" # Units, die ganz dem Repo gehören (deploy.sh spielt sie aus). Aktiviert wird nur, was laufen soll: # Konsole und Spracherkennung schlafen seit 24.09.2026 (disable --now), ihre Units bleiben aber aktuell. +# Die Sicherung auf den PBS (pbs-backup) liegt seit 24.09.2026 im Repo; eingeschaltet wird sie von Hand +# (docs/BETRIEB.md, Sicherung) — deploy.sh schaltet sie weder ein noch aus. UNITS="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service box-console.service voice-service.service mc2-radar.service mc2-radar.timer mc2-backup.service mc2-backup.timer mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.timer - projekte-sync.service projekte-sync.timer" + projekte-sync.service projekte-sync.timer + mc2-probe-wiederherstellung.service mc2-probe-wiederherstellung.timer + pbs-backup.service pbs-backup.timer" AKTIV="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service - mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer" + mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer mc2-autoupdate.timer projekte-sync.timer + mc2-probe-wiederherstellung.timer" # Skills, die abgelöst sind: aus Lucys Skill-Index nehmen (verschoben, nicht gelöscht). Dazu die # alten Bindestrich-Doppel der übrigen Skills (deploy.sh schreibt die Unterstrich-Fassung). SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand @@ -125,7 +130,8 @@ stufe2() { # shellcheck disable=SC2086 systemctl --user enable $AKTIV >/dev/null 2>&1 # shellcheck disable=SC2086 - systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer + systemctl --user start mc2-radar.timer mc2-backup.timer mc2-morgenmeldung.timer projekte-sync.timer \ + mc2-probe-wiederherstellung.timer # Den Update-Timer nur starten, wenn er nicht schon läuft: Mit Persistent=true holt ein frisch # gestarteter Timer einen verpassten Sonntagslauf sofort nach (so am 24.09.2026 passiert). Neu # gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh). diff --git a/deploy/mc2-probe-wiederherstellung.service b/deploy/mc2-probe-wiederherstellung.service new file mode 100644 index 0000000..537dec3 --- /dev/null +++ b/deploy/mc2-probe-wiederherstellung.service @@ -0,0 +1,23 @@ +# systemd-USER-Unit: monatliche Probe-Wiederherstellung (seit 24.09.2026), gestartet von +# mc2-probe-wiederherstellung.timer. Packt die jüngste Sicherung aus /srv/models/mc2-backups probeweise in +# einen Tmp-Ordner unter /var/tmp aus, prüft die Pflichtinhalte (Lucys Gedächtnis, SOUL.md, Skills, +# Cron-Skripte, Hermes-Config, Box-Wart-Zustand, Units) und löscht den Ordner wieder. Lebende Dateien fasst +# sie nie an. Ergebnis: /srv/models/mc2-probe-wiederherstellung.json (liest der Wächter); bei Rot eine +# Meldung über notify.sh (nachts in die Morgenmeldung). Logik: backend/services/probe_wiederherstellung.py. +# Von Hand: systemctl --user start mc2-probe-wiederherstellung.service + +[Unit] +Description=MC2 Probe-Wiederherstellung (jüngste Sicherung probeweise auspacken und prüfen) +Documentation=file:%h/mission-control-v2/docs/BETRIEB.md +After=mc2-backup.service + +[Service] +Type=oneshot +WorkingDirectory=%h/mission-control-v2/backend +ExecStart=%h/mission-control-v2/backend/.venv/bin/python -m services.probe_wiederherstellung +Environment=PYTHONPATH=%h/mission-control-v2 +Environment=MC_MODELS_DIR=/srv/models +Environment=MC_LOCAL_TZ=Europe/Berlin +Nice=10 +IOSchedulingClass=idle +TimeoutStartSec=15min diff --git a/deploy/mc2-probe-wiederherstellung.timer b/deploy/mc2-probe-wiederherstellung.timer new file mode 100644 index 0000000..64fa6ca --- /dev/null +++ b/deploy/mc2-probe-wiederherstellung.timer @@ -0,0 +1,13 @@ +[Unit] +Description=Monatliche Probe-Wiederherstellung (erster Montag im Monat, 05:15) + +[Timer] +# Erster Montag im Monat: nie sonntags (Updates So 04:30, Neustart bis 07:00) und lange nach der Sicherung +# (03:30, +≤5 min), deren Ergebnis sie prüft. Rot geht nachts in die Morgenmeldung um 07:00. +# Persistent=true holt einen verpassten Lauf nach — ungefährlich, die Probe schreibt nur in /var/tmp und in +# ihre Zustandsdatei. +OnCalendar=Mon *-*-01..07 05:15:00 +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/deploy/pbs-backup.service b/deploy/pbs-backup.service new file mode 100644 index 0000000..4ece7d2 --- /dev/null +++ b/deploy/pbs-backup.service @@ -0,0 +1,17 @@ +# systemd-USER-Unit: Sicherung der Box auf den Proxmox Backup Server (PBS 192.168.178.156, Datastore „qnap“ = +# NFS-Freigabe auf dem QNAP), gestartet von pbs-backup.timer. Lief seit 09.07.2026 täglich (Unit lag nur auf der +# Box), brach am 21.08. ab (Quellordner des alten Gedächtnis-Dienstes weg) und war seitdem aus. Seit 24.09.2026 +# im Repo: deploy.sh spielt die Unit aus, eingeschaltet wird sie von Hand (docs/BETRIEB.md, Sicherung). +# Skript: deploy/pbs-backup.sh (Zugang und Client liegen nur auf der Box, nicht im Repo). + +[Unit] +Description=Sicherung der Box auf den PBS (~/.hermes, Wissens-Sammlung, llama-swap-Config) +Documentation=file:%h/mission-control-v2/docs/BETRIEB.md +After=mc2-backup.service + +[Service] +Type=oneshot +ExecStart=/bin/bash %h/mission-control-v2/deploy/pbs-backup.sh +Nice=10 +IOSchedulingClass=idle +TimeoutStartSec=1h diff --git a/deploy/pbs-backup.sh b/deploy/pbs-backup.sh new file mode 100644 index 0000000..4806e4e --- /dev/null +++ b/deploy/pbs-backup.sh @@ -0,0 +1,46 @@ +#!/usr/bin/env bash +# pbs-backup.sh — Sicherung der Box auf den Proxmox Backup Server (Backup-ID aibox, Datastore „qnap“). +# +# Zweite Schicht neben der täglichen Tarball-Sicherung (backup.sh): ~/.hermes ganz (auch Sitzungen und Protokolle, +# die der Tarball bewusst weglässt), die Wissens-Sammlung und die llama-swap-Config als pxar-Archive. Der PBS +# dedupliziert und hebt Stände auf (Prune auf dem PBS: 7 Tage, 4 Wochen, 3 Monate); die Daten liegen auf dem QNAP. +# Inkrementell dauert ein Lauf rund 30 Sekunden. +# +# Geschichte: lief vom 09.07.2026 an täglich um 03:50 (damals ~/bin/pbs-backup.sh, nicht im Repo). Am 21.08. brach +# sie ab, weil ihr erster Quellordner (/srv/models/mem0, das abgelöste Gedächtnis) fehlte, und wurde im KISS-Umbau +# abgeschaltet. Seit 24.09.2026 im Repo und ohne mem0. Fehlt ein optionaler Ordner, läuft sie ohne ihn weiter. +# +# Braucht (nur auf der Box, nie im Repo): +# ~/.config/pbs-backup/env PBS_REPOSITORY, PBS_PASSWORD, PBS_FINGERPRINT (Rechte 600) +# ~/bin/proxmox-backup-client statisches Client-Programm +# Aufruf: systemctl --user start pbs-backup.service (Protokoll: journalctl --user -u pbs-backup) +set -euo pipefail + +ENV_DATEI="${MC_PBS_ENV:-$HOME/.config/pbs-backup/env}" +CLIENT="${MC_PBS_CLIENT:-$HOME/bin/proxmox-backup-client}" +BACKUP_ID="${MC_PBS_BACKUP_ID:-aibox}" +HERMES_DIR="${HERMES_HOME:-$HOME/.hermes}" +LSWAP_DIR="${MC_PBS_LLAMASWAP_DIR:-/etc/llama-swap}" +VAULT_DIR="${MC_PBS_VAULT_DIR:-$HOME/wissens-vault}" + +[ -r "$ENV_DATEI" ] || { echo "! Zugangsdatei fehlt: $ENV_DATEI"; exit 1; } +[ -x "$CLIENT" ] || { echo "! proxmox-backup-client fehlt: $CLIENT"; exit 1; } + +# Pflicht: ohne diese beiden ist die Sicherung wertlos. +for pflicht in "$HERMES_DIR" "$LSWAP_DIR"; do + [ -d "$pflicht" ] || { echo "! Ordner fehlt: $pflicht"; exit 1; } +done +archive=("hermes.pxar:$HERMES_DIR" "llamaswap.pxar:$LSWAP_DIR") +# Optional: fehlt die Wissens-Sammlung, läuft die Sicherung ohne sie (am 21.08. brach sie an so etwas ab). +if [ -d "$VAULT_DIR" ]; then + archive+=("vault.pxar:$VAULT_DIR") +else + echo "Hinweis: $VAULT_DIR fehlt, wird übersprungen." +fi + +# Zugang erst hier einlesen: PBS_PASSWORD und Co. landen nur in der Umgebung des Clients, nie auf der Befehlszeile. +set -a +# shellcheck disable=SC1090 +. "$ENV_DATEI" +set +a +exec "$CLIENT" backup "${archive[@]}" --backup-id "$BACKUP_ID" diff --git a/deploy/pbs-backup.timer b/deploy/pbs-backup.timer new file mode 100644 index 0000000..0621f39 --- /dev/null +++ b/deploy/pbs-backup.timer @@ -0,0 +1,12 @@ +[Unit] +Description=Tägliche Sicherung auf den PBS (03:50) + +[Timer] +# Nach der Tarball-Sicherung (03:30, +≤5 min) und vor dem Sonntags-Update (04:30). Dauert inkrementell ~30 s. +# Persistent=true holt einen verpassten Lauf nach. Achtung beim ersten Einschalten: Der Timer lief zuletzt am +# 21.08.2026 — er holt deshalb sofort einen Lauf nach (ungefährlich, nur eine zusätzliche Sicherung). +OnCalendar=*-*-* 03:50:00 +Persistent=true + +[Install] +WantedBy=timers.target