Merge branch 'worktree-agent-a1c799645a0ade657' into wartung/restarbeiten
This commit is contained in:
@@ -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,156 @@
|
||||
"""Lucys Hirn gezielt prüfen (Wächter, 24.09.2026) und „Jetzt laden“ mit echtem Ergebnis.
|
||||
|
||||
Bis 24.09. galt das Hirn als bereit, sobald irgendein Modell lief, und POST /api/models/{id}/load meldete „ok“,
|
||||
egal was die Engine sagte. llama-swap wird hier durch httpx.MockTransport ersetzt (Format von /running wie in
|
||||
llama-swap v257: jedes Modell, das nicht „stopped“ ist, mit seinem Zustand)."""
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from services import llamaswap, waechter
|
||||
|
||||
_ECHTER_CLIENT = httpx.Client
|
||||
|
||||
|
||||
def _engine(monkeypatch, handler) -> None:
|
||||
"""llama-swap durch eine Attrappe ersetzen (alle httpx.Client in services.llamaswap)."""
|
||||
monkeypatch.setattr(llamaswap.httpx, "Client", lambda *a, **kw: _ECHTER_CLIENT(
|
||||
transport=httpx.MockTransport(handler), timeout=kw.get("timeout")))
|
||||
|
||||
|
||||
def _running(*eintraege) -> httpx.Response:
|
||||
return httpx.Response(200, json={"running": list(eintraege)})
|
||||
|
||||
|
||||
# --- Zustände aus /running ------------------------------------------------------------------------
|
||||
|
||||
def test_modell_zustaende_liest_objekte_und_alte_namenslisten(monkeypatch):
|
||||
_engine(monkeypatch, lambda req: _running({"model": "Hirn", "state": "starting"},
|
||||
{"model": "Coder", "state": "ready"}, "Alt"))
|
||||
assert llamaswap.modell_zustaende() == {"Hirn": "starting", "Coder": "ready", "Alt": "ready"}
|
||||
_engine(monkeypatch, lambda req: httpx.Response(500))
|
||||
assert llamaswap.modell_zustaende() is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("running, zustand", [
|
||||
([{"model": "Hirn", "state": "ready"}], "bereit"),
|
||||
([{"model": "Hirn", "state": "starting"}], "laedt"),
|
||||
([{"model": "Hirn", "state": "stopping"}], "fehlt"),
|
||||
([{"model": "Coder", "state": "ready"}], "fehlt"), # der alte Fehler: Coder läuft, Hirn ist weg
|
||||
(None, "unbekannt"),
|
||||
])
|
||||
def test_hirn_zustand_prueft_gezielt_das_hirn(monkeypatch, running, zustand):
|
||||
monkeypatch.setattr(llamaswap, "brain_model_name", lambda: "Hirn")
|
||||
monkeypatch.setattr(llamaswap, "modell_zustaende",
|
||||
lambda: None if running is None else {e["model"]: e["state"] for e in running})
|
||||
assert llamaswap.hirn_zustand() == {"modell": "Hirn", "zustand": zustand}
|
||||
|
||||
|
||||
# --- Wächter ------------------------------------------------------------------------------------------
|
||||
|
||||
@pytest.fixture
|
||||
def hirn(monkeypatch):
|
||||
"""Der Wächter sieht den Zustand, den der Test vorgibt; seine Frist beginnt frisch."""
|
||||
zustand = {"modell": "Qwen3.6-35B-A3B", "zustand": "bereit"}
|
||||
monkeypatch.setattr(waechter, "_hirn", {"fehlt_seit": None, "geprueft": None})
|
||||
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: dict(zustand))
|
||||
return zustand
|
||||
|
||||
|
||||
def test_abgestuerztes_hirn_neben_geladenem_coder_ist_ein_befund(monkeypatch, hirn):
|
||||
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
|
||||
monkeypatch.setattr(waechter.llamaswap, "get_running_models", lambda: ["Qwen3.8-27B"]) # Coder läuft
|
||||
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: True)
|
||||
monkeypatch.setattr(waechter, "_systemctl_show", lambda name, system: {"ActiveState": "active"})
|
||||
hirn["zustand"] = "fehlt"
|
||||
befunde = {b.id: b for b in waechter.pruefe_kern()}
|
||||
assert befunde["kern:hirn"].titel == "Lucys Hirn ist nicht geladen"
|
||||
assert "Qwen3.6-35B-A3B läuft nicht" in befunde["kern:hirn"].text
|
||||
assert befunde["kern:hirn"].aktionen == [{"id": "protokoll", "label": "Protokoll des Motors", "dienst": "llama-swap"}]
|
||||
assert not befunde["kern:hirn"].sofort # zählt erst nach den üblichen Takten
|
||||
|
||||
|
||||
def test_laden_ist_kein_ausfall_bis_zur_frist(monkeypatch, hirn):
|
||||
monkeypatch.setattr(waechter, "HIRN_LADEN_MAX_S", 600)
|
||||
hirn["zustand"] = "laedt"
|
||||
assert waechter._hirn_befund(jetzt=1000.0) is None
|
||||
assert waechter._hirn_befund(jetzt=1060.0) is None
|
||||
# Startet es immer wieder neu, wechseln sich „fehlt“ und „lädt“ ab: „fehlt“ ist ein Befund, und nach der Frist
|
||||
# ist es auch „lädt“ — sonst käme nie ein Hinweis zustande.
|
||||
hirn["zustand"] = "fehlt"
|
||||
assert waechter._hirn_befund(jetzt=1120.0).titel == "Lucys Hirn ist nicht geladen"
|
||||
hirn["zustand"] = "laedt"
|
||||
for jetzt in range(1180, 1600, 60): # Takt wie im Betrieb: jede Minute
|
||||
assert waechter._hirn_befund(jetzt=float(jetzt)) is None
|
||||
b = waechter._hirn_befund(jetzt=1600.0)
|
||||
assert b.titel == "Lucys Hirn lädt nicht fertig" and "seit über 10 Minuten" in b.text
|
||||
hirn["zustand"] = "bereit"
|
||||
assert waechter._hirn_befund(jetzt=1660.0) is None
|
||||
hirn["zustand"] = "laedt" # nach „bereit“ beginnt die Frist neu
|
||||
assert waechter._hirn_befund(jetzt=1720.0) is None
|
||||
|
||||
|
||||
def test_lange_pause_setzt_die_frist_zurueck(hirn):
|
||||
"""Während eines Updates prüft der Wächter den Kern nicht. Danach darf das Hirn wieder in Ruhe laden."""
|
||||
hirn["zustand"] = "laedt"
|
||||
assert waechter._hirn_befund(jetzt=0.0) is None
|
||||
assert waechter._hirn_befund(jetzt=5000.0) is None
|
||||
|
||||
|
||||
def test_ohne_hirn_rolle_ist_es_ein_befund(hirn):
|
||||
hirn.update(modell=None, zustand="fehlt")
|
||||
assert "Kein Modell trägt die Rolle „hermes“" in waechter._hirn_befund(jetzt=0.0).text
|
||||
|
||||
|
||||
# --- Jetzt laden -----------------------------------------------------------------------------------------
|
||||
|
||||
def _laden(monkeypatch, antwort, running=()) -> dict:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
if req.url.path == "/running":
|
||||
return _running(*running)
|
||||
if isinstance(antwort, Exception):
|
||||
raise antwort
|
||||
return antwort
|
||||
_engine(monkeypatch, handler)
|
||||
return llamaswap.lade_modell("Qwen3.8-27B", warte_s=5)
|
||||
|
||||
|
||||
def test_laden_meldet_den_fehler_der_engine(monkeypatch):
|
||||
ergebnis = _laden(monkeypatch, httpx.Response(502, text="upstream command exited prematurely but successfully"))
|
||||
assert ergebnis["ok"] is False
|
||||
assert ergebnis["detail"] == ("Qwen3.8-27B ließ sich nicht laden. Die Engine meldet (HTTP 502): "
|
||||
"upstream command exited prematurely but successfully")
|
||||
ergebnis = _laden(monkeypatch, httpx.Response(404, json={"error": {"message": "model not found"}}))
|
||||
assert ergebnis["ok"] is False and ergebnis["detail"].endswith("(HTTP 404): model not found")
|
||||
|
||||
|
||||
def test_laden_gelingt(monkeypatch):
|
||||
assert _laden(monkeypatch, httpx.Response(200, json={"choices": []})) == {"ok": True, "text": "Qwen3.8-27B ist geladen."}
|
||||
# Ein Modell, das nicht chattet (embed), antwortet mit Fehler — geladen ist es trotzdem.
|
||||
ergebnis = _laden(monkeypatch, httpx.Response(501, text="no chat"), running=[{"model": "Qwen3.8-27B", "state": "ready"}])
|
||||
assert ergebnis["ok"] is True
|
||||
|
||||
|
||||
def test_laden_nach_der_wartezeit(monkeypatch):
|
||||
zeitueber = httpx.ReadTimeout("zu langsam")
|
||||
ergebnis = _laden(monkeypatch, zeitueber, running=[{"model": "Qwen3.8-27B", "state": "starting"}])
|
||||
assert ergebnis["ok"] is True and ergebnis["laedt"] is True
|
||||
ergebnis = _laden(monkeypatch, zeitueber)
|
||||
assert ergebnis["ok"] is False and "nach 5 Sekunden nicht geantwortet" in ergebnis["detail"]
|
||||
ergebnis = _laden(monkeypatch, httpx.ConnectError("refused"))
|
||||
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectError)" in ergebnis["detail"]
|
||||
# Unter Windows scheitert ein Verbindungsaufbau zu einem toten Port als Zeitüberschreitung — das ist
|
||||
# „nicht erreichbar“, nicht „lädt noch“.
|
||||
ergebnis = _laden(monkeypatch, httpx.ConnectTimeout("timed out"))
|
||||
assert ergebnis["ok"] is False and "nicht erreichbar (ConnectTimeout)" in ergebnis["detail"]
|
||||
|
||||
|
||||
def test_route_reicht_das_ergebnis_durch(monkeypatch):
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routers import models
|
||||
|
||||
monkeypatch.setattr(models.llamaswap, "lade_modell", lambda mid: {"ok": False, "detail": f"{mid}: kaputt"})
|
||||
app = FastAPI()
|
||||
app.include_router(models.router)
|
||||
antwort = TestClient(app).post("/api/models/Qwen3.8-27B/load")
|
||||
assert antwort.status_code == 200 and antwort.json() == {"ok": False, "detail": "Qwen3.8-27B: kaputt"}
|
||||
@@ -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
|
||||
@@ -0,0 +1,77 @@
|
||||
"""„Jetzt suchen“ im Radar als Auftrag (24.09.2026): Die Route antwortet sofort mit einer Auftrags-ID, gesucht wird
|
||||
in radar_lauf.py --nur-suche. Vorher lief die Suche synchron in der Anfrage und konnte Minuten dauern."""
|
||||
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
import radar_lauf
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from routers import radar as radar_router
|
||||
from services import radar
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auftraege(monkeypatch):
|
||||
"""Job-Engine-Attrappe: merkt sich den Start; läuft schon einer, kommt der zurück."""
|
||||
stand: dict = {"gestartet": [], "laeuft": None}
|
||||
|
||||
def start_job_exklusiv(gruppe, args, label, **kw):
|
||||
if stand["laeuft"]:
|
||||
return None, stand["laeuft"]
|
||||
stand["gestartet"].append({"gruppe": gruppe, "args": args, "label": label, **kw})
|
||||
return "job123", None
|
||||
|
||||
monkeypatch.setattr(radar.jobengine, "start_job_exklusiv", start_job_exklusiv)
|
||||
return stand
|
||||
|
||||
|
||||
def test_suche_startet_einen_auftrag(auftraege):
|
||||
ergebnis = radar.suche_starten()
|
||||
assert ergebnis["ok"] is True and ergebnis["job_id"] == "job123" and "Aufträge" in ergebnis["text"]
|
||||
(start,) = auftraege["gestartet"]
|
||||
assert start["gruppe"] == "radar" and start["zeitlimit_s"] == radar.SUCHE_ZEITLIMIT_S
|
||||
assert start["args"][0] == sys.executable
|
||||
assert start["args"][1].endswith("radar_lauf.py") and start["args"][2:] == ["--nur-suche"]
|
||||
|
||||
|
||||
def test_laufende_suche_wird_nicht_doppelt_gestartet(auftraege):
|
||||
auftraege["laeuft"] = {"id": "job-alt", "state": "running"}
|
||||
ergebnis = radar.suche_starten()
|
||||
assert (ergebnis["job_id"], ergebnis["laeuft_schon"]) == ("job-alt", True)
|
||||
assert auftraege["gestartet"] == []
|
||||
|
||||
|
||||
def test_route_antwortet_sofort_ohne_selbst_zu_suchen(auftraege, monkeypatch):
|
||||
def nicht_hier():
|
||||
raise AssertionError("Die Route darf nicht selbst suchen.")
|
||||
|
||||
monkeypatch.setattr(radar, "suche", nicht_hier)
|
||||
app = FastAPI()
|
||||
app.include_router(radar_router.router)
|
||||
antwort = TestClient(app).post("/api/radar/suche")
|
||||
assert antwort.status_code == 200 and antwort.json()["job_id"] == "job123"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("ergebnis, code, zeile", [
|
||||
({"neu": ["Ornith 1.5"], "wartend": 2, "quellen": {"watchlist": True, "discover": True}}, 0,
|
||||
"Suche fertig: 1 neu (Ornith 1.5), 2 warten auf den Nachttest."),
|
||||
({"neu": [], "wartend": 1, "quellen": {"watchlist": True, "discover": False}}, 0,
|
||||
"Suche fertig: nichts Neues, 1 warten auf den Nachttest, Hugging Face war nicht erreichbar."),
|
||||
({"neu": [], "wartend": 0, "quellen": {"watchlist": False, "discover": False}}, 1,
|
||||
("Suche fertig: nichts Neues, 0 warten auf den Nachttest, Hugging Face war nicht erreichbar, "
|
||||
"die Merkliste war nicht lesbar.")),
|
||||
])
|
||||
def test_nur_suche_sagt_das_ergebnis_in_der_letzten_zeile(monkeypatch, capsys, ergebnis, code, zeile):
|
||||
monkeypatch.setattr(radar_lauf.radar, "suche", lambda: ergebnis)
|
||||
assert radar_lauf.nur_suche() == code
|
||||
assert capsys.readouterr().out.strip().splitlines()[-1] == zeile
|
||||
|
||||
|
||||
def test_nur_suche_bei_absturz(monkeypatch, capsys):
|
||||
def kaputt():
|
||||
raise OSError("Platte voll")
|
||||
|
||||
monkeypatch.setattr(radar_lauf.radar, "suche", kaputt)
|
||||
assert radar_lauf.nur_suche() == 1
|
||||
assert capsys.readouterr().out.strip().endswith("Die Suche ist gescheitert: OSError: Platte voll")
|
||||
@@ -169,7 +169,7 @@ def test_ausblenden_gilt_bis_zum_naechsten_lauf(monkeypatch, tmp_path):
|
||||
def test_schlafende_spracherkennung_ist_kein_kernbefund(monkeypatch):
|
||||
"""24.09.2026: Die Spracherkennung schläft bis zur Android-App — kein roter Hinweis mehr."""
|
||||
monkeypatch.setattr(waechter.llamaswap, "engine_reachable", lambda: True)
|
||||
monkeypatch.setattr(waechter.llamaswap, "brain_status", lambda: {"ready": True})
|
||||
monkeypatch.setattr(waechter.llamaswap, "hirn_zustand", lambda: {"modell": "Hirn", "zustand": "bereit"})
|
||||
monkeypatch.setattr(waechter, "_reach", lambda url, pfad, kopf=None: "8650" not in url)
|
||||
monkeypatch.setattr(waechter, "_systemctl_show",
|
||||
lambda name, system: {"ActiveState": "inactive", "UnitFileState": "disabled"})
|
||||
|
||||
Reference in New Issue
Block a user