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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
f2733f44fe
@@ -0,0 +1,180 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user