homelab: eigener Reiter „Snapshots“ mit Verwaltung (anlegen, löschen, zurücksetzen)
Die Seite /homelab/snapshots (in der Seitenleiste nach „Updates“: Snapshots sind der Rückweg der Updates) zeigt je Gerät die Snapshots mit Zeitpunkt, Alter, Herkunft (vor Update / per Knopf / von Hand) und Beschreibung, beim PBS die Sicherungen des Orchestrators mit Größe. Knöpfe „Snapshot anlegen“, „Zurücksetzen“ und „Löschen“ gibt es nur für freigegebene Geräte und nur für mc2-Snapshots bzw. eigene Sicherungen; von Hand angelegte bleiben reine Ansicht. Die Rückfragen kommen vom Homelab-Teil (wie im Ziel-Modell); Zurücksetzen sagt vorher, was verloren geht und dass das Gerät neu startet. Am Handy (320–400 px) untereinander, ab md Knöpfe rechts, ab xl fünf Spalten. Entscheide: - Ausführer: kein neuer Befehl. Der Bericht bringt zusätzlich snapshot_details (snaptime, Beschreibung, eigen) und sicherung_details (ctime, Größe); snapshots und sicherungen bleiben unverändert. Keine Belegung je Snapshot — die kennt Proxmox bei LVM-Thin nicht, die Seite sagt das so. „snapshot“ nimmt einen Anlass (update oder knopf) und wählt damit eine feste Beschreibung, damit ein Snapshot auf Knopfdruck nicht „Vor einem Update“ heißt. Bis der neue Ausführer eingespielt ist, liest der Homelab-Teil den Zeitpunkt aus dem Namen. - Aktionen sind Läufe in homelab-laeufe.json (Mechanik aus updates.py): gleiche Sperren (START_SPERRE, je Gerät einer, SAMMELLAUF_SPERRE), Protokoll-Art „snapshot“ mit eigenen Titeln, der Wächter lässt ein Gerät im Zurücksetzen in Ruhe. Zusätzlich keine Aktion, solange irgendwo ein Update läuft: Der Ausführer arbeitet einen Auftrag nach dem anderen ab — hinter einem Update liefe die Aktion in ihr Zeitlimit und käme danach unbeobachtet doch noch dran. - Zurücksetzen prüft danach wie ein Update und meldet immer ([Homelab-Snapshot], bei Rot als Alarm); anlegen und löschen melden nur ihr Scheitern, das Ergebnis steht beim Gerät. Nicht im Update-Verlauf, nicht unter „Zuletzt eingespielt“. - Per Knopf angelegte Snapshots räumt kein grünes Update weg (updates._aufraeumen fragt snapshots.knopf_namen). - speicher.py: alle Snapshots in einem Vorschlag mit Verweis auf die Seite statt einer je Snapshot. - Nebenbei: Der Update-Verlauf stürzte bei einem Ergebnis ohne Eintrag ab (etwa „offen“ nach einem Docker-Probelauf); die Protokoll-Seite stürzte im Entwicklungsmodus ab (Konstante „Symbol“ gegen das Symbol.for des React Compilers); Auftragstitel „Alter Snapshot gelöscht“ heißt jetzt „Snapshot gelöscht“ (gelöscht wird auch von Hand). Tests: pytest test_homelab_snapshots.py (Ausführer-Details, Liste, Läufe mit nachgespieltem Ausführer, Sperren, Aufräumen, Schnittstelle), test_homelab_speicher.py; Vitest SnapshotListe, lib/snapshots, Speicher. Prüftor grün. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2784c0ea32
commit
450c782ed3
@@ -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