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 <noreply@anthropic.com>
This commit is contained in:
Hitonabi
2026-09-24 20:41:33 +02:00
co-authored by Claude Opus 5.5
parent 93f6613146
commit 2e0ad70a85
11 changed files with 947 additions and 6 deletions
+2 -1
View File
@@ -25,7 +25,8 @@ SYSTEM_SERVICES = {"llama-swap"}
# erneut laufen lassen“ — der Wächter bietet das als Knopf an (services/waechter.py). # 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", USER_SERVICES = {"mission-control-2", "mc2-gateway", "mc2-steward", "box-console",
"hermes-gateway", "hermes-builtin-ui", "voice-service", "lucy-stimme", "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 # 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 # Prüfungen Netz-, API- und apt-Fehler und meldeten „kein Update“ — das Cockpit zeigte dann
+415
View File
@@ -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())
+40 -2
View File
@@ -11,6 +11,7 @@ gesehen. Dieser Wächter schaut deshalb breiter hin:
• Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP • Kern Engine, Hirn, Hermes, Hör-Dienst, MC2, Gateway antworten per HTTP
• Platte Füllstand des Modell-Laufwerks • Platte Füllstand des Modell-Laufwerks
• Updates Bausteine, die der Sonntags-Lauf nach einem Fehlschlag festhält • 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 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 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 config import HERMES_API_KEY, HERMES_API_URL, HERMES_HOME, VOICE_SERVICE_URL
from kern import partner from kern import partner
from kern.einstellungen import einstellungen 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__) log = logging.getLogger(__name__)
ROLLE = einstellungen().rolle ROLLE = einstellungen().rolle
@@ -98,6 +100,9 @@ TIMER_DIENSTE: dict[str, tuple[str, bool]] = {
"mc2-morgenmeldung": ("Morgenmeldung", True), "mc2-morgenmeldung": ("Morgenmeldung", True),
# Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu). # Seit 24.09.2026 ein eigener Timer statt Hermes-Cron (Hermes startet sich beim Update selbst neu).
"mc2-autoupdate": ("Updates am Sonntag", True), "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" HERMES_JOBS_PATH = HERMES_HOME / "cron" / "jobs.json"
@@ -465,6 +470,39 @@ def pruefe_platte() -> list[Befund]:
quelle="platte", sofort=True)] 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]: def pruefe_festgehalten() -> list[Befund]:
"""Festgehaltene Bausteine (Pin-Register von autoupdate.sh). Vom 06. bis 17.09.2026 hielt """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.""" 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. # den Ausführer auf dem Proxmox-Host und seine Gäste.
PRUEFUNGEN = { PRUEFUNGEN = {
"box": (pruefe_dienste, pruefe_timer_dienste, pruefe_hermes_jobs, pruefe_kern, pruefe_platte, "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), "homelab": (pruefe_platte, pruefe_partner, pruefe_ausfuehrer, pruefe_gaeste),
}[ROLLE] }[ROLLE]
BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"), BETREFF_PROBLEM, BETREFF_OK = {"box": ("[Box-Problem]", "[Box wieder ok]"),
+118
View File
@@ -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()
@@ -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
+9 -3
View File
@@ -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: # 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. # 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 UNITS="mission-control-2.service mc2-gateway.service mc2-steward.service lucy-stimme.service
box-console.service voice-service.service box-console.service voice-service.service
mc2-radar.service mc2-radar.timer mc2-backup.service mc2-backup.timer mc2-radar.service mc2-radar.timer mc2-backup.service mc2-backup.timer
mc2-morgenmeldung.service mc2-morgenmeldung.timer mc2-autoupdate.service mc2-autoupdate.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 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 # 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). # 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 SKILLS_ALT="autonomie konzept-fliessband llm-wiki morning-report orchestrator projekt-start pruefstand
@@ -125,7 +130,8 @@ stufe2() {
# shellcheck disable=SC2086 # shellcheck disable=SC2086
systemctl --user enable $AKTIV >/dev/null 2>&1 systemctl --user enable $AKTIV >/dev/null 2>&1
# shellcheck disable=SC2086 # 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 # 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 # 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). # gestartet wird die Box dabei ohnehin nur sonntags zwischen 04 und 07 Uhr (autoupdate.sh).
@@ -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
+13
View File
@@ -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
+17
View File
@@ -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
+46
View File
@@ -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"
+12
View File
@@ -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