- 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>
181 lines
8.3 KiB
Python
181 lines
8.3 KiB
Python
"""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 <Datenordner>/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)
|