Files
mission-control-v2/backend/tests/test_homelab_pflege.py
T
HitonabiandClaude Opus 5.5 f2733f44fe homelab: Wartezeit nach Skriptaenderung, PBS mit Sicherung statt Snapshot, woechentliches Suchen
- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf,
  updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die
  Rueckfrage sagt es.
- Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen
  Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher
  Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht;
  Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit
  host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden
  beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM.
- updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen
  weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet).
- pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen),
  einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es
  zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py.
- kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt.
- inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der
  Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen).

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

168 lines
8.2 KiB
Python

"""Wöchentliches Suchen (services/homelab/pflege.py, 24.09.2026): Der Homelab-Teil erneuert alte Paketlisten in den
Gästen selbst — nachts, einmal je Gast und Tag, nie neben einem Update. Mit dem echten Bericht vom 24.09.
(tests/fixtures/pve-bericht.json): AdGuard, NPMplus, NetBird, PVE Scripts Local und Gitea haben alte Listen, die des
PBS sind frisch."""
import json
import os
import subprocess
import sys
import time
from collections import Counter
from datetime import datetime
from pathlib import Path
import pytest
from kern import einstellungen as einstellungen_mod
from kern.zeit import LOCAL_TZ
from services import waechter
from services.homelab import kanal, pflege, updates
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
BACKEND = Path(__file__).resolve().parents[1]
NACHT = datetime(2026, 9, 25, 3, 0, tzinfo=LOCAL_TZ).timestamp()
TAG = datetime(2026, 9, 25, 14, 0, tzinfo=LOCAL_TZ).timestamp()
NAECHSTE_NACHT = datetime(2026, 9, 26, 3, 0, tzinfo=LOCAL_TZ).timestamp()
ALTE_LISTEN = {100, 101, 102, 103, 104}
@pytest.fixture
def uhr(tmp_path, monkeypatch):
"""Daten in tmp; der Ausführer hat zuletzt um uhr["zuletzt"] berichtet."""
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
stand = {"zuletzt": NACHT}
monkeypatch.setattr(kanal, "zuletzt", lambda: stand["zuletzt"])
monkeypatch.setattr(waechter, "TROCKEN", False)
kanal.bericht_speichern(BERICHT)
yield stand
einstellungen_mod.einstellungen.cache_clear()
def _suchen() -> Counter:
"""Wie viele Aufträge „suchen“ es je Gast gibt."""
return Counter(a["parameter"]["vmid"] for a in kanal.liste() if a["aktion"] == "suchen")
def _ausfuehrer_antwortet(scheitert: set[int] = frozenset()) -> None:
while (a := kanal.naechster()):
vmid = a["parameter"].get("vmid")
if vmid in scheitert:
kanal.ergebnis(a["id"], 100, "Hit:1 http://deb.debian.org/debian trixie InRelease\n"
"E: Failed to fetch http://security.debian.org Temporary failure resolving")
else:
kanal.ergebnis(a["id"], 0, "Hit:1 http://deb.debian.org/debian trixie InRelease")
def test_nachts_einmal_je_gast_und_tag(uhr):
assert pflege.pruefe_paketlisten(jetzt=NACHT) == []
assert set(_suchen()) == ALTE_LISTEN # PBS: frische Listen; die Arcane-VM ist kein Container
pflege.pruefe_paketlisten(jetzt=NACHT + 60) # nächster Takt: offen, nichts doppelt
_ausfuehrer_antwortet()
pflege.pruefe_paketlisten(jetzt=NACHT + 120) # erledigt, Listen im Bericht noch alt: heute nicht mehr
assert _suchen() == Counter({v: 1 for v in ALTE_LISTEN})
zustand = pflege.zustand()
assert zustand["nacht"] == "2026-09-25"
assert zustand["gaeste"]["100"]["name"] == "AdGuard Home" and zustand["gaeste"]["100"]["fehlschlaege"] == 0
uhr["zuletzt"] = NAECHSTE_NACHT # am nächsten Tag wieder, falls die Listen alt bleiben
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT)
assert _suchen() == Counter({v: 2 for v in ALTE_LISTEN})
def test_tagsueber_nur_wenn_die_nacht_verpasst_wurde(uhr):
uhr["zuletzt"] = TAG
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {}}) # die Instanz war letzte Nacht da
pflege.pruefe_paketlisten(jetzt=TAG)
assert _suchen() == Counter() # also bis zur nächsten Nacht warten
pflege._schreiben({"nacht": "2026-09-24", "gaeste": {}}) # letzte Nacht war sie aus
pflege.pruefe_paketlisten(jetzt=TAG)
assert set(_suchen()) == ALTE_LISTEN # dann eben jetzt
assert pflege.zustand()["nacht"] == "2026-09-24" # tagsüber gilt keine Nacht als gesehen
def test_vor_zwei_uhr_zaehlt_die_nacht_von_gestern(uhr):
kurz_vor_zwei = datetime(2026, 9, 26, 1, 30, tzinfo=LOCAL_TZ).timestamp()
uhr["zuletzt"] = kurz_vor_zwei
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {}})
pflege.pruefe_paketlisten(jetzt=kurz_vor_zwei)
assert _suchen() == Counter()
def test_nie_neben_einem_update_oder_einem_offenen_auftrag(uhr):
updates._merken({"id": "l1", "ziel": "ct-104", "baustein": "app", "name": "Gitea", "status": "laeuft",
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": []})
kanal.anlegen("os_update", {"vmid": 100})
pflege.pruefe_paketlisten(jetzt=NACHT)
assert set(_suchen()) == ALTE_LISTEN - {100, 104}
def test_ohne_ausfuehrer_keine_auftraege(uhr):
uhr["zuletzt"] = NACHT - 3600 # letzter Bericht vor einer Stunde
pflege.pruefe_paketlisten(jetzt=NACHT)
assert _suchen() == Counter() and pflege.zustand().get("nacht") is None
def test_ausfuehrer_im_nur_lesen_modus_bekommt_keine_auftraege(uhr):
nur_lesen = json.loads(json.dumps(BERICHT))
nur_lesen["host"]["nur_lesen"] = True
kanal.bericht_speichern(nur_lesen)
pflege.pruefe_paketlisten(jetzt=NACHT)
assert _suchen() == Counter() and pflege.zustand().get("nacht") is None
def test_trockenlauf_legt_nichts_an(uhr, monkeypatch):
monkeypatch.setattr(waechter, "TROCKEN", True)
pflege.pruefe_paketlisten(jetzt=NACHT)
assert _suchen() == Counter()
def test_wiederholtes_scheitern_wird_ein_gelber_hinweis(uhr):
pflege.pruefe_paketlisten(jetzt=NACHT)
_ausfuehrer_antwortet(scheitert={100})
assert pflege.pruefe_paketlisten(jetzt=NACHT + 60) == [] # einmal ist noch kein Muster
uhr["zuletzt"] = NAECHSTE_NACHT
pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT) # neuer Tag, neuer Versuch
_ausfuehrer_antwortet(scheitert={100})
befunde = pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 60)
assert [(b.id, b.stufe, b.titel, b.sofort) for b in befunde] == [
("pflege:ct-100", "gelb", "AdGuard Home: Die Suche nach Updates scheitert", True)]
assert "2-mal hintereinander" in befunde[0].text and "E: Failed to fetch" in befunde[0].text
# Listen wieder frisch (etwa von Hand gesucht): Hinweis weg, Zähler vergessen.
frisch = json.loads(json.dumps(BERICHT))
next(g for g in frisch["gaeste"] if g["vmid"] == 100)["os_updates"]["listen_stand"] = int(NAECHSTE_NACHT)
kanal.bericht_speichern(frisch)
assert pflege.pruefe_paketlisten(jetzt=NAECHSTE_NACHT + 120) == []
assert pflege.zustand()["gaeste"]["100"]["fehlschlaege"] == 0
def test_der_waechter_zeigt_den_hinweis_ohne_telegram(uhr, tmp_path, monkeypatch):
pflege._schreiben({"nacht": "2026-09-25", "gaeste": {"100": {
"name": "AdGuard Home", "tag": "2026-09-25", "auftrag": None, "fehlschlaege": 3, "fehler": "E: kaputt"}}})
uhr["zuletzt"] = TAG
monkeypatch.setattr(waechter, "STORE_PATH", tmp_path / "mc2-waechter.json")
monkeypatch.setattr(waechter, "_stand", {"hinweise": {}, "kandidaten": {}, "verlauf": [], "auto": {}, "stand": 0.0})
monkeypatch.setattr(waechter, "_update_laeuft", lambda: False)
meldungen: list[str] = []
monkeypatch.setattr(waechter, "_telegram", lambda betreff, text: meldungen.append(text))
def pruefe_paketlisten():
return pflege.pruefe_paketlisten(jetzt=TAG)
monkeypatch.setattr(waechter, "PRUEFUNGEN", (pruefe_paketlisten,))
waechter.takt()
hinweis = waechter._stand["hinweise"]["pflege:ct-100"]
assert hinweis["stufe"] == "gelb" and "3-mal" in hinweis["text"] and meldungen == []
@pytest.mark.skipif(os.name != "posix", reason="Die Dateisperre gibt es nur unter Linux (am PC: nur Thread-Sperre).")
def test_auftraege_aus_zwei_prozessen_gehen_nicht_verloren(tmp_path):
"""Oberfläche und Steward schreiben beide in die Auftragsliste; ohne Dateisperre überschrieben sie sich."""
code = ("from services.homelab import kanal\n"
"for i in range(40):\n"
" kanal.anlegen('suchen', {'vmid': 100 + i})\n")
env = {**os.environ, "MC_DATEN_DIR": str(tmp_path), "MC_ROLLE": "homelab"}
prozesse = [subprocess.Popen([sys.executable, "-c", code], cwd=BACKEND, env=env) for _ in range(3)]
assert all(p.wait(timeout=120) == 0 for p in prozesse)
auftraege = json.loads((tmp_path / "ausfuehrer-auftraege.json").read_text(encoding="utf-8"))
assert len(auftraege) == 120