"""Pflege im Hintergrund: wöchentlich nach Updates suchen (24.09.2026). Die Übersicht ist nur so ehrlich wie die Paketlisten in den Gästen, und die erneuert dort niemand von selbst (AdGuard stand im September 2026 auf Listen vom Oktober 2025). Deshalb stößt der Homelab-Teil den Auftrag „suchen“ (apt-get update bzw. apk update im Gast, ändert keine Pakete) selbst an, sobald die Listen eines Gasts älter als sieben Tage sind: • nur freigegebene, laufende Container — der Ausführer prüft das Etikett ohnehin selbst noch einmal • höchstens ein Auftrag je Gast und Tag; nie während eines Update-Laufs für diesen Gast und nie, solange für ihn ein anderer Auftrag offen ist; nur, wenn der Ausführer gerade berichtet • bevorzugt nachts 02:00–05:00 (Berlin); war die Instanz oder der Ausführer in der letzten Nacht nicht da, eben gleich • keine Meldungen. Scheitert die Suche für einen Gast FEHLSCHLAEGE_HINWEIS-mal hintereinander, wird daraus ein gelber Hinweis des Wächters (gelb geht nicht an Telegram). Der Steward der Rolle homelab ruft pruefe_paketlisten() in jedem Wächter-Takt auf (services/waechter.py, PRUEFUNGEN). Zustand in /homelab-pflege.json; den schreibt nur der Steward. Die Aufträge selbst landen im Kanal (kanal.py, unter Dateisperre, weil auch die Oberfläche dort schreibt). """ import json import logging import threading import time from datetime import datetime, timedelta from pathlib import Path from kern.einstellungen import einstellungen from kern.zeit import LOCAL_TZ from services.homelab import apps, inventar, kanal, updates log = logging.getLogger(__name__) LISTEN_ALT_S = 7 * 24 * 3600 NACHT_VON, NACHT_BIS = 2, 5 # volle Stunden, Europe/Berlin FEHLSCHLAEGE_HINWEIS = 2 _lock = threading.Lock() def _pfad() -> Path: return einstellungen().daten_dir / "homelab-pflege.json" def zustand() -> dict: """{"nacht": Datum der letzten Nacht, in der gesucht werden konnte, "gaeste": {vmid: {…}}}""" try: daten = json.loads(_pfad().read_text(encoding="utf-8")) except (OSError, ValueError): daten = {} if not isinstance(daten, dict): daten = {} if not isinstance(daten.get("gaeste"), dict): daten["gaeste"] = {} return daten def _schreiben(daten: dict) -> None: _pfad().parent.mkdir(parents=True, exist_ok=True) tmp = _pfad().with_suffix(".tmp") tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8") tmp.replace(_pfad()) def faellig(gaeste: list[dict], jetzt: float) -> list[dict]: """Die Container, deren Paketlisten eine neue Suche brauchen: freigegeben, laufend, Listen älter als 7 Tage.""" ergebnis = [] for g in gaeste: listen = (g.get("os_updates") or {}).get("listen_stand") if (g.get("art") == "lxc" and g.get("status") == "running" and g.get("erlaubt") and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or []) and listen and jetzt - listen > LISTEN_ALT_S): ergebnis.append(g) return ergebnis def _fehlerzeile(text: str | None) -> str: """Die aussagekräftigste Zeile einer gescheiterten Suche (apt meldet Fehler mit „E:“).""" zeilen = [z.strip() for z in str(text or "").splitlines() if z.strip()] wichtig = [z for z in zeilen if z.startswith(("E:", "ERROR", "Abgelehnt", "Zeitlimit", "Fehler"))] return (wichtig or zeilen or ["ohne Angabe"])[-1][:200] def _einsammeln(daten: dict) -> bool: """Ergebnisse der eigenen Aufträge übernehmen: fertig → Zähler auf null, gescheitert → eins mehr.""" geaendert = False for eintrag in daten["gaeste"].values(): aid = eintrag.get("auftrag") if not aid: continue a = kanal.auftrag(aid) if a is not None and a.get("status") in ("wartet", "laeuft"): continue if a is not None and a.get("status") == "fertig": eintrag["fehlschlaege"], eintrag["fehler"] = 0, None elif a is not None: # fehler, verloren eintrag["fehlschlaege"] = int(eintrag.get("fehlschlaege") or 0) + 1 eintrag["fehler"] = _fehlerzeile(a.get("text")) eintrag["auftrag"] = None # nicht mehr in der Liste: vergessen, nicht zählen geaendert = True return geaendert def _offene_gaeste() -> set: """Gäste mit einem offenen Auftrag gleich welcher Art (auch von Hand oder aus einem Update-Lauf).""" return {(a.get("parameter") or {}).get("vmid") for a in kanal.liste() if a.get("status") in ("wartet", "laeuft")} def _befunde(daten: dict, faellige: list[dict]) -> list: """Gelbe Hinweise für Gäste, deren Listen weiter alt sind und deren Suche wiederholt scheiterte.""" from services.waechter import Befund # der Wächter registriert diese Prüfung, also erst hier befunde = [] for g in faellige: eintrag = daten["gaeste"].get(str(g["vmid"])) or {} anzahl = int(eintrag.get("fehlschlaege") or 0) if anzahl < FEHLSCHLAEGE_HINWEIS: continue name = eintrag.get("name") or str(g.get("name") or g["vmid"]) befunde.append(Befund( id=f"pflege:ct-{g['vmid']}", stufe="gelb", titel=f"{name}: Die Suche nach Updates scheitert", text=(f"Die Paketlisten ließen sich {anzahl}-mal hintereinander nicht erneuern (wöchentliche Suche). " f"Zuletzt: {eintrag.get('fehler') or 'ohne Angabe'}"), quelle=f"ct-{g['vmid']}", sofort=True)) return befunde def pruefe_paketlisten(jetzt: float | None = None) -> list: """Im Wächter-Takt: Ergebnisse einsammeln, fällige Suchen anstoßen. Rückgabe: Befunde für den Wächter — nur, wenn die Suche für einen Gast wiederholt scheitert (gelb).""" from services import waechter jetzt = time.time() if jetzt is None else jetzt lokal = datetime.fromtimestamp(jetzt, LOCAL_TZ) heute = lokal.date().isoformat() nachts = NACHT_VON <= lokal.hour < NACHT_BIS # Die zuletzt begonnene Nacht: vor 02:00 die von gestern. letzte_nacht = (lokal.date() - timedelta(days=0 if lokal.hour >= NACHT_VON else 1)).isoformat() eingang = kanal.bericht() bericht = (eingang or {}).get("bericht") or {} gaeste = bericht.get("gaeste") or [] zuletzt = kanal.zuletzt() # Aufträge nur, wenn der Ausführer gerade berichtet und nicht im Nur-Lesen-Modus läuft (dann lehnte er ab). verbunden = bool(eingang and zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S and not (bericht.get("host") or {}).get("nur_lesen")) faellige = faellig(gaeste, jetzt) with _lock: daten = zustand() geaendert = _einsammeln(daten) faellig_ids = {str(g["vmid"]) for g in faellige} for vmid, eintrag in daten["gaeste"].items(): # Listen wieder frisch (auch von Hand gesucht) oder Gast nicht mehr dabei: Zähler vergessen. if gaeste and eintrag.get("fehlschlaege") and vmid not in faellig_ids: eintrag["fehlschlaege"], eintrag["fehler"] = 0, None geaendert = True if verbunden and nachts and daten.get("nacht") != heute: daten["nacht"] = heute geaendert = True # Nachts sowieso; tagsüber nur, wenn die letzte Nacht verpasst wurde (Instanz oder Ausführer war weg). if verbunden and (nachts or daten.get("nacht") != letzte_nacht): offen = _offene_gaeste() im_update = updates.laufende_ziele() for g in faellige: vmid = g["vmid"] eintrag = daten["gaeste"].setdefault(str(vmid), {}) if eintrag.get("tag") == heute or eintrag.get("auftrag") or vmid in offen \ or f"ct-{vmid}" in im_update: continue app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name")) eintrag["name"] = app.name if app else str(g.get("name") or vmid) if waechter.TROCKEN: log.info("pflege (trocken): würde die Paketlisten von %s erneuern", eintrag["name"]) continue eintrag.update(tag=heute, auftrag=kanal.anlegen("suchen", {"vmid": vmid}), angestossen=jetzt) geaendert = True log.info("pflege: Paketlisten von %s sind alt → suchen", eintrag["name"]) if geaendert: _schreiben(daten) return _befunde(daten, faellige)