Merge branch 'worktree-agent-ab032f7900d8a5a63' into wartung/snapshots-reiter
This commit is contained in:
@@ -6,7 +6,8 @@
|
||||
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
|
||||
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
|
||||
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
|
||||
GET /api/homelab/laeufe Die letzten Update-Läufe mit ihren Schritten
|
||||
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
|
||||
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
|
||||
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
|
||||
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
|
||||
GET /api/homelab/ausfuehrer/auftrag ┐
|
||||
@@ -146,6 +147,44 @@ def speicher_lage() -> dict:
|
||||
return speicher.lage()
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen verwalten (Seite Homelab → Snapshots, seit 25.09.2026) ------------------------------
|
||||
# GET /api/homelab/snapshots je Gerät Snapshots, Sicherungen, ob gerade etwas läuft
|
||||
# POST /api/homelab/snapshots/{id}/anlegen Snapshot jetzt anlegen (freigegeben, Snapshot möglich)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/loeschen nur Snapshots des Orchestrators (mc2-…)
|
||||
# POST /api/homelab/snapshots/{id}/{name}/zuruecksetzen ebenso; danach Prüfung wie nach einem Update
|
||||
# POST /api/homelab/sicherungen/{id}/{datei}/loeschen nur Sicherungen des Orchestrators (Bericht)
|
||||
# Jede Aktion ist ein Lauf wie „Jetzt updaten“; was nicht geht, beantworten sie mit 409 wie die Update-Knöpfe.
|
||||
|
||||
@router.get("/snapshots")
|
||||
def snapshots_liste() -> dict:
|
||||
from services.homelab import snapshots
|
||||
return snapshots.liste()
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/anlegen")
|
||||
def snapshot_anlegen(ziel_id: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.anlegen(ziel_id))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/loeschen")
|
||||
def snapshot_loeschen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.loeschen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/snapshots/{ziel_id}/{name}/zuruecksetzen")
|
||||
def snapshot_zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.zuruecksetzen(ziel_id, name))
|
||||
|
||||
|
||||
@router.post("/sicherungen/{ziel_id}/{datei}/loeschen")
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
from services.homelab import snapshots
|
||||
return _antwort(snapshots.sicherung_loeschen(ziel_id, datei))
|
||||
|
||||
|
||||
@router.get("/einstellungen")
|
||||
def einstellungen_stand() -> dict:
|
||||
from services.homelab import einstellungen as homelab_einstellungen
|
||||
|
||||
@@ -5,6 +5,8 @@ Quellen, alle nur gelesen:
|
||||
Berichte nur auf Wunsch (berichte=1): die Aufträge „bericht“ der Update-Läufe und die Berichte, die
|
||||
der Ausführer von sich aus schickt (alle zehn Minuten; kanal.py vermerkt ihren Eingang).
|
||||
lauf „Jetzt updaten“-Läufe mit ihren Schritten (updates.py)
|
||||
snapshot Aktionen der Seite Snapshots (snapshots.py, seit 25.09.2026): anlegen, löschen, zurücksetzen, Sicherung
|
||||
löschen — Läufe wie die Updates, mit eigenen Titeln
|
||||
sammellauf „Alle aktualisieren“ (sammellauf.py)
|
||||
hinweis Hinweise des Wächters: erschienen, erledigt, Selbstreparatur (der Verlauf in mc2-waechter.json;
|
||||
ein Hinweis, der dort schon herausgefallen ist, erscheint mit seinem Beginn)
|
||||
@@ -28,7 +30,7 @@ from kern.einstellungen import einstellungen
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.einstellungen import melde_log
|
||||
from services.homelab import apps, arcane, kanal, pflege, sammellauf, updates
|
||||
from services.homelab import apps, arcane, kanal, pflege, sammellauf, snapshots, updates
|
||||
|
||||
GRENZE_MAX = 1000
|
||||
DETAILS_MAX = 4000
|
||||
@@ -52,12 +54,13 @@ _AKTION = {
|
||||
"Paketlisten erneuern"),
|
||||
"zurueck": ("Auf den Snapshot zurückgesetzt", "Zurücksetzen gescheitert", "Wird auf den Snapshot zurückgesetzt",
|
||||
"Zurücksetzen"),
|
||||
"snapshot_loeschen": ("Alter Snapshot gelöscht", "Snapshot löschen gescheitert", "Alter Snapshot wird gelöscht",
|
||||
# Seit 25.09.2026 nicht mehr „Alter …“: Gelöscht wird auch auf der Seite Snapshots, nicht nur nach einem Update.
|
||||
"snapshot_loeschen": ("Snapshot gelöscht", "Snapshot löschen gescheitert", "Snapshot wird gelöscht",
|
||||
"Snapshot löschen"),
|
||||
"sichern": ("Sicherung angelegt", "Sicherung gescheitert", "Sicherung läuft", "Sicherung"),
|
||||
"sicherung_zurueck": ("Sicherung zurückgespielt", "Zurückspielen gescheitert", "Sicherung wird zurückgespielt",
|
||||
"Zurückspielen"),
|
||||
"sicherung_loeschen": ("Alte Sicherung gelöscht", "Sicherung löschen gescheitert", "Alte Sicherung wird gelöscht",
|
||||
"sicherung_loeschen": ("Sicherung gelöscht", "Sicherung löschen gescheitert", "Sicherung wird gelöscht",
|
||||
"Sicherung löschen"),
|
||||
"host_update": ("Host-Pakete eingespielt", "Host-Pakete gescheitert", "Host-Pakete werden eingespielt",
|
||||
"Host-Pakete"),
|
||||
@@ -287,7 +290,46 @@ def _anzahl_pakete(n: object, wort: str) -> str:
|
||||
return f"{n} {wort}{'' if n == 1 else 'e'} eingespielt" if isinstance(n, int) and n > 0 else f"{wort}e eingespielt"
|
||||
|
||||
|
||||
# Läufe der Seite Snapshots: (Baustein, Ergebnis) → (Titel, Stufe). Gescheitert heißt dort meist: Am Gerät hat sich
|
||||
# nichts geändert (warn) — nur ein gescheitertes Zurücksetzen ist rot.
|
||||
_SNAPSHOT_TITEL = {
|
||||
("snapshot-anlegen", "angelegt"): ("Snapshot angelegt", "ok"),
|
||||
("snapshot-anlegen", "fehler"): ("Snapshot anlegen gescheitert", "warn"),
|
||||
("snapshot-loeschen", "geloescht"): ("Snapshot gelöscht", "ok"),
|
||||
("snapshot-loeschen", "fehler"): ("Snapshot löschen gescheitert", "warn"),
|
||||
("snapshot-zurueck", "zurueckgesetzt"): ("Zurückgesetzt, Prüfung grün", "ok"),
|
||||
("snapshot-zurueck", "fehler"): ("Zurücksetzen gescheitert", "fehler"),
|
||||
("sicherung-loeschen", "geloescht"): ("Sicherung gelöscht", "ok"),
|
||||
("sicherung-loeschen", "fehler"): ("Sicherung löschen gescheitert", "warn"),
|
||||
}
|
||||
|
||||
|
||||
def _snapshot_lauf(x: dict, schw: Callable) -> dict | None:
|
||||
"""Eine Aktion der Seite Snapshots (snapshots.py): eigene Art „snapshot“, eigene Titel."""
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, status, text = str(x.get("baustein")), x.get("status"), x.get("text")
|
||||
was = snapshots.WAS.get(baustein, "Snapshot-Aktion")
|
||||
if status == "laeuft":
|
||||
titel, stufe = f"{snapshots.LAEUFT.get(baustein, was)} · {name}", "info"
|
||||
elif status == "unterbrochen":
|
||||
titel, stufe = f"{was} unterbrochen · {name}", "fehler"
|
||||
text = text or ("Der Homelab-Teil wurde währenddessen neu gestartet; was am Gerät geschah, zeigt der nächste "
|
||||
"Bericht.")
|
||||
elif baustein == "snapshot-zurueck" and x.get("ergebnis") == "fehler" and x.get("zurueckgesetzt"):
|
||||
titel, stufe = f"Zurückgesetzt, Prüfung rot · {name}", "fehler"
|
||||
else:
|
||||
wort, stufe = _SNAPSHOT_TITEL.get((baustein, str(x.get("ergebnis"))), (f"{was} beendet", "info"))
|
||||
titel = f"{wort} · {name}"
|
||||
zeilen = [str(s) for s in x.get("schritte") or []]
|
||||
if status == "fertig" and x.get("gemeldet") is False:
|
||||
zeilen.append("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||
return _eintrag(x.get("ende") or x.get("start"), "snapshot", titel, text, stufe, x.get("ziel"),
|
||||
"\n".join(zeilen) or None, schw)
|
||||
|
||||
|
||||
def _lauf(x: dict, schw: Callable) -> dict | None:
|
||||
if str(x.get("baustein")) in snapshots.LAEUFT:
|
||||
return _snapshot_lauf(x, schw)
|
||||
name = str(x.get("name") or x.get("ziel") or "?")
|
||||
baustein, ergebnis, status, text = x.get("baustein"), x.get("ergebnis"), x.get("status"), x.get("text")
|
||||
was = _LAUF_WAS.get(str(baustein), "Update")
|
||||
@@ -431,6 +473,9 @@ def _betreffe(laeufe: list[dict], sammellaeufe: list[dict]) -> dict[str, str]:
|
||||
bekannt = {}
|
||||
for x in laeufe:
|
||||
if x.get("text") and x.get("gemeldet") is not False:
|
||||
if x.get("betreff"): # die Läufe der Seite Snapshots merken sich ihren Betreff selbst
|
||||
bekannt[x["text"]] = x["betreff"]
|
||||
continue
|
||||
dringend = x.get("dringend")
|
||||
if dringend is None: # Läufe von vor dem 24.09.2026 abends: wie updates._ende es entschied
|
||||
dringend = x.get("ergebnis") in ("zurueckgerollt", "fehler") and "nicht begonnen" not in x["text"]
|
||||
|
||||
@@ -0,0 +1,486 @@
|
||||
"""Snapshots und Sicherungen verwalten — die Seite Homelab → Snapshots (seit 25.09.2026, User-Wunsch).
|
||||
|
||||
Liste (liste): je Gerät aus dem Bericht des Ausführers die Snapshots mit Zeitpunkt, Beschreibung und Herkunft, dazu die
|
||||
Sicherungen des Orchestrators (vzdump-Archive, heute nur beim PBS, der keinen Snapshot kann) und ob für das Gerät
|
||||
gerade ein Lauf läuft. Ein Ausführer vor dem 25.09.2026 liefert nur die Namen (snapshots, sicherungen): Dann kommt der
|
||||
Zeitpunkt aus dem Namen (mc2-JJJJMMTT-HHMMSS bzw. …-JJJJ_MM_TT-HH_MM_SS, Ortszeit des Hosts), von Hand angelegte
|
||||
Snapshots stehen ohne Zeit da. Wie viel ein Snapshot belegt, sagt Proxmox bei LVM-Thin nicht — hier steht deshalb keine
|
||||
Zahl, die Seite sagt es so.
|
||||
|
||||
Herkunft eines Snapshots:
|
||||
update Rückweg vor einem Update (Name mc2-…)
|
||||
knopf „Snapshot anlegen“ auf dieser Seite (Name mc2-…; der Ausführer schreibt „Auf Knopfdruck …“ in die
|
||||
Beschreibung, und der Lauf hier merkt sich den Namen). Solche räumt kein grünes Update weg
|
||||
(updates._aufraeumen fragt knopf_namen) — die löscht nur der User.
|
||||
proxmox von Hand in Proxmox angelegt: nur zur Ansicht, nie gelöscht oder zurückgesetzt (der Ausführer lehnt das
|
||||
ohnehin ab, er fasst nur mc2-… an).
|
||||
|
||||
Die Aktionen laufen als Läufe wie „Jetzt updaten“ (Mechanik aus updates.py, bewusst mitbenutzt): in
|
||||
homelab-laeufe.json, je Gerät höchstens einer, nie während „Alle aktualisieren“ (SAMMELLAUF_SPERRE) oder eines Updates
|
||||
irgendwo (_sperre: der Ausführer arbeitet einen Auftrag nach dem anderen ab) und nur, wenn der Ausführer verbunden ist.
|
||||
So stehen sie im Protokoll, der Wächter lässt ein Gerät mitten im Zurücksetzen in Ruhe, und nichts kommt einem Update
|
||||
in die Quere. Neue Befehle braucht der Ausführer nicht:
|
||||
snapshot-anlegen snapshot (Anlass „knopf“) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-loeschen snapshot_loeschen (nur mc2-…) → frischer Bericht. Eine Meldung nur, wenn es scheitert.
|
||||
snapshot-zurueck zurueck (nur mc2-…; der Ausführer stoppt, setzt zurück, startet) → 20 s warten → frischer Bericht
|
||||
→ Prüfung wie nach einem Update (läuft, Weboberfläche antwortet). Immer mit Meldung, dringend, wenn
|
||||
es scheitert oder die Prüfung rot ist.
|
||||
sicherung-loeschen sicherung_loeschen (nur Sicherungen des Orchestrators) → frischer Bericht. Meldung nur bei Fehler.
|
||||
Im Update-Verlauf stehen sie nicht: Das sind keine Updates.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, inventar, kanal, sammellauf, updates
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Wie SNAPSHOT_NAME im Ausführer: Nur solche Snapshots fasst er an. Der Zeitpunkt im Namen ist die Ortszeit des
|
||||
# Proxmox-Hosts (Europe/Berlin, wie LOCAL_TZ).
|
||||
SNAPSHOT_NAME = re.compile(r"^mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$")
|
||||
ARCHIV_ZEIT = re.compile(r"-(\d{4})_(\d{2})_(\d{2})-(\d{2})_(\d{2})_(\d{2})\.")
|
||||
KNOPF_BESCHREIBUNG = "Auf Knopfdruck" # so beginnt die Beschreibung, die der Ausführer beim Anlass „knopf“ setzt
|
||||
# Die Läufe dieser Seite: was gerade geschieht (für „Läuft: …“) und wie die Aktion heißt.
|
||||
LAEUFT = {"snapshot-anlegen": "Snapshot wird angelegt", "snapshot-loeschen": "Snapshot wird gelöscht",
|
||||
"snapshot-zurueck": "Wird zurückgesetzt", "sicherung-loeschen": "Sicherung wird gelöscht"}
|
||||
WAS = {"snapshot-anlegen": "Snapshot anlegen", "snapshot-loeschen": "Snapshot löschen",
|
||||
"snapshot-zurueck": "Zurücksetzen", "sicherung-loeschen": "Sicherung löschen"}
|
||||
GUT = {"angelegt", "geloescht", "zurueckgesetzt"}
|
||||
ZULETZT_S = 6 * 3600 # so lange steht das Ergebnis der letzten Aktion beim Gerät
|
||||
BETREFF = "[Homelab-Snapshot]"
|
||||
BETREFF_ALARM = "[Alarm] Homelab-Snapshot"
|
||||
|
||||
|
||||
# --- Kleine Helfer -------------------------------------------------------------------------------------------------
|
||||
|
||||
def _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _wo(g: dict) -> str:
|
||||
return f"{'Container' if g.get('art') == 'lxc' else 'VM'} {g['vmid']}"
|
||||
|
||||
|
||||
def _name(g: dict) -> str:
|
||||
"""Der Name wie in der Update-Liste (Gitea, Arcane (Docker) …)."""
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or _zid(g))
|
||||
|
||||
|
||||
def _ortszeit(m: re.Match | None) -> float | None:
|
||||
if not m:
|
||||
return None
|
||||
try:
|
||||
return datetime(*(int(x) for x in m.groups()), tzinfo=LOCAL_TZ).timestamp()
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _wann(ts: float | None) -> str:
|
||||
"""„vom 25.09.2026 11:26“ — leer, wo der Zeitpunkt fehlt."""
|
||||
return f"vom {datetime.fromtimestamp(ts, LOCAL_TZ):%d.%m.%Y %H:%M}" if ts else ""
|
||||
|
||||
|
||||
def _groesse(n: int | None) -> str | None:
|
||||
if not n:
|
||||
return None
|
||||
return f"{n / 1e6:.0f} MB" if n < 1e9 else f"{n / 1e9:.1f} GB".replace(".", ",")
|
||||
|
||||
|
||||
def _aktion(pfad: str, frage: str, label: str) -> dict:
|
||||
"""Ein Knopf wie im Ziel-Modell (kern/ziele.py: Aktion): Methode, Pfad, Rückfrage, Beschriftung."""
|
||||
return {"methode": "POST", "pfad": pfad, "frage": frage, "label": label}
|
||||
|
||||
|
||||
def _grund(exc: Exception) -> str:
|
||||
return str(exc).rstrip(".")
|
||||
|
||||
|
||||
# --- Snapshots und Sicherungen eines Geräts ---------------------------------------------------------------------------
|
||||
|
||||
def _knopf(laeufe: list[dict]) -> set[tuple[str, str]]:
|
||||
"""(Gerät, Snapshot) aus den Läufen „Snapshot anlegen“ — so erkennt die Seite ihre Snapshots auch mit einem
|
||||
Ausführer, der die Beschreibung „Auf Knopfdruck …“ noch nicht schreibt."""
|
||||
return {(str(x.get("ziel")), str(x["snapshot"])) for x in laeufe
|
||||
if x.get("baustein") == "snapshot-anlegen" and x.get("snapshot")}
|
||||
|
||||
|
||||
def knopf_namen(ziel_id: str, gast: dict) -> set[str]:
|
||||
"""Die Snapshots eines Gasts, die jemand auf der Seite Snapshots angelegt hat. Kein grünes Update räumt sie weg
|
||||
(updates._aufraeumen) — die löscht nur der User."""
|
||||
namen = {name for ziel, name in _knopf(updates.laeufe(100)) if ziel == ziel_id}
|
||||
for d in gast.get("snapshot_details") or []:
|
||||
if isinstance(d, dict) and str(d.get("beschreibung") or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
namen.add(str(d.get("name")))
|
||||
return namen
|
||||
|
||||
|
||||
def _snapshots(g: dict, zid: str, knopf: set[tuple[str, str]]) -> list[dict]:
|
||||
"""Die Snapshots eines Gasts, ältester zuerst: Name, Zeitpunkt, Beschreibung, Herkunft, ob vom Orchestrator."""
|
||||
details = g.get("snapshot_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("name")] if isinstance(details, list)
|
||||
else [{"name": n} for n in g.get("snapshots") or [] if n])
|
||||
liste = []
|
||||
for d in roh:
|
||||
name = str(d["name"])
|
||||
m = SNAPSHOT_NAME.match(name)
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(m)
|
||||
beschreibung = str(d.get("beschreibung") or "").strip() or None
|
||||
if not m:
|
||||
herkunft = "proxmox"
|
||||
elif (zid, name) in knopf or (beschreibung or "").startswith(KNOPF_BESCHREIBUNG):
|
||||
herkunft = "knopf"
|
||||
else:
|
||||
herkunft = "update"
|
||||
liste.append({"name": name, "zeit": zeit, "beschreibung": beschreibung, "herkunft": herkunft,
|
||||
"eigen": bool(m)})
|
||||
return sorted(liste, key=lambda s: (s["zeit"] or 0, s["name"]))
|
||||
|
||||
|
||||
def _sicherungen(g: dict) -> list[dict]:
|
||||
"""Die Sicherungen des Orchestrators für diesen Gast (der Ausführer nennt nur eigene), älteste zuerst."""
|
||||
details = g.get("sicherung_details")
|
||||
roh = ([d for d in details if isinstance(d, dict) and d.get("volid")] if isinstance(details, list)
|
||||
else [{"volid": v} for v in g.get("sicherungen") or [] if v])
|
||||
liste = []
|
||||
for d in roh:
|
||||
volid = str(d["volid"])
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
zeit = d.get("zeit") if isinstance(d.get("zeit"), (int, float)) else _ortszeit(ARCHIV_ZEIT.search(datei))
|
||||
groesse = d.get("groesse") if isinstance(d.get("groesse"), int) else None
|
||||
liste.append({"datei": datei, "speicher": volid.split(":", 1)[0], "zeit": zeit, "groesse": groesse})
|
||||
return sorted(liste, key=lambda e: (e["zeit"] or 0, e["datei"]))
|
||||
|
||||
|
||||
def _anlegen_grund(g: dict) -> str | None:
|
||||
"""Warum es für diesen Gast keinen Knopf „Snapshot anlegen“ gibt — oder None."""
|
||||
if not g.get("erlaubt"):
|
||||
return ("Nicht freigegeben: Das Etikett „watcher“ fehlt (oder „watcher-aus“ ist gesetzt). Der Orchestrator fasst "
|
||||
"dieses Gerät nicht an; Snapshots gehen hier nur von Hand in Proxmox.")
|
||||
if "snapshot_moeglich" not in g:
|
||||
return "Ob ein Snapshot geht, ließ sich nicht lesen" + (f": {g['fehler']}" if g.get("fehler") else ".")
|
||||
if not g["snapshot_moeglich"]:
|
||||
satz = f"Kein Snapshot möglich: {str(g.get('snapshot_grund') or 'Der Speicher kann keine').rstrip('.')}."
|
||||
if g.get("sicherung_moeglich"):
|
||||
satz += " Vor einem Update legt der Orchestrator stattdessen eine Sicherung an."
|
||||
return satz
|
||||
return None
|
||||
|
||||
|
||||
# --- Rückfragen (die Oberfläche zeigt sie wörtlich) ---------------------------------------------------------------
|
||||
|
||||
def _frage_anlegen(g: dict, name: str) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
saetze = [f"Jetzt einen Snapshot von {name} ({_wo(g)}) anlegen?"]
|
||||
if g.get("status") == "running":
|
||||
saetze.append(f"{'Die VM' if vm else 'Der Container'} läuft dabei weiter.")
|
||||
saetze.append("Diesen Snapshot räumt kein Update weg — du löschst ihn hier, wenn du ihn nicht mehr brauchst.")
|
||||
saetze.append("Die VM schreibt laufend; ihr Snapshot wächst im Speicherpool schnell mit, also nicht lange stehen "
|
||||
"lassen." if vm else "Im Speicherpool belegt er so viel, wie sich seitdem im Container ändert.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
def _frage_loeschen(name: str, s: dict) -> str:
|
||||
wie = "Rückweg eines Updates" if s["herkunft"] == "update" else "auf Knopfdruck angelegt"
|
||||
return (f"Den Snapshot {s['name']} von {name} ({', '.join(t for t in (_wann(s['zeit']), wie) if t)}) endgültig "
|
||||
"löschen? Am Gerät selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||
|
||||
|
||||
def _frage_zurueck(g: dict, name: str, s: dict) -> str:
|
||||
vm = g.get("art") != "lxc"
|
||||
wann = _wann(s["zeit"])
|
||||
stand = f"den Snapshot {wann} ({s['name']})" if wann else f"den Snapshot {s['name']}"
|
||||
saetze = [f"{name} ({_wo(g)}) auf {stand} zurücksetzen?",
|
||||
(f"Alles, was sich seitdem {'in der VM' if vm else 'im Container'} geändert hat — Daten, Einstellungen, "
|
||||
"Updates —, ist danach verloren. Rückgängig machen lässt sich das nicht."),
|
||||
(f"{'Die VM' if vm else 'Der Container'} wird dafür gestoppt und wieder gestartet; "
|
||||
f"{name} ist ein paar Minuten nicht erreichbar.")]
|
||||
if g.get("status") != "running":
|
||||
saetze.append(f"Gerade {'ist die VM' if vm else 'ist der Container'} gestoppt — danach läuft "
|
||||
f"{'sie' if vm else 'er'}.")
|
||||
saetze.append(f"Danach prüft der Orchestrator, ob {name} wieder antwortet, und meldet das Ergebnis. Willst du den "
|
||||
"jetzigen Stand behalten, lege vorher einen Snapshot an.")
|
||||
return " ".join(saetze)
|
||||
|
||||
|
||||
def _frage_sicherung(name: str, e: dict) -> str:
|
||||
teile = [t for t in (_wann(e["zeit"]), _groesse(e["groesse"])) if t] + [f"auf „{e['speicher']}“"]
|
||||
return (f"Die Sicherung {e['datei']} von {name} ({', '.join(teile)}) endgültig löschen? Der Orchestrator hat sie "
|
||||
"vor einem Update als Rückweg angelegt. Am Gerät selbst ändert sich nichts, und die nächtlichen Sicherungen "
|
||||
"(Proxmox → Backup) bleiben, wie sie sind.")
|
||||
|
||||
|
||||
# --- Liste für die Oberfläche ------------------------------------------------------------------------------------------
|
||||
|
||||
def _laeuft_text(x: dict) -> str:
|
||||
"""Was für ein Gerät gerade läuft, in Worten — auch ein Update, das die Knöpfe hier sperrt."""
|
||||
baustein = str(x.get("baustein"))
|
||||
if baustein == "snapshot-zurueck" and "zurueck: ok" in (x.get("schritte") or []):
|
||||
return "Zurückgesetzt, wird geprüft"
|
||||
return LAEUFT.get(baustein, "Update läuft")
|
||||
|
||||
|
||||
def _zuletzt(x: dict | None, jetzt: float) -> dict | None:
|
||||
"""Das Ergebnis der letzten Aktion dieser Seite für ein Gerät, solange es frisch ist (ZULETZT_S)."""
|
||||
ende = (x or {}).get("ende") or (x or {}).get("start")
|
||||
if not isinstance(ende, (int, float)) or jetzt - ende > ZULETZT_S:
|
||||
return None
|
||||
if x.get("status") == "unterbrochen":
|
||||
return {"ok": False, "ende": ende,
|
||||
"text": f"{WAS.get(str(x.get('baustein')), 'Die Aktion')} unterbrochen: Der Homelab-Teil wurde "
|
||||
"währenddessen neu gestartet. Was am Gerät geschah, zeigt die Liste."}
|
||||
return {"ok": x.get("ergebnis") in GUT, "ende": ende, "text": str(x.get("text") or "")}
|
||||
|
||||
|
||||
def _geraet(g: dict, laufend: dict[str, dict], fertig: dict[str, dict], knopf: set, jetzt: float) -> dict:
|
||||
zid, name = _zid(g), _name(g)
|
||||
grund = _anlegen_grund(g)
|
||||
snapshots = _snapshots(g, zid, knopf)
|
||||
for s in snapshots:
|
||||
darf = s["eigen"] and bool(g.get("erlaubt"))
|
||||
pfad = f"/api/homelab/snapshots/{zid}/{s['name']}"
|
||||
s["loeschen"] = _aktion(f"{pfad}/loeschen", _frage_loeschen(name, s), "Snapshot löschen") if darf else None
|
||||
s["zuruecksetzen"] = (_aktion(f"{pfad}/zuruecksetzen", _frage_zurueck(g, name, s), "Zurücksetzen")
|
||||
if darf else None)
|
||||
sicherungen = _sicherungen(g)
|
||||
for e in sicherungen:
|
||||
e["loeschen"] = (_aktion(f"/api/homelab/sicherungen/{zid}/{e['datei']}/loeschen", _frage_sicherung(name, e),
|
||||
"Sicherung löschen") if g.get("erlaubt") else None)
|
||||
lauf = laufend.get(zid)
|
||||
return {"id": zid, "vmid": g["vmid"], "art": "container" if g.get("art") == "lxc" else "vm", "name": name,
|
||||
"wo": _wo(g), "status": g.get("status"), "erlaubt": bool(g.get("erlaubt")),
|
||||
"anlegen": None if grund else _aktion(f"/api/homelab/snapshots/{zid}/anlegen", _frage_anlegen(g, name),
|
||||
"Snapshot anlegen"),
|
||||
"grund": grund, "laeuft": _laeuft_text(lauf) if lauf else None, "zuletzt": _zuletzt(fertig.get(zid), jetzt),
|
||||
"snapshots": snapshots, "sicherungen": sicherungen}
|
||||
|
||||
|
||||
def liste(jetzt: float | None = None) -> dict:
|
||||
"""GET /api/homelab/snapshots: je Gerät (ohne den Container des Orchestrators selbst) Snapshots und Sicherungen."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
eingang = kanal.bericht()
|
||||
bericht = (eingang or {}).get("bericht") or {}
|
||||
zuletzt = kanal.zuletzt()
|
||||
gaeste = [g for g in bericht.get("gaeste") or [] if isinstance(g, dict) and isinstance(g.get("vmid"), int)
|
||||
and inventar.EIGENES_ETIKETT not in (g.get("etiketten") or [])]
|
||||
laeufe = updates.laeufe(100) # neueste zuerst
|
||||
laufend: dict[str, dict] = {}
|
||||
fertig: dict[str, dict] = {}
|
||||
for x in laeufe:
|
||||
if x.get("status") == "laeuft":
|
||||
laufend.setdefault(str(x.get("ziel")), x)
|
||||
elif x.get("baustein") in LAEUFT:
|
||||
fertig.setdefault(str(x.get("ziel")), x)
|
||||
knopf = _knopf(laeufe)
|
||||
geraete = [_geraet(g, laufend, fertig, knopf, jetzt) for g in sorted(gaeste, key=lambda g: g["vmid"])]
|
||||
alle = [s for g in geraete for s in g["snapshots"]]
|
||||
sicherungen = [e for g in geraete for e in g["sicherungen"]]
|
||||
empfangen = (eingang or {}).get("empfangen")
|
||||
return {
|
||||
"jetzt": jetzt,
|
||||
"bericht": {"empfangen": empfangen, "veraltet": jetzt - empfangen > inventar.BERICHT_ALT_S} if eingang else None,
|
||||
"ausfuehrer": {"verbunden": bool(zuletzt and jetzt - zuletzt < inventar.BERICHT_ALT_S), "zuletzt": zuletzt,
|
||||
"nur_lesen": bool((bericht.get("host") or {}).get("nur_lesen"))},
|
||||
# Warum gerade keine Aktion geht („Alle aktualisieren“, ein Update, der Ausführer) — wie die Knöpfe es sagten.
|
||||
"sperre": _sperre(),
|
||||
# Ausführer vor dem 25.09.2026: nur Namen, Zeitpunkte aus dem Namen, keine Beschreibung.
|
||||
"details": any("snapshot_details" in g for g in gaeste),
|
||||
"geraete": geraete,
|
||||
"summe": {"snapshots": len(alle), "eigene": sum(1 for s in alle if s["eigen"]), "sicherungen": len(sicherungen),
|
||||
"sicherungen_groesse": sum(e["groesse"] or 0 for e in sicherungen) or None},
|
||||
}
|
||||
|
||||
|
||||
# --- Aktionen ------------------------------------------------------------------------------------------------------
|
||||
|
||||
def _sperre() -> str | None:
|
||||
"""Warum gerade gar keine Aktion dieser Seite geht — oder None. Derselbe Satz steht auf der Seite über der Liste.
|
||||
|
||||
Läuft irgendwo ein Update, warten alle: Der Ausführer arbeitet einen Auftrag nach dem anderen ab. Hinter einem
|
||||
Update (bis zu einer Stunde) liefe eine Aktion in ihr Zeitlimit — und käme danach doch noch dran, ohne dass ein
|
||||
Lauf hinsieht; beim Zurücksetzen wäre das gefährlich. Vor Aktionen dieser Seite stehen so nur kurze Aufträge.
|
||||
Umgekehrt darf ein Update neben ihnen starten: Es wartet höchstens ein paar Sekunden."""
|
||||
if sammellauf.laeuft():
|
||||
return f"{updates.SAMMELLAUF_SPERRE} Bis es fertig ist, ruhen die Knöpfe hier."
|
||||
im_update = [str(x.get("name") or x.get("ziel")) for x in updates.laeufe(100)
|
||||
if x.get("status") == "laeuft" and x.get("baustein") not in LAEUFT]
|
||||
if im_update:
|
||||
return (f"Gerade läuft ein Update ({', '.join(dict.fromkeys(im_update))}). Anlegen, löschen und zurücksetzen "
|
||||
"geht, sobald es fertig ist — der Ausführer arbeitet einen Auftrag nach dem anderen ab.")
|
||||
zuletzt = kanal.zuletzt()
|
||||
if not (zuletzt and time.time() - zuletzt < inventar.BERICHT_ALT_S):
|
||||
return ("Der Ausführer auf dem Proxmox-Host meldet sich nicht. Die Liste zeigt den letzten bekannten Stand; "
|
||||
"anlegen, löschen und zurücksetzen geht erst, wenn er wieder da ist.")
|
||||
if (((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}).get("nur_lesen"):
|
||||
return "Der Ausführer läuft im Nur-Lesen-Modus: Er zeigt alles, ändert aber nichts."
|
||||
return None
|
||||
|
||||
|
||||
def _vorab(ziel_id: str) -> tuple[dict | None, str | None]:
|
||||
"""Was vor jeder Aktion gelten muss (unter updates.START_SPERRE geprüft): (Gast, None) oder (None, warum nicht)."""
|
||||
if grund := _sperre():
|
||||
return None, grund
|
||||
if updates.laeuft(ziel_id):
|
||||
return None, ("Für dieses Gerät läuft gerade schon etwas (ein Update oder eine Snapshot-Aktion). Das geht "
|
||||
"erst, wenn es fertig ist.")
|
||||
gast = inventar.gast(ziel_id)
|
||||
if not gast or inventar.EIGENES_ETIKETT in (gast.get("etiketten") or []):
|
||||
return None, "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||
if not gast.get("erlaubt"):
|
||||
return None, "Dieses Gerät ist nicht freigegeben (Etikett „watcher“ fehlt); der Ausführer fasst es nicht an."
|
||||
return gast, None
|
||||
|
||||
|
||||
def _starten(ziel_id: str, baustein: str, vorbereiten: Callable[[dict], tuple[str | None, dict]],
|
||||
schritte: Callable[[dict, dict], None]) -> dict:
|
||||
"""Prüfen und den Lauf anlegen am Stück (wie updates.starten), dann im eigenen Faden ablaufen lassen.
|
||||
Rückgabe {"ok": True, "lauf": id} oder {"ok": False, "detail": …}."""
|
||||
with updates.START_SPERRE:
|
||||
gast, grund = _vorab(ziel_id)
|
||||
mehr: dict = {}
|
||||
if gast is not None:
|
||||
grund, mehr = vorbereiten(gast)
|
||||
if grund or gast is None:
|
||||
return {"ok": False, "detail": grund or "Das geht gerade nicht."}
|
||||
lauf = updates._neuer_lauf(ziel_id, baustein, _name(gast), **mehr)
|
||||
threading.Thread(target=_ablauf, args=(schritte, lauf, gast), name=f"homelab-{ziel_id}", daemon=True).start()
|
||||
return {"ok": True, "lauf": lauf["id"]}
|
||||
|
||||
|
||||
def _eigener_snapshot(gast: dict, name: str) -> tuple[str | None, dict]:
|
||||
if not SNAPSHOT_NAME.match(name):
|
||||
return ("Nur Snapshots des Orchestrators (mc2-…) lassen sich hier löschen oder zurücksetzen; von Hand "
|
||||
"angelegte verwaltest du in Proxmox."), {}
|
||||
s = next((s for s in _snapshots(gast, "", set()) if s["name"] == name), None)
|
||||
if s is None:
|
||||
return f"Den Snapshot {name} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"snapshot": name, "snapshot_zeit": s["zeit"]}
|
||||
|
||||
|
||||
def anlegen(ziel_id: str) -> dict:
|
||||
"""Knopf „Snapshot anlegen“: nur freigegebene Gäste, auf deren Speicher ein Snapshot geht."""
|
||||
return _starten(ziel_id, "snapshot-anlegen", lambda g: (_anlegen_grund(g), {}), _anlegen)
|
||||
|
||||
|
||||
def loeschen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Löschen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-loeschen", lambda g: _eigener_snapshot(g, name), _loeschen)
|
||||
|
||||
|
||||
def zuruecksetzen(ziel_id: str, name: str) -> dict:
|
||||
"""Knopf „Zurücksetzen“ an einem Snapshot des Orchestrators."""
|
||||
return _starten(ziel_id, "snapshot-zurueck", lambda g: _eigener_snapshot(g, name), _zurueck)
|
||||
|
||||
|
||||
def sicherung_loeschen(ziel_id: str, datei: str) -> dict:
|
||||
"""Knopf „Löschen“ an einer Sicherung des Orchestrators (nur, was der Bericht als eigene nennt)."""
|
||||
def vorbereiten(gast: dict) -> tuple[str | None, dict]:
|
||||
volid = next((str(v) for v in gast.get("sicherungen") or [] if str(v).rsplit("/", 1)[-1] == datei), None)
|
||||
if volid is None:
|
||||
return f"Die Sicherung {datei} gibt es laut dem letzten Bericht nicht (mehr).", {}
|
||||
return None, {"sicherung": volid}
|
||||
return _starten(ziel_id, "sicherung-loeschen", vorbereiten, _sicherung_loeschen)
|
||||
|
||||
|
||||
# --- Abläufe (im eigenen Faden) --------------------------------------------------------------------------------------
|
||||
|
||||
def _ende(lauf: dict, ergebnis: str, text: str, melden: bool = False, dringend: bool = False) -> None:
|
||||
betreff = (BETREFF_ALARM if dringend else BETREFF) if melden else None
|
||||
lauf.update(status="fertig", ergebnis=ergebnis, text=text, ende=time.time(), dringend=dringend, gemeldet=melden,
|
||||
betreff=betreff)
|
||||
updates._merken(lauf)
|
||||
if betreff:
|
||||
try:
|
||||
announce.notify_telegram(betreff, text)
|
||||
except Exception:
|
||||
log.warning("homelab: Meldung zu %s ging nicht raus", lauf.get("baustein"), exc_info=True)
|
||||
|
||||
|
||||
def _ablauf(schritte: Callable[[dict, dict], None], lauf: dict, gast: dict) -> None:
|
||||
try:
|
||||
schritte(lauf, gast)
|
||||
except Exception as exc:
|
||||
# Sonst hieße der Lauf bis zum nächsten Neustart „läuft“, und das Gerät wäre für alles gesperrt.
|
||||
log.exception("homelab: %s für %s ist abgestürzt", lauf.get("baustein"), lauf.get("ziel"))
|
||||
_ende(lauf, "fehler", f"{lauf['name']}: {WAS.get(lauf['baustein'], 'Die Aktion')} ist mit einem internen "
|
||||
f"Fehler stehen geblieben ({exc.__class__.__name__}).", melden=True)
|
||||
|
||||
|
||||
def _anlegen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name = gast["vmid"], lauf["name"]
|
||||
try:
|
||||
a = updates._auftrag(lauf, "snapshot", {"vmid": vmid, "anlass": "knopf"}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot nicht angelegt — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
m = re.search(r"snapshot=(mc2-\d{8}-\d{6})", a.get("text") or "")
|
||||
lauf["snapshot"] = m.group(1) if m else None
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
if lauf["snapshot"] and nachher is not None and lauf["snapshot"] not in (nachher.get("snapshots") or []):
|
||||
_ende(lauf, "fehler", f"{name}: Der Ausführer meldete den Snapshot {lauf['snapshot']}, im neuen Bericht fehlt "
|
||||
"er aber.", melden=True)
|
||||
return
|
||||
_ende(lauf, "angelegt", f"{name}: Snapshot {lauf['snapshot']} angelegt." if lauf["snapshot"]
|
||||
else f"{name}: Snapshot angelegt.")
|
||||
|
||||
|
||||
def _loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
try:
|
||||
updates._auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Snapshot {snapshot} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Snapshot {snapshot} gelöscht.")
|
||||
|
||||
|
||||
def _lage(name: str, gast: dict | None) -> str:
|
||||
"""Wie es dem Gerät nach einem gescheiterten Zurücksetzen geht, laut frischem Bericht."""
|
||||
if gast is None:
|
||||
return f"Ob {name} läuft, ließ sich nicht prüfen — bitte in Proxmox nachsehen."
|
||||
if gast.get("status") == "running":
|
||||
return f"{name} läuft."
|
||||
return f"{name} läuft nicht ({gast.get('status')}) — bitte in Proxmox nachsehen."
|
||||
|
||||
|
||||
def _zurueck(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, snapshot = gast["vmid"], lauf["name"], lauf["snapshot"]
|
||||
wann = _wann(lauf.get("snapshot_zeit"))
|
||||
stand = f"den Snapshot {wann} ({snapshot})" if wann else f"den Snapshot {snapshot}"
|
||||
try:
|
||||
updates._auftrag(lauf, "zurueck", {"vmid": vmid, "snapshot": snapshot}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "fehler", f"{name}: Zurücksetzen auf {stand} gescheitert — {_grund(exc)}. {_lage(name, nachher)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
lauf["zurueckgesetzt"] = True
|
||||
time.sleep(updates.WARTEN_NACH_UPDATE_S)
|
||||
nachher = updates._frischer_gast(lauf, vmid)
|
||||
fehler = updates.pruefen(gast, nachher, "zurueck")
|
||||
if fehler:
|
||||
_ende(lauf, "fehler", f"{name}: auf {stand} zurückgesetzt, aber die Prüfung ist rot — {' '.join(fehler)}",
|
||||
melden=True, dringend=True)
|
||||
return
|
||||
_ende(lauf, "zurueckgesetzt", f"{name}: auf {stand} zurückgesetzt, Prüfung grün.", melden=True)
|
||||
|
||||
|
||||
def _sicherung_loeschen(lauf: dict, gast: dict) -> None:
|
||||
vmid, name, volid = gast["vmid"], lauf["name"], lauf["sicherung"]
|
||||
datei = volid.rsplit("/", 1)[-1]
|
||||
try:
|
||||
updates._auftrag(lauf, "sicherung_loeschen", {"vmid": vmid, "sicherung": volid}, updates.ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError as exc:
|
||||
_ende(lauf, "fehler", f"{name}: Sicherung {datei} ließ sich nicht löschen — {_grund(exc)}.", melden=True)
|
||||
return
|
||||
updates._frischer_gast(lauf, vmid)
|
||||
_ende(lauf, "geloescht", f"{name}: Sicherung {datei} gelöscht.")
|
||||
@@ -4,7 +4,7 @@ Alles aus dem Bericht des Ausführers (alle 10 Minuten), dazu eine eigene Prüfu
|
||||
• Speicherpool (Thin-LVM, heute local-lvm): Läuft er voll, stehen alle Gäste darauf still. Gelb ab 80 %, rot ab
|
||||
90 % — also bei 10 % frei (User-Vorgabe) —, für Daten wie Metadaten. Dazu Empfehlungen, was Platz bringt,
|
||||
der größte Gewinn zuerst: Platz, den ein Gast längst freigegeben hat (TRIM), ungenutzte und verwaiste Platten,
|
||||
Snapshots.
|
||||
Snapshots (seit 25.09.2026 in einem Vorschlag, verwaltet auf der Seite Homelab → Snapshots).
|
||||
• NAS: jedes Netzlaufwerk des Proxmox-Hosts (heute das QNAP per NFS, Ziel der nächtlichen Sicherungen): antwortet,
|
||||
ist eingebunden, hängt nicht, hat Platz (80/90 % wie oben).
|
||||
• Sicherungen: Für jeden Gast, den ein Sicherungsauftrag erfasst, liegt auf dessen Ziel eine frische Sicherung
|
||||
@@ -23,6 +23,7 @@ from datetime import datetime
|
||||
from kern.zeit import LOCAL_TZ
|
||||
|
||||
from services.homelab import apps, kanal
|
||||
from services.homelab.inventar import EIGENES_ETIKETT
|
||||
|
||||
GELB, ROT = 0.80, 0.90
|
||||
EMPFEHLUNG_AB = 1_000_000_000 # kleinere Gewinne sind keine eigene Empfehlung wert
|
||||
@@ -135,20 +136,50 @@ def empfehlungen(bericht: dict) -> list[dict]:
|
||||
if kernel := _kernel_vorschlag(bericht.get("host") or {}):
|
||||
liste.append(kernel)
|
||||
liste.sort(key=lambda e: -(e["platz"] or 0))
|
||||
for g in gaeste:
|
||||
for s in g.get("snapshots") or []:
|
||||
m = re.fullmatch(r"mc2-(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})\d{2}", s)
|
||||
wann = f"vom {m.group(3)}.{m.group(2)}. {m.group(4)}:{m.group(5)}" if m else "von Hand angelegt"
|
||||
liste.append({
|
||||
"art": "snapshot", "platz": None, "titel": f"{_name(g)}: Snapshot {s}", "kurz": f"Snapshot bei {_name(g)}",
|
||||
"ort": "pool",
|
||||
"text": ((f"Rückweg des Updates {wann}. Läuft {_name(g)} seitdem gut, kann er weg" if m else
|
||||
f"Snapshot {wann}. Wird er nicht mehr gebraucht, kann er weg")
|
||||
+ f": in Proxmox {_wo(g)} → Snapshots → {s} → Entfernen. Wie viel er belegt, sagt Proxmox "
|
||||
"nicht — bei Containern meist wenig, er wächst mit jeder Änderung seitdem.")})
|
||||
if snapshots := _snapshot_vorschlag(gaeste):
|
||||
liste.append(snapshots)
|
||||
return liste
|
||||
|
||||
|
||||
def _geraete_text(namen: list[str]) -> str:
|
||||
"""„Gitea (2), NetBird und AdGuard Home“ — je Gerät einmal, mit der Zahl, wo es mehrere sind."""
|
||||
teile = [f"{n} ({namen.count(n)})" if namen.count(n) > 1 else n for n in dict.fromkeys(namen)]
|
||||
return teile[0] if len(teile) == 1 else ", ".join(teile[:-1]) + " und " + teile[-1]
|
||||
|
||||
|
||||
def _snapshot_vorschlag(gaeste: list[dict]) -> dict | None:
|
||||
"""Alle Snapshots in EINEM Vorschlag (seit 25.09.2026; vorher einer je Snapshot): Verwalten lassen sie sich auf der
|
||||
Seite Homelab → Snapshots, mit Zeitpunkt und Knöpfen. Der Container des Orchestrators selbst fehlt dort wie hier.
|
||||
Wie viel sie belegen, sagt Proxmox nicht — deshalb ohne Größe und zuletzt."""
|
||||
eigene: list[str] = []
|
||||
von_hand: list[str] = []
|
||||
for g in gaeste:
|
||||
if EIGENES_ETIKETT in (g.get("etiketten") or []):
|
||||
continue
|
||||
for s in g.get("snapshots") or []:
|
||||
(eigene if re.fullmatch(r"mc2-\d{8}-\d{6}", str(s)) else von_hand).append(_name(g))
|
||||
anzahl = len(eigene) + len(von_hand)
|
||||
if not anzahl:
|
||||
return None
|
||||
if eigene:
|
||||
titel = (f"{len(eigene)} Snapshot{'s' if len(eigene) != 1 else ''} des Orchestrators — verwalten unter "
|
||||
"Homelab → Snapshots")
|
||||
text = (f"Bei {_geraete_text(eigene)}. Läuft ein Gerät seit seinem Snapshot gut, kann der weg — auf der Seite "
|
||||
"Snapshots per Knopf, dort auch mit Zeitpunkt und dem Weg zurück.")
|
||||
else:
|
||||
titel = (f"{len(von_hand)} von Hand angelegte{'r' if len(von_hand) == 1 else ''} "
|
||||
f"Snapshot{'s' if len(von_hand) != 1 else ''} — ansehen unter Homelab → Snapshots")
|
||||
text = f"Bei {_geraete_text(von_hand)}."
|
||||
if eigene and von_hand:
|
||||
text += f" Dazu {len(von_hand)} von Hand in Proxmox angelegt ({_geraete_text(von_hand)})."
|
||||
if von_hand:
|
||||
text += " Von Hand angelegte löscht der Orchestrator nicht; das geht in Proxmox."
|
||||
text += (" Wie viel ein Snapshot belegt, sagt Proxmox nicht — bei Containern meist wenig, er wächst mit jeder "
|
||||
"Änderung seitdem.")
|
||||
return {"art": "snapshot", "platz": None, "ort": "pool", "titel": titel, "text": text,
|
||||
"kurz": f"{anzahl} Snapshot{'s' if anzahl != 1 else ''} (Homelab → Snapshots)"}
|
||||
|
||||
|
||||
def _kernel_vorschlag(host: dict) -> dict | None:
|
||||
"""Alte Kernel auf der Systemplatte des Hosts — mit Knopf. Die Liste stammt vom Ausführer (was apt für entbehrlich
|
||||
hält, nie der laufende oder der nächste); beim Klick bestimmt er sie noch einmal selbst."""
|
||||
|
||||
@@ -12,7 +12,8 @@ Ablauf für einen Gast:
|
||||
3. 20 s warten, frischen Bericht holen
|
||||
4. Prüfen: läuft der Gast, antwortet die Weboberfläche, ist die App-Version jetzt neuer?
|
||||
5. Rot → zurück auf den Snapshot bzw. die Sicherung zurückspielen → dringende Meldung.
|
||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg → Meldung „eingespielt“.
|
||||
Grün → ältere Snapshots bzw. Sicherungen des Orchestrators für diesen Gast weg (nicht die, die jemand auf der
|
||||
Seite Snapshots per Knopf angelegt hat, snapshots.py) → Meldung „eingespielt“.
|
||||
Docker-Images (Arcane, VM 106) gehen über Arcanes eigenen Updater: bis zum Schalter „echt“ nur als Probelauf,
|
||||
danach genauso mit Snapshot vorher und Rückweg bei Rot — sofern die VM freigegeben ist (Etikett watcher, seit
|
||||
25.09.), sonst ohne; Rückfrage und Update-Liste sagen es. Nach grüner Prüfung geht dieser Snapshot gleich wieder weg.
|
||||
@@ -228,11 +229,14 @@ def _gast_lauf(lauf: dict, gast: dict) -> None:
|
||||
|
||||
def _aufraeumen(lauf: dict, vmid: int, rueckweg: tuple[str, str] | None) -> None:
|
||||
"""Ältere Snapshots des Orchestrators für diesen Gast löschen, nach einer Sicherung auch ältere Sicherungen;
|
||||
der Rückweg dieses Laufs (der neueste) bleibt."""
|
||||
der Rückweg dieses Laufs (der neueste) bleibt. Ebenso bleiben Snapshots, die jemand auf der Seite Snapshots per
|
||||
Knopf angelegt hat (seit 25.09.2026): Die löscht nur der User."""
|
||||
from services.homelab import snapshots # snapshots importiert dieses Modul
|
||||
art, behalten = rueckweg or (None, None)
|
||||
gast = inventar.gast(lauf["ziel"]) or {}
|
||||
per_knopf = snapshots.knopf_namen(lauf["ziel"], gast)
|
||||
for name in gast.get("snapshots") or []:
|
||||
if name.startswith("mc2-") and name != behalten:
|
||||
if name.startswith("mc2-") and name != behalten and name not in per_knopf:
|
||||
try:
|
||||
_auftrag(lauf, "snapshot_loeschen", {"vmid": vmid, "snapshot": name}, ZEITLIMIT_KURZ_S)
|
||||
except RuntimeError:
|
||||
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Seite Homelab → Snapshots (25.09.2026): Liste je Gerät, Aktionen als Läufe, der Ausführer mit Snapshot-Details.
|
||||
|
||||
Die Snapshots stammen aus dem echten Stand vom 25.09.2026 (pvesh get /nodes/pve/lxc/104/snapshot: nach „Alle
|
||||
aktualisieren“ lag an jedem Container ein mc2-Snapshot), die Sicherung aus /nodes/pve/storage/local/content."""
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
from kern import einstellungen as einstellungen_mod
|
||||
from services.homelab import inventar, kanal, protokoll, sammellauf, snapshots, updates
|
||||
|
||||
BERICHT = json.loads((Path(__file__).parent / "fixtures" / "pve-bericht.json").read_text(encoding="utf-8"))
|
||||
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
|
||||
UPDATE = "Vor einem Update durch den Homelab Orchestrator"
|
||||
KNOPF = "Auf Knopfdruck im Homelab Orchestrator angelegt"
|
||||
GITEA_SNAP = "mc2-20260925-112639" # snaptime 1790328400 = 25.09.2026 11:26:40
|
||||
ALT = "local:backup/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"
|
||||
NEU = "mc2-20260925-130512"
|
||||
|
||||
|
||||
def _ausfuehrer_modul():
|
||||
spec = importlib.util.spec_from_file_location("ausfuehrer_snapshots", AUSFUEHRER)
|
||||
modul = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(modul)
|
||||
return modul
|
||||
|
||||
|
||||
# --- Ausführer: Snapshot-Details im Bericht, Anlass beim Anlegen -------------------------------------------------------
|
||||
|
||||
# pvesh get /nodes/pve/lxc/104/snapshot am 25.09.2026, dazu ein von Hand angelegter (älter, ohne Beschreibung).
|
||||
PVESH_104 = [
|
||||
{"description": UPDATE + "\n", "name": GITEA_SNAP, "snaptime": 1790328400},
|
||||
{"description": "You are here!", "digest": "e355", "name": "current", "parent": GITEA_SNAP, "running": 1},
|
||||
{"name": "vor-dem-umbau", "snaptime": 1789000000},
|
||||
]
|
||||
|
||||
|
||||
def test_snapshot_details_aus_pvesh():
|
||||
a = _ausfuehrer_modul()
|
||||
assert a.snapshot_details(PVESH_104) == [
|
||||
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}]
|
||||
assert a.snapshot_details([{"name": "current"}]) == [] and a.snapshot_details(None) == []
|
||||
assert a.sicherung_details([{"volid": ALT, "ctime": 1790328597, "size": 360932725, "notes": "mc2-sicherung: …"}]) == [
|
||||
{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||
|
||||
|
||||
def test_bericht_behaelt_die_namenslisten_und_bringt_details():
|
||||
a = _ausfuehrer_modul()
|
||||
antworten = {
|
||||
"/nodes/pve/storage": [{"storage": "local", "type": "dir", "content": "backup,iso", "active": 1, "shared": 0,
|
||||
"avail": 60 * 1024 ** 3}],
|
||||
"/nodes/pve/storage/local/content": [
|
||||
{"volid": ALT, "vmid": 105, "ctime": 1790328597, "size": 360932725,
|
||||
"notes": "mc2-sicherung: vor einem Update durch den Homelab Orchestrator (proxmox-backup-server)"},
|
||||
{"volid": "local:backup/vzdump-lxc-105-2026_09_01-03_00_00.tar.zst", "vmid": 105, "ctime": 1788000000,
|
||||
"size": 1, "notes": "von Hand"}],
|
||||
"/cluster/resources": [
|
||||
{"vmid": 104, "type": "lxc", "node": "pve", "name": "gitea", "status": "stopped", "tags": "community-script"},
|
||||
{"vmid": 105, "type": "lxc", "node": "pve", "name": "proxmox-backup-server", "status": "stopped",
|
||||
"tags": "backup;community-script"}],
|
||||
"/nodes/pve/lxc/104/snapshot": PVESH_104,
|
||||
"/nodes/pve/lxc/105/snapshot": [{"name": "current"}],
|
||||
"/nodes/pve/lxc/104/config": {"rootfs": "local-lvm:vm-104-disk-0,size=8G"},
|
||||
"/nodes/pve/lxc/105/config": {"rootfs": "local-lvm:vm-105-disk-0,size=10G", "mp0": "/mnt/qnap,mp=/mnt/ds"},
|
||||
}
|
||||
|
||||
def pvesh(pfad, *parameter):
|
||||
if pfad not in antworten:
|
||||
raise RuntimeError(f"pvesh {pfad}: gibt es im Test nicht")
|
||||
return antworten[pfad]
|
||||
|
||||
a.NODE = "pve"
|
||||
a._pvesh = pvesh
|
||||
a._laufen = lambda befehl, zeitlimit=60, sanft=False: (0, "")
|
||||
gitea, pbs = a.bericht()["gaeste"]
|
||||
# Die Namensliste bleibt, wie pvesh sie liefert (andere Stellen hängen daran) — die Details kommen dazu.
|
||||
assert gitea["snapshots"] == [GITEA_SNAP, "vor-dem-umbau"]
|
||||
assert [d["name"] for d in gitea["snapshot_details"]] == ["vor-dem-umbau", GITEA_SNAP]
|
||||
assert pbs["sicherungen"] == [ALT] and pbs["snapshots"] == [] and pbs["snapshot_details"] == []
|
||||
assert pbs["sicherung_details"] == [{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}]
|
||||
|
||||
|
||||
def test_anlegen_mit_festem_anlass():
|
||||
a = _ausfuehrer_modul()
|
||||
a._pvesh = lambda pfad, *p: [{"vmid": 104, "type": "lxc", "tags": "community-script;git"}]
|
||||
befehle: list[list[str]] = []
|
||||
a._laufen = lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, "")
|
||||
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "knopf"}})
|
||||
assert antwort["code"] == 0 and antwort["text"].startswith("snapshot=mc2-")
|
||||
assert befehle[-1][:3] == ["pct", "snapshot", "104"] and befehle[-1][-2:] == ["--description", KNOPF]
|
||||
assert a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104}})["code"] == 0 # wie bisher: vor Update
|
||||
assert befehle[-1][-1] == UPDATE
|
||||
# Der Server wählt nur aus der Liste; ein freier Text kommt nicht in die Beschreibung.
|
||||
antwort = a.ausfuehren({"aktion": "snapshot", "parameter": {"vmid": 104, "anlass": "; rm -rf /"}})
|
||||
assert antwort["code"] == 2 and "Anlass" in antwort["text"] and len(befehle) == 2
|
||||
|
||||
|
||||
# --- Homelab-Teil: die Liste -----------------------------------------------------------------------------------------
|
||||
|
||||
def _bericht(**gitea) -> dict:
|
||||
"""Der echte Bericht vom 24.09. mit Snapshots und Sicherungen, wie der Ausführer sie seit dem 25.09. schickt."""
|
||||
b = json.loads(json.dumps(BERICHT))
|
||||
g = {x["vmid"]: x for x in b["gaeste"]}
|
||||
g[104].update(snapshots=[GITEA_SNAP, "vor-dem-umbau"], snapshot_details=[
|
||||
{"name": "vor-dem-umbau", "zeit": 1789000000, "beschreibung": None, "eigen": False},
|
||||
{"name": GITEA_SNAP, "zeit": 1790328400, "beschreibung": UPDATE, "eigen": True}], **gitea)
|
||||
g[105].update(sicherungen=[ALT], sicherung_details=[{"volid": ALT, "zeit": 1790328597, "groesse": 360932725}],
|
||||
sicherung_moeglich=True, sicherung_speicher="local", snapshot_details=[])
|
||||
g[106].update(snapshots=["docker-vorher"], snapshot_details=[
|
||||
{"name": "docker-vorher", "zeit": 1789500000, "beschreibung": "vor dem Umzug", "eigen": False}])
|
||||
for x in (100, 101, 102, 103):
|
||||
g[x]["snapshot_details"] = []
|
||||
b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "status": "running",
|
||||
"etiketten": ["mc2"], "erlaubt": False, "snapshots": ["test"]})
|
||||
return b
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def daten(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
|
||||
monkeypatch.setenv("MC_NOTIFY_LOG", str(tmp_path / "notify.log"))
|
||||
for name in ("MC_AUSFUEHRER_TOKEN", "MC_WAECHTER_STORE", "MC_ARCANE_KEY"):
|
||||
monkeypatch.delenv(name, raising=False)
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
monkeypatch.setattr(kanal, "_kontakt", {"zuletzt": None})
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: True)
|
||||
monkeypatch.setattr(inventar.karenz, "skript_geaendert", lambda kennung: 0.0) # kein Netz
|
||||
yield tmp_path
|
||||
einstellungen_mod.einstellungen.cache_clear()
|
||||
|
||||
|
||||
def _geraete(stand: dict) -> dict[str, dict]:
|
||||
return {g["id"]: g for g in stand["geraete"]}
|
||||
|
||||
|
||||
def test_liste_je_geraet_mit_herkunft_und_knoepfen(daten):
|
||||
kanal.bericht_speichern(_bericht())
|
||||
stand = snapshots.liste()
|
||||
g = _geraete(stand)
|
||||
assert list(g) == ["ct-100", "ct-101", "ct-102", "ct-103", "ct-104", "ct-105", "vm-106"] # ohne ct-107
|
||||
assert stand["details"] is True and stand["sperre"] is None and stand["ausfuehrer"]["verbunden"] is True
|
||||
assert stand["summe"] == {"snapshots": 3, "eigene": 1, "sicherungen": 1, "sicherungen_groesse": 360932725}
|
||||
|
||||
gitea = g["ct-104"]
|
||||
assert (gitea["name"], gitea["wo"], gitea["art"], gitea["laeuft"], gitea["grund"]) == (
|
||||
"Gitea", "Container 104", "container", None, None)
|
||||
hand, eigen = gitea["snapshots"] # ältester zuerst
|
||||
assert (hand["name"], hand["herkunft"], hand["eigen"], hand["loeschen"], hand["zuruecksetzen"]) == (
|
||||
"vor-dem-umbau", "proxmox", False, None, None)
|
||||
assert (eigen["herkunft"], eigen["zeit"], eigen["beschreibung"]) == ("update", 1790328400, UPDATE)
|
||||
assert eigen["loeschen"]["pfad"] == f"/api/homelab/snapshots/ct-104/{GITEA_SNAP}/loeschen"
|
||||
assert eigen["loeschen"]["frage"] == (
|
||||
f"Den Snapshot {GITEA_SNAP} von Gitea (vom 25.09.2026 11:26, Rückweg eines Updates) endgültig löschen? Am Gerät "
|
||||
"selbst ändert sich nichts; nur der Weg zurück auf diesen Stand ist danach weg.")
|
||||
zurueck = eigen["zuruecksetzen"]
|
||||
assert zurueck["pfad"].endswith(f"/{GITEA_SNAP}/zuruecksetzen") and zurueck["label"] == "Zurücksetzen"
|
||||
assert zurueck["frage"].startswith(f"Gitea (Container 104) auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) "
|
||||
"zurücksetzen? Alles, was sich seitdem im Container geändert hat")
|
||||
assert "ist danach verloren" in zurueck["frage"] and "gestoppt und wieder gestartet" in zurueck["frage"]
|
||||
assert gitea["anlegen"]["pfad"] == "/api/homelab/snapshots/ct-104/anlegen"
|
||||
assert "Der Container läuft dabei weiter." in gitea["anlegen"]["frage"]
|
||||
assert "räumt kein Update weg" in gitea["anlegen"]["frage"]
|
||||
|
||||
pbs = g["ct-105"]
|
||||
assert pbs["anlegen"] is None and pbs["grund"] == (
|
||||
"Kein Snapshot möglich: Bind-Mount mp0 (/mnt/qnap-backup/pbs-datastore) — nur Backup möglich. Vor einem Update "
|
||||
"legt der Orchestrator stattdessen eine Sicherung an.")
|
||||
(sicherung,) = pbs["sicherungen"]
|
||||
assert (sicherung["datei"], sicherung["speicher"], sicherung["groesse"]) == (
|
||||
"vzdump-lxc-105-2026_09_25-11_29_57.tar.zst", "local", 360932725)
|
||||
assert sicherung["loeschen"]["pfad"] == (
|
||||
"/api/homelab/sicherungen/ct-105/vzdump-lxc-105-2026_09_25-11_29_57.tar.zst/loeschen")
|
||||
assert "(vom 25.09.2026 11:29, 361 MB, auf „local“)" in sicherung["loeschen"]["frage"]
|
||||
|
||||
# Arcane ohne Etikett (Stand 24.09.): zu sehen, aber ohne Knöpfe.
|
||||
arcane = g["vm-106"]
|
||||
assert arcane["anlegen"] is None and arcane["grund"].startswith("Nicht freigegeben")
|
||||
assert arcane["snapshots"][0]["herkunft"] == "proxmox" and arcane["snapshots"][0]["loeschen"] is None
|
||||
|
||||
|
||||
def test_liste_mit_altem_ausfuehrer_nur_aus_namen(daten):
|
||||
"""Bis der neue Ausführer läuft, gibt es nur Namen: der Zeitpunkt aus dem Namen, keine Beschreibung."""
|
||||
b = json.loads(json.dumps(BERICHT))
|
||||
next(g for g in b["gaeste"] if g["vmid"] == 104)["snapshots"] = [GITEA_SNAP, "vor-dem-umbau"]
|
||||
next(g for g in b["gaeste"] if g["vmid"] == 105)["sicherungen"] = [ALT]
|
||||
kanal.bericht_speichern(b)
|
||||
stand = snapshots.liste()
|
||||
assert stand["details"] is False
|
||||
gitea = _geraete(stand)["ct-104"]
|
||||
assert [(s["name"], s["zeit"], s["beschreibung"]) for s in gitea["snapshots"]] == [
|
||||
("vor-dem-umbau", None, None), (GITEA_SNAP, 1790328399.0, None)]
|
||||
(sicherung,) = _geraete(stand)["ct-105"]["sicherungen"]
|
||||
assert sicherung["zeit"] == 1790328597.0 and sicherung["groesse"] is None
|
||||
|
||||
|
||||
def test_ohne_bericht_leer(daten):
|
||||
stand = snapshots.liste()
|
||||
assert stand["geraete"] == [] and stand["bericht"] is None and stand["ausfuehrer"]["verbunden"] is False
|
||||
|
||||
|
||||
# --- Aktionen mit einem nachgespielten Ausführer ---------------------------------------------------------------------
|
||||
|
||||
class Ausfuehrer:
|
||||
"""Beantwortet Aufträge wie der echte und führt einen Bericht, der sich mit ihnen ändert."""
|
||||
|
||||
def __init__(self, bericht: dict):
|
||||
self.bericht = bericht
|
||||
self.auftraege: list[tuple[str, dict]] = []
|
||||
self.fehlschlag: dict[str, tuple[int, str]] = {}
|
||||
self.stopp = threading.Event()
|
||||
threading.Thread(target=self._laufen, daemon=True).start()
|
||||
|
||||
def gast(self, vmid: int) -> dict:
|
||||
return next(g for g in self.bericht["gaeste"] if g["vmid"] == vmid)
|
||||
|
||||
def _laufen(self) -> None:
|
||||
while not self.stopp.is_set():
|
||||
a = kanal.naechster()
|
||||
if not a:
|
||||
time.sleep(0.02)
|
||||
continue
|
||||
aktion, p = a["aktion"], a["parameter"]
|
||||
self.auftraege.append((aktion, p))
|
||||
code, text = self.fehlschlag.get(aktion, (0, ""))
|
||||
if code == 0 and aktion == "snapshot":
|
||||
g = self.gast(p["vmid"])
|
||||
g["snapshots"].append(NEU)
|
||||
# Ein Ausführer, der den Anlass noch nicht kennt, schreibt „Vor einem Update …“ hinein.
|
||||
g["snapshot_details"].append({"name": NEU, "zeit": 1790333112, "beschreibung": UPDATE, "eigen": True})
|
||||
text = f"snapshot={NEU}\nfreeze guest filesystem"
|
||||
elif code == 0 and aktion == "snapshot_loeschen":
|
||||
g = self.gast(p["vmid"])
|
||||
g["snapshots"].remove(p["snapshot"])
|
||||
g["snapshot_details"] = [d for d in g["snapshot_details"] if d["name"] != p["snapshot"]]
|
||||
elif code == 0 and aktion == "sicherung_loeschen":
|
||||
g = self.gast(p["vmid"])
|
||||
g["sicherungen"].remove(p["sicherung"])
|
||||
g["sicherung_details"] = [d for d in g["sicherung_details"] if d["volid"] != p["sicherung"]]
|
||||
elif code == 0 and aktion == "bericht":
|
||||
text = json.dumps(self.bericht)
|
||||
kanal.ergebnis(a["id"], code, text)
|
||||
|
||||
|
||||
_echtes_warten = kanal.warten
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def host(daten, monkeypatch):
|
||||
monkeypatch.setattr(updates, "WARTEN_NACH_UPDATE_S", 0)
|
||||
monkeypatch.setattr(kanal, "warten", lambda aid, zeitlimit_s, takt_s=2.0: _echtes_warten(aid, min(zeitlimit_s, 10),
|
||||
0.02))
|
||||
meldungen: list[tuple[str, str]] = []
|
||||
monkeypatch.setattr(snapshots.announce, "notify_telegram", lambda betreff, text: meldungen.append((betreff, text)))
|
||||
verlauf: list[tuple] = []
|
||||
monkeypatch.setattr(updates.update_verlauf, "eintragen", lambda *a: verlauf.append(a))
|
||||
bericht = _bericht()
|
||||
kanal.bericht_speichern(bericht)
|
||||
a = Ausfuehrer(json.loads(json.dumps(bericht)))
|
||||
a.meldungen, a.verlauf = meldungen, verlauf
|
||||
yield a
|
||||
a.stopp.set()
|
||||
|
||||
|
||||
def _auf_ende(antwort: dict) -> dict:
|
||||
assert antwort["ok"], antwort
|
||||
ende = time.time() + 20
|
||||
while time.time() < ende:
|
||||
lauf = updates.lauf_lesen(antwort["lauf"]) or {}
|
||||
if lauf.get("status") == "fertig":
|
||||
return lauf
|
||||
time.sleep(0.05)
|
||||
raise AssertionError("Lauf wurde nicht fertig")
|
||||
|
||||
|
||||
def test_snapshot_anlegen_ohne_meldung_und_von_keinem_update_weggeraeumt(host):
|
||||
lauf = _auf_ende(snapshots.anlegen("ct-104"))
|
||||
assert (lauf["ergebnis"], lauf["baustein"], lauf["snapshot"]) == ("angelegt", "snapshot-anlegen", NEU)
|
||||
assert lauf["text"] == f"Gitea: Snapshot {NEU} angelegt."
|
||||
assert host.auftraege == [("snapshot", {"vmid": 104, "anlass": "knopf"}), ("bericht", {})]
|
||||
assert host.meldungen == [] and host.verlauf == [] # kein Update, keine Meldung bei Erfolg
|
||||
gitea = _geraete(snapshots.liste())["ct-104"]
|
||||
neu = next(s for s in gitea["snapshots"] if s["name"] == NEU)
|
||||
# Der Ausführer schrieb noch „Vor einem Update …“ — der Lauf weiß es besser.
|
||||
assert neu["herkunft"] == "knopf" and neu["loeschen"]["frage"].count("auf Knopfdruck angelegt") == 1
|
||||
assert gitea["zuletzt"] == {"ok": True, "ende": lauf["ende"], "text": f"Gitea: Snapshot {NEU} angelegt."}
|
||||
assert snapshots.knopf_namen("ct-104", host.gast(104)) == {NEU}
|
||||
|
||||
|
||||
def test_gruenes_update_laesst_snapshots_per_knopf_stehen(host):
|
||||
"""Sonst hätte das nächste Update den Snapshot gelöscht, den der User eben selbst angelegt hat."""
|
||||
g = host.gast(104)
|
||||
g["snapshots"] = [GITEA_SNAP, "mc2-20260925-120000", "vor-dem-umbau"]
|
||||
g["snapshot_details"] = [{"name": "mc2-20260925-120000", "zeit": 1790330400, "beschreibung": KNOPF, "eigen": True}]
|
||||
g["app"]["version"] = "1.27.3" # der Bericht nach dem Update: neue Version
|
||||
kanal.bericht_speichern(_bericht())
|
||||
lauf = _auf_ende(updates.starten("ct-104", "app"))
|
||||
assert lauf["ergebnis"] == "eingespielt"
|
||||
geloescht = [p["snapshot"] for aktion, p in host.auftraege if aktion == "snapshot_loeschen"]
|
||||
assert geloescht == [GITEA_SNAP] # der per Knopf und der von Hand bleiben
|
||||
|
||||
|
||||
def test_snapshot_loeschen_nur_eigene(host):
|
||||
antwort = snapshots.loeschen("ct-104", "vor-dem-umbau")
|
||||
assert antwort["ok"] is False and "von Hand angelegte verwaltest du in Proxmox" in antwort["detail"]
|
||||
assert "gibt es laut dem letzten Bericht nicht" in snapshots.loeschen("ct-104", "mc2-20200101-000000")["detail"]
|
||||
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||
assert (lauf["ergebnis"], lauf["text"]) == ("geloescht", f"Gitea: Snapshot {GITEA_SNAP} gelöscht.")
|
||||
assert host.auftraege == [("snapshot_loeschen", {"vmid": 104, "snapshot": GITEA_SNAP}), ("bericht", {})]
|
||||
assert [s["name"] for s in _geraete(snapshots.liste())["ct-104"]["snapshots"]] == ["vor-dem-umbau"]
|
||||
|
||||
|
||||
def test_zuruecksetzen_gruen_mit_meldung(host):
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "zurueckgesetzt" and lauf["zurueckgesetzt"] is True
|
||||
assert [a for a, _ in host.auftraege] == ["zurueck", "bericht"]
|
||||
assert host.auftraege[0][1] == {"vmid": 104, "snapshot": GITEA_SNAP}
|
||||
text = f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."
|
||||
assert host.meldungen == [(snapshots.BETREFF, text)]
|
||||
assert host.verlauf == []
|
||||
|
||||
|
||||
def test_zuruecksetzen_rot_meldet_dringend(host, monkeypatch):
|
||||
monkeypatch.setattr(inventar, "erreichbar", lambda url: False)
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert (lauf["ergebnis"], lauf["zurueckgesetzt"], lauf["dringend"]) == ("fehler", True, True)
|
||||
betreff, text = host.meldungen[-1]
|
||||
assert betreff == snapshots.BETREFF_ALARM and "zurückgesetzt, aber die Prüfung ist rot" in text
|
||||
assert "antwortet nicht" in text
|
||||
eintrag = next(e for e in protokoll.eintraege() if e["art"] == "snapshot")
|
||||
assert (eintrag["titel"], eintrag["stufe"], eintrag["ziel"]) == ("Zurückgesetzt, Prüfung rot · Gitea", "fehler",
|
||||
"ct-104")
|
||||
|
||||
|
||||
def test_zuruecksetzen_scheitert_und_sagt_wie_es_dem_geraet_geht(host):
|
||||
host.fehlschlag["zurueck"] = (1, "unable to rollback: snapshot is locked")
|
||||
lauf = _auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "fehler" and not lauf.get("zurueckgesetzt")
|
||||
betreff, text = host.meldungen[-1]
|
||||
assert betreff == snapshots.BETREFF_ALARM
|
||||
assert text.startswith(f"Gitea: Zurücksetzen auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) gescheitert")
|
||||
assert "snapshot is locked" in text and text.endswith("Gitea läuft.")
|
||||
|
||||
|
||||
def test_sicherung_loeschen(host):
|
||||
assert "gibt es laut dem letzten Bericht nicht" in snapshots.sicherung_loeschen("ct-105", "x.tar.zst")["detail"]
|
||||
lauf = _auf_ende(snapshots.sicherung_loeschen("ct-105", "vzdump-lxc-105-2026_09_25-11_29_57.tar.zst"))
|
||||
assert lauf["ergebnis"] == "geloescht" and host.auftraege[0] == ("sicherung_loeschen", {"vmid": 105, "sicherung": ALT})
|
||||
assert _geraete(snapshots.liste())["ct-105"]["sicherungen"] == []
|
||||
|
||||
|
||||
def test_gescheitertes_loeschen_meldet_ohne_alarm(host):
|
||||
host.fehlschlag["snapshot_loeschen"] = (2, "Abgelehnt: Gast 104 hat kein erlaubtes Etikett")
|
||||
lauf = _auf_ende(snapshots.loeschen("ct-104", GITEA_SNAP))
|
||||
assert lauf["ergebnis"] == "fehler" and host.meldungen[-1][0] == snapshots.BETREFF
|
||||
assert "ließ sich nicht löschen" in host.meldungen[-1][1] and "kein erlaubtes Etikett" in host.meldungen[-1][1]
|
||||
assert _geraete(snapshots.liste())["ct-104"]["zuletzt"]["ok"] is False
|
||||
|
||||
|
||||
def test_sperren_wie_bei_den_updates(host, monkeypatch):
|
||||
# Nicht freigegeben, der eigene Container, ein unbekanntes Gerät
|
||||
assert snapshots.anlegen("vm-106")["detail"].startswith("Dieses Gerät ist nicht freigegeben")
|
||||
assert snapshots.anlegen("ct-107")["detail"] == "Dieses Gerät steht nicht im Bericht des Ausführers."
|
||||
assert snapshots.anlegen("ct-999")["ok"] is False
|
||||
# Kein Snapshot möglich (PBS: Bind-Mount)
|
||||
assert snapshots.anlegen("ct-105")["detail"].startswith("Kein Snapshot möglich: Bind-Mount mp0")
|
||||
# Während „Alle aktualisieren“ — die Seite sagt denselben Satz über der Liste.
|
||||
with monkeypatch.context() as m:
|
||||
m.setattr(sammellauf, "laeuft", lambda: True)
|
||||
assert snapshots.anlegen("ct-104")["detail"].startswith(updates.SAMMELLAUF_SPERRE)
|
||||
assert snapshots.liste()["sperre"] == snapshots.anlegen("ct-104")["detail"]
|
||||
# Läuft irgendwo ein Update, warten alle Aktionen (sonst stünden sie beim Ausführer hinter ihm an) …
|
||||
updates._merken({"id": "u1", "ziel": "ct-100", "baustein": "os", "name": "AdGuard Home", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["os_update …"]})
|
||||
assert snapshots.zuruecksetzen("ct-104", GITEA_SNAP)["detail"].startswith(
|
||||
"Gerade läuft ein Update (AdGuard Home).")
|
||||
assert _geraete(snapshots.liste())["ct-100"]["laeuft"] == "Update läuft"
|
||||
updates._merken({**updates.lauf_lesen("u1"), "status": "fertig", "ende": time.time(), "ergebnis": "eingespielt"})
|
||||
# … eine Aktion dieser Seite dagegen sperrt nur ihr Gerät; dort lehnen beide Seiten ab.
|
||||
updates._merken({"id": "x1", "ziel": "ct-104", "baustein": "snapshot-zurueck", "name": "Gitea", "status": "laeuft",
|
||||
"start": time.time(), "ende": None, "ergebnis": None, "text": None, "schritte": ["zurueck: ok"]})
|
||||
assert "läuft gerade schon etwas" in snapshots.loeschen("ct-104", GITEA_SNAP)["detail"]
|
||||
assert updates.starten("ct-104", "os")["ok"] is False
|
||||
assert snapshots.liste()["sperre"] is None
|
||||
assert _geraete(snapshots.liste())["ct-104"]["laeuft"] == "Zurückgesetzt, wird geprüft"
|
||||
assert "ct-104" in updates.laufende_ziele() # der Wächter lässt das Gerät in Ruhe
|
||||
# Ausführer im Nur-Lesen-Modus
|
||||
nur_lesen = _bericht()
|
||||
nur_lesen["host"]["nur_lesen"] = True
|
||||
kanal.bericht_speichern(nur_lesen)
|
||||
assert "Nur-Lesen-Modus" in snapshots.anlegen("ct-102")["detail"]
|
||||
assert host.auftraege == []
|
||||
|
||||
|
||||
def test_protokoll_und_betreff(host):
|
||||
_auf_ende(snapshots.anlegen("ct-104"))
|
||||
_auf_ende(snapshots.zuruecksetzen("ct-104", GITEA_SNAP))
|
||||
eintraege = [e for e in protokoll.eintraege() if e["art"] == "snapshot"]
|
||||
assert [(e["titel"], e["stufe"]) for e in eintraege] == [("Zurückgesetzt, Prüfung grün · Gitea", "ok"),
|
||||
("Snapshot angelegt · Gitea", "ok")]
|
||||
assert eintraege[1]["details"].endswith("Ohne Meldung: Das Ergebnis steht auf der Seite Snapshots.")
|
||||
# Die Auftrags-Einträge heißen nicht mehr „Alter …“: Gelöscht wird auch von Hand.
|
||||
assert protokoll._AKTION["snapshot_loeschen"][0] == "Snapshot gelöscht"
|
||||
bekannt = protokoll._betreffe(updates.laeufe(100), [])
|
||||
assert bekannt[f"Gitea: auf den Snapshot vom 25.09.2026 11:26 ({GITEA_SNAP}) zurückgesetzt, Prüfung grün."] == (
|
||||
snapshots.BETREFF)
|
||||
|
||||
|
||||
def test_schnittstelle(host):
|
||||
from routers import homelab
|
||||
app = FastAPI()
|
||||
app.include_router(homelab.router)
|
||||
c = TestClient(app)
|
||||
daten = c.get("/api/homelab/snapshots").json()
|
||||
assert [g["id"] for g in daten["geraete"]][:2] == ["ct-100", "ct-101"] and daten["summe"]["eigene"] == 1
|
||||
antwort = c.post("/api/homelab/snapshots/ct-104/vor-dem-umbau/loeschen")
|
||||
assert antwort.status_code == 409 and "Proxmox" in antwort.json()["detail"]
|
||||
assert c.post("/api/homelab/sicherungen/ct-105/fremd.tar.zst/loeschen").status_code == 409
|
||||
antwort = c.post("/api/homelab/snapshots/ct-102/anlegen")
|
||||
assert antwort.status_code == 200 and antwort.json()["ok"] is True
|
||||
lauf = _auf_ende(antwort.json())
|
||||
assert lauf["ergebnis"] == "angelegt" and lauf["name"] == "NetBird"
|
||||
@@ -168,7 +168,31 @@ def test_empfehlungen_groesster_gewinn_zuerst():
|
||||
container = tipps[1]
|
||||
assert container["platz"] == (2_076_616_687 - 888_725_504) + (6_442_450_944 - 4_867_121_152)
|
||||
assert "pct fstrim 104 --ignore-mountpoints" in container["text"] and "Gitea 1,6 GB" in container["text"]
|
||||
assert tipps[2]["titel"] == "Gitea: Snapshot mc2-20260924-192352" and "vom 24.09. 19:23" in tipps[2]["text"]
|
||||
assert tipps[2]["titel"] == "1 Snapshot des Orchestrators — verwalten unter Homelab → Snapshots"
|
||||
assert tipps[2]["text"].startswith("Bei Gitea.") and tipps[2]["platz"] is None
|
||||
|
||||
|
||||
def test_snapshots_sind_ein_vorschlag():
|
||||
"""25.09.2026: Nach „Alle aktualisieren“ standen fünf Snapshot-Vorschläge untereinander — jetzt einer, der auf die
|
||||
Seite Snapshots zeigt. Von Hand angelegte zählen mit, der Container des Orchestrators selbst nicht."""
|
||||
b = _bericht()
|
||||
b["gaeste"][0]["snapshots"] = ["mc2-20260925-112118", "vor-dem-umbau"]
|
||||
b["gaeste"][1]["snapshots"] = ["mc2-20260924-192352", "mc2-20260925-112639"]
|
||||
b["gaeste"].append({"vmid": 107, "art": "lxc", "name": "homelab-orchestrator", "etiketten": ["mc2"],
|
||||
"snapshots": ["test"], "ungenutzt": []})
|
||||
tipps = [t for t in speicher.empfehlungen(b) if t["art"] == "snapshot"]
|
||||
assert len(tipps) == 1
|
||||
s = tipps[0]
|
||||
assert s["titel"] == "3 Snapshots des Orchestrators — verwalten unter Homelab → Snapshots"
|
||||
assert s["text"].startswith("Bei NetBird und Gitea (2).")
|
||||
assert "Dazu 1 von Hand in Proxmox angelegt (NetBird)." in s["text"] and "homelab" not in s["text"]
|
||||
assert s["kurz"] == "4 Snapshots (Homelab → Snapshots)"
|
||||
b["gaeste"][1]["snapshots"] = []
|
||||
b["gaeste"][0]["snapshots"] = ["vor-dem-umbau"]
|
||||
assert [t["titel"] for t in speicher.empfehlungen(b) if t["art"] == "snapshot"] == [
|
||||
"1 von Hand angelegter Snapshot — ansehen unter Homelab → Snapshots"]
|
||||
b["gaeste"][0]["snapshots"] = []
|
||||
assert all(t["art"] != "snapshot" for t in speicher.empfehlungen(b))
|
||||
|
||||
|
||||
def test_kleine_gewinne_sind_keine_empfehlung():
|
||||
|
||||
Reference in New Issue
Block a user