Files
mission-control-v2/backend/tests/test_altreste.py
T
HitonabiandClaude Opus 5.5 3d8df1c6fd einstellungen: Zeitfenster, Radar-Schalter, Telegram-Test, Software-Stand und Altreste
Einstellungen, die etwas einstellen (services/einstellungen.py, /api/einstellungen):
- Update-Zeitfenster: Wochentag und Uhrzeit des Update-Laufs als Drop-in
  mc2-autoupdate.timer.d/zeitfenster.conf. Gegen die Nachhol-Falle von
  Persistent=true (24.09.): Timer vor dem daemon-reload stoppen, Stempel auf
  jetzt, dann starten. Bei Fehler zurück auf das alte Drop-in. Während eines
  Update-Laufs abgelehnt.
- Neustart nur im eingestellten Fenster (deploy/wartungsfenster.sh, drei
  Stunden ab der vollen Stunde des Beginns; Standard So 04:00-06:59).
- Radar an/aus: disable --now bzw. enable + Stempel + start (kein Nachholen).
  deploy.sh respektiert den Schalter und startet den Radar-Timer nur mit
  Stempel; das Zeitfenster-Drop-in fasst er nie an.
- Wächter: schläft der Timer eines Timer-Dienstes, ist ein alter Fehlschlag
  kein Befund mehr (der Dienst selbst ist static und schläft nie).
- Telegram-Test je Instanz (/api/einstellungen/telegram-test und
  /api/homelab/einstellungen/telegram-test) über notify.sh -d; Ergebnis und
  Grund aus dem Melde-Log. notify.sh schreibt jetzt auch den Grund des
  gescheiterten Zweitwegs in den FALLBACK-Eintrag.
- Einstellungsdatei mc2-einstellungen.json im Datenordner.

Software-Stand: deploy.sh und homelab/ausrollen.sh schreiben nach dem
Umschalten mc2-stand.json (deploy/stand-schreiben.py); /api/instanz liefert
den Stand, die Einstellungen zeigen beide Instanzen und warnen bei Abweichung.
Ohne Datei aus git, sonst unbekannt.

Aufräumen: Altreste neben dem Betrieb aus einer festen, auf der Box geprüften
Liste (Größe, Hinweis, nur was da und nicht in Gebrauch ist). Löschen nur per
Kennung und Klick mit Rückfrage; Worktrees mit git worktree prune, Alt-Units
mit daemon-reload, /opt/llamacpp notfalls mit sudo -n.

Tests: pytest mit einem systemd-Abbild samt Nachhol-Regel, echtem notify.sh
(Ersatz-Hermes, Tmp-Zugang, lokale Bot-API), Shell-Funktionen des
Wartungsfensters und von deploy.sh; vitest für die Rückfragen der Schublade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 20:37:30 +02:00

198 lines
9.9 KiB
Python

"""Altreste neben dem Betrieb (24.09.2026): nur aus der festen Liste, nur was da ist, nur auf Knopfdruck — und die
Löschfunktion nimmt eine Kennung, nie einen Pfad. Befehle (systemctl, git, sudo) laufen über eine Schicht, die hier
ersetzt ist; kein Test fasst die Box an."""
import os
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from routers import boxwart
from services import aufraeumen
from services.aufraeumen import Altrest
class Befehle:
"""Ersatz für aufraeumen._befehl: schreibt mit, und Units laufen nur, wenn der Test es sagt."""
def __init__(self):
self.aufrufe: list[list[str]] = []
self.laufend: set[str] = set()
self.eingetragen: set[str] = set()
self.sudo_ok = True
def __call__(self, args: list[str]) -> tuple[bool, str]:
self.aufrufe.append(args)
if args[:3] == ["systemctl", "--user", "show"]:
unit = args[3]
return True, (f"ActiveState={'active' if unit in self.laufend else 'inactive'}\n"
f"UnitFileState={'enabled' if unit in self.eingetragen else 'disabled'}")
if args[0] == "sudo":
return self.sudo_ok, "" if self.sudo_ok else "sudo: a password is required"
return True, ""
@pytest.fixture
def heim(tmp_path, monkeypatch):
"""Ein Home-Ordner mit Resten, eine Liste darauf und daneben etwas, das bleiben muss."""
home = tmp_path / "home"
for pfad, groesse in (("zonos/modell.bin", 3000), ("zonos/sub/x.txt", 500), (".venv-a/lib/a.py", 1000),
("skript.sh", 50), ("wt/datei", 10), ("bleibt/wichtig.txt", 5)):
datei = home / pfad
datei.parent.mkdir(parents=True, exist_ok=True)
datei.write_bytes(b"x" * groesse)
liste = (
Altrest("zonos", "TTS-Test", ("~/zonos",), "Test."),
Altrest("venvs", "Alte venvs", ("~/.venv-a", "~/.venv-gibt-es-nicht"), "Alt."),
Altrest("skript", "Altes Skript", ("~/skript.sh",), "Tot."),
Altrest("fehlt", "Längst weg", ("~/gibt-es-nicht",), "Weg."),
Altrest("wt", "Arbeitskopie", ("~/wt",), "Git.", art="worktree"),
Altrest("mem", "mem0", ("~/.venv-a",), "Sidecar.", sperre=("mem0-service.service",)),
)
befehle = Befehle()
monkeypatch.setenv("MC_ALTRESTE_HOME", str(home))
monkeypatch.setattr(aufraeumen, "ALTRESTE", liste)
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", True)
monkeypatch.setattr(aufraeumen, "_befehl", befehle)
monkeypatch.setitem(aufraeumen._altreste_cache, "liste", None)
return home, befehle
def test_liste_zeigt_nur_was_da_ist(heim):
liste = aufraeumen.altreste(frisch=True)
assert [a["id"] for a in liste] == ["zonos", "venvs", "mem", "skript", "wt"] # größte zuerst
zonos = liste[0]
assert zonos["groesse_bytes"] == 3500 and zonos["groesse"] == "4 KB" and zonos["pfade"] == ["~/zonos"]
venvs = next(a for a in liste if a["id"] == "venvs")
assert venvs["pfade"] == ["~/.venv-a"] # nur, was da ist
assert next(a for a in liste if a["id"] == "skript")["groesse"] == "unter 1 KB"
def test_was_ein_dienst_braucht_wird_nicht_angeboten(heim):
_, befehle = heim
befehle.laufend.add("mem0-service.service")
assert "mem" not in {a["id"] for a in aufraeumen.altreste(frisch=True)}
ergebnis = aufraeumen.altrest_loeschen("mem")
assert not ergebnis["ok"] and "läuft gerade" in ergebnis["detail"]
befehle.laufend.clear()
befehle.eingetragen.add("mem0-service.service")
assert "Autostart" in aufraeumen.altrest_loeschen("mem")["detail"]
assert (heim[0] / ".venv-a").exists()
def test_loeschen_nur_ueber_die_kennung(heim):
home, _ = heim
for kennung in ("../../etc", "~/bleibt", str(home / "bleibt"), ""):
ergebnis = aufraeumen.altrest_loeschen(kennung)
assert not ergebnis["ok"] and "kennt die Liste nicht" in ergebnis["detail"]
ergebnis = aufraeumen.altrest_loeschen("zonos")
assert ergebnis == {"ok": True, "text": "TTS-Test gelöscht, 4 KB frei."}
assert not (home / "zonos").exists() and (home / "bleibt" / "wichtig.txt").exists()
assert "zonos" not in {a["id"] for a in aufraeumen.altreste()} # die Liste ist danach frisch
assert "schon weg" in aufraeumen.altrest_loeschen("zonos")["detail"]
def test_nacharbeit_je_art(heim):
home, befehle = heim
assert aufraeumen.altrest_loeschen("wt")["ok"] and not (home / "wt").exists()
assert ["git", "-C", str(aufraeumen.REPO), "worktree", "prune"] in befehle.aufrufe
assert aufraeumen.altrest_loeschen("skript")["ok"]
assert ["systemctl", "--user", "daemon-reload"] not in befehle.aufrufe # nur bei Units
def test_units_werden_neu_eingelesen(heim, monkeypatch):
home, befehle = heim
unit = home / ".config" / "systemd" / "user" / "alt.timer"
unit.parent.mkdir(parents=True)
unit.write_text("[Timer]\n", encoding="utf-8")
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("units", "Alt-Units", ("~/.config/systemd/user/alt.timer",),
"Aus.", art="unit", sperre=("alt.timer",)),))
befehle.eingetragen.add("alt.timer")
assert not aufraeumen.altrest_loeschen("units")["ok"] and unit.exists()
befehle.eingetragen.clear()
assert aufraeumen.altrest_loeschen("units")["ok"] and not unit.exists()
assert befehle.aufrufe[-1] == ["systemctl", "--user", "daemon-reload"]
def test_verknuepfung_nimmt_nie_ihr_ziel_mit(heim, monkeypatch):
home, _ = heim
try:
os.symlink(home / "bleibt", home / "link", target_is_directory=True)
except (OSError, NotImplementedError):
pytest.skip("Verknüpfungen gehen hier nicht (Windows ohne Rechte)")
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("link", "Verknüpfung", ("~/link",), "Test."),))
assert aufraeumen.altrest_loeschen("link")["ok"]
assert not (home / "link").is_symlink() and (home / "bleibt" / "wichtig.txt").exists()
def test_root_ordner_mit_sudo_nur_wo_erlaubt(heim, monkeypatch):
home, befehle = heim
def verweigert(pfad, *args, **kwargs):
raise PermissionError(13, "Keine Berechtigung", str(pfad))
monkeypatch.setattr(aufraeumen.shutil, "rmtree", verweigert)
monkeypatch.setattr(aufraeumen, "ALTRESTE", (Altrest("rocm", "ROCm", ("~/zonos",), "Alt.", sudo=True),
Altrest("ohne", "Ohne sudo", ("~/.venv-a",), "Alt.")))
assert aufraeumen.altrest_loeschen("rocm")["ok"]
assert ["sudo", "-n", "rm", "-rf", "--", str(home / "zonos")] in befehle.aufrufe
ergebnis = aufraeumen.altrest_loeschen("ohne")
assert not ergebnis["ok"] and "Keine Berechtigung" in ergebnis["detail"]
assert sum(1 for a in befehle.aufrufe if a[0] == "sudo") == 1
befehle.sudo_ok = False
assert "sudo -n rm ging nicht" in aufraeumen.altrest_loeschen("rocm")["detail"]
def test_am_pc_wird_nichts_geloescht(heim, monkeypatch):
monkeypatch.setattr(aufraeumen, "AUF_DER_BOX", False)
assert not aufraeumen.altrest_loeschen("zonos")["ok"] and (heim[0] / "zonos").exists()
def test_schnittstelle_nimmt_nur_eine_kennung(heim):
app = FastAPI()
app.include_router(boxwart.router)
c = TestClient(app)
assert c.get("/api/aufraeumen/altreste").json()["altreste"][0]["id"] == "zonos"
assert c.post("/api/aufraeumen/altreste/loeschen", json={"pfad": "/"}).status_code == 422
r = c.post("/api/aufraeumen/altreste/loeschen", json={"id": "../bleibt"})
assert r.status_code == 409 and "kennt die Liste nicht" in r.json()["detail"]
assert c.post("/api/aufraeumen/altreste/loeschen", json={"id": "skript"}).json()["ok"] is True
# --- Die echte Liste ----------------------------------------------------------------------------------
GESCHUETZT = ("~", "~/.hermes", "~/.hermes/hermes-agent", "~/.hermes/hermes-agent/hermes_cli/web_dist",
"~/.hermes/config.yaml", "~/.hermes/.env", "~/.hermes/memories", "~/.hermes/skills",
"~/.hermes/kanban.db", "~/.hermes/scripts/news-melden.sh", "~/.hermes/scripts/sonntags-update.sh",
"~/.hermes/scripts/stack-radar.sh", "~/.hermes/plugins/mc2-web-lesen", "~/mission-control-v2",
"~/mission-control-v2/frontend/dist/index.html", "~/.config/systemd/user",
"~/.config/systemd/user/mc2-radar.timer", "~/.config/systemd/user/lucy-stimme.service",
"~/.lucy-stimme", "~/.voice", "~/.cache", "~/.opencode", "~/projekte", "~/.local/bin",
"/srv/models", "/opt", "/opt/llamacpp-vulkan", "/opt/llamacpp-vulkan.bak")
def test_feste_liste_ist_eng():
kennungen = [a.id for a in aufraeumen.ALTRESTE]
assert len(kennungen) == len(set(kennungen))
for a in aufraeumen.ALTRESTE:
assert a.art in ("ordner", "worktree", "unit") and a.hinweis and a.name
for pfad in a.pfade:
assert pfad.startswith("~/") or pfad == "/opt/llamacpp", pfad
assert not any(z in pfad for z in ("*", "?", "..", "//")) and not pfad.endswith("/"), pfad
for heilig in GESCHUETZT:
assert pfad != heilig and not heilig.startswith(pfad + "/"), (pfad, heilig)
assert a.sudo == (a.pfade == ("/opt/llamacpp",)) # sudo nur für den ROCm-Ordner unter /opt
def test_feste_liste_enthaelt_die_bekannten_reste():
alle = {p for a in aufraeumen.ALTRESTE for p in a.pfade}
for pfad in ("~/zonos2-test", "/opt/llamacpp", "~/.mem0", "~/.hermes/kanban", "~/.venv-audit",
"~/.hermes/worktrees/orch-graph-verknuepfer", "~/projekte/mc2-referenz",
"~/.hermes/plugins/mc2-memory", "~/.hermes/PINNED_VERSION",
"~/mission-control-v2/frontend/dist/avatar.vrm"):
assert pfad in alle, pfad
# Was noch jemand nutzt, steht nicht darin (Prüfung vom 24.09.2026).
assert not alle & {"~/.hermes/kanban.db", "~/.voice", "~/.cache", "~/.opencode",
"~/.hermes/scripts/ampel-waechter.sh", "~/.hermes/scripts/pc_path_lookup.sh"}
assert "~/.hermes/scripts/curator-idle-watchdog.sh" not in alle # steht in der crontab