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>
198 lines
9.9 KiB
Python
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
|