- 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>
416 lines
18 KiB
Python
416 lines
18 KiB
Python
"""
|
||
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())
|