homelab: Wartezeit nach Skriptaenderung, PBS mit Sicherung statt Snapshot, woechentliches Suchen
- karenz.py: ct/<app>.sh juenger als MC_HOMELAB_KARENZ_H (Standard 48 h) -> Baustein "neu" ohne Knopf, updates.starten lehnt mit demselben Satz ab (Berliner Zeit). GitHub stumm -> nicht blockieren, die Rueckfrage sagt es. - Ausfuehrer: neue Aktionen sichern, sicherung_zurueck, sicherung_loeschen. vzdump auf den ersten lokalen Speicher mit Inhalt backup (oder sicherung_speicher aus /etc/mc2-ausfuehrer.json), nie auf pbs; vorher Platz pruefen (frei > belegt x 1,2); Notiz mc2-sicherung, nur solche werden zurueckgespielt/geloescht; Rueckweg: stoppen, pct restore --force auf den bisherigen rootfs-Speicher, starten. Bericht mit host.sicherung, sicherung_moeglich/_grund, eigenen Sicherungen und nur_lesen. Unerwartete Fehler werden beantwortet statt verschluckt; vzdump/restore beim Zeitlimit erst SIGTERM. - updates.py: Sicherung, wo kein Snapshot geht; bei Rot zurueckspielen, nach Gruen aeltere Sicherungen weg. Scheitert Snapshot oder Sicherung, beginnt das Update nicht (nicht dringend gemeldet). - pflege.py: "suchen" fuer Gaeste mit Paketlisten aelter als 7 Tage, nachts 02-05 Uhr (sonst nachholen), einmal je Gast und Tag, nie neben einem Update oder offenen Auftrag; gelber Waechter-Hinweis, wenn es zweimal hintereinander scheitert. Eine Registrierungszeile in waechter.py. - kanal.py: Auftragsliste unter flock, weil jetzt auch der Steward Auftraege anlegt. - inventar.py: Rueckweg und Rueckfrage fuer die Sicherung; Gaeste mitten im Update-Lauf nicht in der Webpruefung des Waechters (sonst zweiter Alarm beim Zurueckspielen). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
40d2840b16
commit
f2733f44fe
+269
-17
@@ -6,15 +6,22 @@ beim Homelab-Teil ab („Pull“, User-Entscheid 24.09.): Der Proxmox-Host öffn
|
||||
Container des Homelab-Teils braucht keinen Proxmox-Schlüssel.
|
||||
|
||||
• Bericht alle 10 Minuten (oder auf Anfrage): Host-Updates, Gäste mit Status, App-Version,
|
||||
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht. Nur lesend.
|
||||
OS-Updates, Snapshots und ob ein Snapshot überhaupt geht; wo nicht, ob eine Sicherung
|
||||
geht, und die Sicherungen des Orchestrators. Nur lesend.
|
||||
• Aufträge eine feste Liste von Aktionen (AKTIONEN); alles andere wird abgelehnt. Welche Gäste
|
||||
angefasst werden dürfen, entscheidet dieser Host selbst anhand der Etiketten:
|
||||
community-script oder watcher, aber nicht watcher-aus. Dem Server wird dabei nicht
|
||||
vertraut — er kann nur aus der Liste wählen.
|
||||
• Sicherung wo kein Snapshot geht (PBS: Bind-Mount), vzdump auf einen lokalen Speicher des Hosts —
|
||||
nie auf einen Speicher der Art pbs, sonst sicherte sich der PBS selbst. Nur eigene
|
||||
Sicherungen (Notiz „mc2-sicherung“) werden zurückgespielt oder gelöscht. An der
|
||||
Speicher-Konfiguration ändert der Ausführer nichts.
|
||||
|
||||
Nur die Standardbibliothek (Debian-Python des Hosts). Konfiguration: /etc/mc2-ausfuehrer.json
|
||||
{"server": "http://192.168.178.x:9001", "token": "<gemeinsames Geheimnis>", "node": "pve", "nur_lesen": false}
|
||||
„nur_lesen": true liefert nur den Bericht und lehnt jeden anderen Auftrag ab.
|
||||
„nur_lesen": true liefert nur den Bericht und lehnt jeden anderen Auftrag ab. Optional
|
||||
„sicherung_speicher": "<Speicher>" legt fest, wohin die Sicherungen gehen (sonst der erste lokale Speicher,
|
||||
der Sicherungen annimmt). ausfuehrer-einrichten.sh schreibt die Datei neu; den Schlüssel danach wieder eintragen.
|
||||
|
||||
Aufruf: ausfuehrer.py Dauerbetrieb (systemd)
|
||||
ausfuehrer.py --bericht Bericht einmal auf die Konsole (nur lesend, zum Prüfen)
|
||||
@@ -42,6 +49,17 @@ SNAPSHOT_NAME = re.compile(r"^mc2-\d{8}-\d{6}$")
|
||||
# Speicherarten, auf denen Proxmox Snapshots kann.
|
||||
SNAPSHOT_SPEICHER = {"lvmthin", "zfspool", "rbd", "btrfs", "cephfs"}
|
||||
|
||||
# Sicherungen vor einem Update, wo kein Snapshot geht (24.09.2026). Jede trägt diese Notiz; nur solche fasst der
|
||||
# Ausführer später wieder an (zurückspielen, löschen).
|
||||
SICHERUNG_KENNUNG = "mc2-sicherung"
|
||||
SICHERUNG_NOTIZ = SICHERUNG_KENNUNG + ": vor einem Update durch den Homelab Orchestrator ({{guestname}})"
|
||||
SICHERUNG_ARCHIV = re.compile(
|
||||
r"^([A-Za-z][\w.-]*):backup/vzdump-lxc-(\d+)-\d{4}_\d{2}_\d{2}-\d{2}_\d{2}_\d{2}\.tar(?:\.(?:zst|gz|lzo))?$")
|
||||
# Dateispeicher auf dem Host selbst. Netzspeicher nur, wenn „sicherung_speicher“ es so will — pbs nie.
|
||||
LOKALE_SICHERUNGSARTEN = {"dir", "btrfs"}
|
||||
PLATZ_FAKTOR = 1.2
|
||||
ZEITLIMIT_SICHERUNG_S = 30 * 60
|
||||
|
||||
# App-Version je Community-Script (Kennung aus /usr/bin/update im Gast). Neuere Skripte legen die Version
|
||||
# in /root/.<app> ab; für die älteren steht hier, wie man sie erfährt. Festes Wissen dieses Hosts.
|
||||
VERSION_PROBEN = {
|
||||
@@ -59,14 +77,27 @@ log = logging.getLogger("ausfuehrer")
|
||||
|
||||
# --- Hilfen -----------------------------------------------------------------------------
|
||||
|
||||
def _laufen(befehl: list[str], zeitlimit: int = 60) -> tuple[int, str]:
|
||||
def _laufen(befehl: list[str], zeitlimit: int = 60, sanft: bool = False) -> tuple[int, str]:
|
||||
"""Befehl mit Zeitlimit. sanft (vzdump, pct restore): beim Zeitlimit zuerst SIGTERM — dann räumen sie Sperre
|
||||
und Snapshot selbst weg; hart abgebrochen wird erst zwei Minuten später."""
|
||||
try:
|
||||
r = subprocess.run(befehl, capture_output=True, text=True, timeout=zeitlimit)
|
||||
except subprocess.TimeoutExpired:
|
||||
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
|
||||
prozess = subprocess.Popen(befehl, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
||||
errors="replace")
|
||||
except OSError as exc:
|
||||
return 127, str(exc)
|
||||
return r.returncode, (r.stdout + r.stderr).strip()
|
||||
try:
|
||||
aus, fehler = prozess.communicate(timeout=zeitlimit)
|
||||
except subprocess.TimeoutExpired:
|
||||
if sanft:
|
||||
prozess.terminate()
|
||||
try:
|
||||
prozess.communicate(timeout=120)
|
||||
except subprocess.TimeoutExpired:
|
||||
pass
|
||||
prozess.kill()
|
||||
prozess.communicate()
|
||||
return 124, f"Zeitlimit ({zeitlimit} s) überschritten"
|
||||
return prozess.returncode, (aus + fehler).strip()
|
||||
|
||||
|
||||
def _pvesh(pfad: str, *parameter: str) -> object:
|
||||
@@ -91,11 +122,16 @@ def erlaubt(etiketten: list[str]) -> bool:
|
||||
|
||||
# --- Bericht (nur lesend) -------------------------------------------------------------------
|
||||
|
||||
def _speicherart() -> dict[str, str]:
|
||||
def _speicher_liste() -> list[dict]:
|
||||
"""Die Speicher des Hosts (Name, Art, Inhalt, frei …) — leer, wenn sie sich nicht lesen lassen."""
|
||||
try:
|
||||
return {s["storage"]: s["type"] for s in _pvesh(f"/nodes/{NODE}/storage")}
|
||||
return [s for s in _pvesh(f"/nodes/{NODE}/storage") or [] if isinstance(s, dict) and s.get("storage")]
|
||||
except Exception:
|
||||
return {}
|
||||
return []
|
||||
|
||||
|
||||
def _speicherart() -> dict[str, str]:
|
||||
return {s["storage"]: s.get("type") for s in _speicher_liste()}
|
||||
|
||||
|
||||
def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str | None]:
|
||||
@@ -112,6 +148,117 @@ def snapshot_moeglich(konfig: dict, arten: dict[str, str]) -> tuple[bool, str |
|
||||
return True, None
|
||||
|
||||
|
||||
# --- Sicherung statt Snapshot (reine Auswertungen, ohne Host testbar) ---------------------------
|
||||
|
||||
def _inhalt(speicher: dict) -> set[str]:
|
||||
return {x.strip() for x in str(speicher.get("content") or "").split(",") if x.strip()}
|
||||
|
||||
|
||||
def sicherungsspeicher(speicher: list[dict], wunsch: str | None = None) -> tuple[dict | None, str | None]:
|
||||
"""Wohin die Sicherung vor einem Update geht: (Speicher, None) oder (None, was fehlt). Nie auf einen Speicher
|
||||
der Art pbs (der PBS würde sich selbst sichern). Ohne Wunsch der erste lokale Speicher, der Sicherungen annimmt.
|
||||
Die Speicher-Konfiguration selbst ist Sache des Users; hier wird nur gewählt oder abgelehnt."""
|
||||
if not speicher:
|
||||
return None, "Die Speicher des Proxmox-Hosts ließen sich nicht lesen."
|
||||
if wunsch:
|
||||
s = next((x for x in speicher if x.get("storage") == wunsch), None)
|
||||
if s is None:
|
||||
return None, (f"Den eingestellten Sicherungsspeicher „{wunsch}“ (sicherung_speicher in {KONFIG}) "
|
||||
"gibt es nicht.")
|
||||
if s.get("type") == "pbs":
|
||||
return None, (f"„{wunsch}“ ist ein Proxmox-Backup-Server-Speicher. Dorthin sichert der Orchestrator nie — "
|
||||
"der PBS würde sich selbst sichern.")
|
||||
if "backup" not in _inhalt(s):
|
||||
return None, f"„{wunsch}“ nimmt keine Sicherungen an (Inhalt „VZDump-Sicherung“ fehlt)."
|
||||
if not s.get("active"):
|
||||
return None, f"Der Sicherungsspeicher „{wunsch}“ ist gerade nicht verfügbar."
|
||||
return s, None
|
||||
for s in speicher:
|
||||
if (s.get("type") in LOKALE_SICHERUNGSARTEN and not s.get("shared") and s.get("active")
|
||||
and "backup" in _inhalt(s)):
|
||||
return s, None
|
||||
return None, ("Kein lokaler Speicher des Proxmox-Hosts nimmt Sicherungen an. In Proxmox unter Rechenzentrum → "
|
||||
"Speicher bei einem lokalen Verzeichnis (z. B. „local“) den Inhalt „VZDump-Sicherung“ zulassen oder "
|
||||
f"„sicherung_speicher“ in {KONFIG} eintragen.")
|
||||
|
||||
|
||||
def sicherung_stand(speicher: list[dict], wunsch: str | None = None) -> dict:
|
||||
"""Für den Bericht: {"speicher", "art", "frei"} oder {"speicher": None, "fehler": was fehlt}."""
|
||||
s, fehlt = sicherungsspeicher(speicher, wunsch)
|
||||
if s is None:
|
||||
return {"speicher": None, "fehler": fehlt}
|
||||
return {"speicher": s["storage"], "art": s.get("type"), "frei": s.get("avail")}
|
||||
|
||||
|
||||
_EINHEIT = {"": 1, "K": 1024, "M": 1024 ** 2, "G": 1024 ** 3, "T": 1024 ** 4}
|
||||
|
||||
|
||||
def _groesse(text: str) -> int | None:
|
||||
"""„10G“ → Bytes (Größenangaben der Proxmox-Konfiguration)."""
|
||||
m = re.fullmatch(r"(\d+(?:\.\d+)?)([KMGT]?)", str(text or "").strip().upper())
|
||||
return int(float(m.group(1)) * _EINHEIT[m.group(2)]) if m else None
|
||||
|
||||
|
||||
def _volume(wert: object) -> tuple[str, dict[str, str]]:
|
||||
teile = str(wert or "").split(",")
|
||||
return teile[0], dict(t.split("=", 1) for t in teile[1:] if "=" in t)
|
||||
|
||||
|
||||
def sicherung_bedarf(konfig: dict, belegt_rootfs: int | None) -> int | None:
|
||||
"""Wie viel die Sicherung höchstens umfasst: die belegte rootfs (läuft der Gast nicht: ihre volle Größe) plus
|
||||
weitere Volumes mit backup=1. Bind-Mounts sichert vzdump nie mit — der Datenspeicher des PBS bleibt draußen."""
|
||||
_, optionen = _volume(konfig.get("rootfs"))
|
||||
bedarf = belegt_rootfs or _groesse(optionen.get("size", ""))
|
||||
if not bedarf:
|
||||
return None
|
||||
for schluessel, wert in konfig.items():
|
||||
if re.fullmatch(r"mp\d+", schluessel):
|
||||
quelle, optionen = _volume(wert)
|
||||
if not quelle.startswith("/") and optionen.get("backup") in ("1", "yes", "on", "true"):
|
||||
bedarf += _groesse(optionen.get("size", "")) or 0
|
||||
return bedarf
|
||||
|
||||
|
||||
def platz_reicht(frei: int | None, bedarf: int | None) -> bool:
|
||||
return frei is not None and bedarf is not None and frei > bedarf * PLATZ_FAKTOR
|
||||
|
||||
|
||||
def _gb(anzahl: float | None) -> str:
|
||||
return f"{(anzahl or 0) / 1024 ** 3:.1f} GB".replace(".", ",")
|
||||
|
||||
|
||||
def sicherung_pruefen(stand: dict, bedarf: int | None) -> tuple[bool, str | None]:
|
||||
"""Geht vor dem Update eine Sicherung? stand wie sicherung_stand(); vorher Platz prüfen: frei > belegt × 1,2."""
|
||||
if not stand.get("speicher"):
|
||||
return False, stand.get("fehler") or "Es gibt keinen Speicher für Sicherungen."
|
||||
if not bedarf:
|
||||
return False, "Wie viel Platz der Gast belegt, ließ sich nicht lesen."
|
||||
if not platz_reicht(stand.get("frei"), bedarf):
|
||||
return False, (f"Zu wenig Platz auf „{stand['speicher']}“: frei {_gb(stand.get('frei'))}, nötig mehr als "
|
||||
f"{_gb(bedarf * PLATZ_FAKTOR)} (belegt {_gb(bedarf)} × 1,2).")
|
||||
return True, None
|
||||
|
||||
|
||||
def sicherung_modus(konfig: dict, arten: dict[str, str]) -> str:
|
||||
"""snapshot, wenn die rootfs Snapshots kann: Der Gast läuft durch, Bind-Mounts lässt vzdump aus. Sonst stop —
|
||||
kurz aus, braucht aber keinen Zwischenspeicher wie „suspend“, auf den vzdump sonst ausweicht."""
|
||||
speicher = str(konfig.get("rootfs") or "").split(":")[0]
|
||||
return "snapshot" if arten.get(speicher) in SNAPSHOT_SPEICHER else "stop"
|
||||
|
||||
|
||||
def eigene_sicherungen(eintraege: object, vmid: int | None = None) -> list[dict]:
|
||||
"""Nur Sicherungen des Orchestrators (Notiz beginnt mit mc2-sicherung, Archiv vzdump-lxc-<vmid>-…), älteste
|
||||
zuerst; mit vmid nur die dieses Gasts."""
|
||||
eigene = []
|
||||
for e in eintraege if isinstance(eintraege, list) else []:
|
||||
m = SICHERUNG_ARCHIV.match(str(e.get("volid") or "")) if isinstance(e, dict) else None
|
||||
if not m or not str(e.get("notes") or "").startswith(SICHERUNG_KENNUNG):
|
||||
continue
|
||||
if vmid is None or int(m.group(2)) == vmid:
|
||||
eigene.append(e)
|
||||
return sorted(eigene, key=lambda e: (e.get("ctime") or 0, e["volid"]))
|
||||
|
||||
|
||||
def app_kennung_aus(update_skript: str) -> str | None:
|
||||
"""Kennung des Community-Scripts aus /usr/bin/update. Zwei Formate: das alte lädt ct/<kennung>.sh direkt,
|
||||
das neue (schreibt jedes erfolgreiche Update seit 09/2026) setzt `export SCRIPT_SLUG="<kennung>"`."""
|
||||
@@ -191,12 +338,17 @@ def _snapshots(art: str, vmid: int) -> list[str]:
|
||||
return []
|
||||
|
||||
|
||||
def _gast(eintrag: dict, arten: dict[str, str]) -> dict:
|
||||
def _eigene_sicherungen(speicher: str, vmid: int | None = None) -> list[dict]:
|
||||
parameter = ["--content", "backup"] + (["--vmid", str(vmid)] if vmid is not None else [])
|
||||
return eigene_sicherungen(_pvesh(f"/nodes/{NODE}/storage/{speicher}/content", *parameter), vmid)
|
||||
|
||||
|
||||
def _gast(eintrag: dict, arten: dict[str, str], sicherung: dict, sicherungen: dict[int, list[str]]) -> dict:
|
||||
art, vmid = eintrag["type"], int(eintrag["vmid"])
|
||||
etiketten = _etiketten(eintrag.get("tags"))
|
||||
gast = {"vmid": vmid, "art": art, "name": eintrag.get("name"), "status": eintrag.get("status"),
|
||||
"etiketten": etiketten, "erlaubt": erlaubt(etiketten), "uptime": eintrag.get("uptime"),
|
||||
"snapshots": _snapshots(art, vmid)}
|
||||
"snapshots": _snapshots(art, vmid), "sicherungen": sicherungen.get(vmid, [])}
|
||||
try:
|
||||
konfig = _pvesh(f"/nodes/{NODE}/{art}/{vmid}/config")
|
||||
except Exception as exc:
|
||||
@@ -204,6 +356,13 @@ def _gast(eintrag: dict, arten: dict[str, str]) -> dict:
|
||||
return gast
|
||||
gast["onboot"] = bool(konfig.get("onboot"))
|
||||
gast["snapshot_moeglich"], gast["snapshot_grund"] = snapshot_moeglich(konfig, arten)
|
||||
if art == "lxc" and not gast["snapshot_moeglich"]:
|
||||
# Kein Snapshot: Geht wenigstens eine Sicherung (Speicher da, genug Platz)?
|
||||
belegt = eintrag.get("disk") if gast["status"] == "running" else None
|
||||
gast["sicherung_moeglich"], gast["sicherung_grund"] = sicherung_pruefen(
|
||||
sicherung, sicherung_bedarf(konfig, belegt))
|
||||
if gast["sicherung_moeglich"]:
|
||||
gast["sicherung_speicher"] = sicherung["speicher"]
|
||||
if art == "lxc":
|
||||
gast["ostype"] = konfig.get("ostype")
|
||||
gast["ip"] = _ip_lxc(vmid) if gast["status"] == "running" else None
|
||||
@@ -217,7 +376,7 @@ def _gast(eintrag: dict, arten: dict[str, str]) -> dict:
|
||||
|
||||
|
||||
def _host() -> dict:
|
||||
host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required")}
|
||||
host: dict = {"node": NODE, "neustart_noetig": os.path.exists("/var/run/reboot-required"), "nur_lesen": NUR_LESEN}
|
||||
try:
|
||||
host["version"] = _pvesh("/version").get("version")
|
||||
host["updates"] = [{"paket": p.get("Package"), "alt": p.get("OldVersion"), "neu": p.get("Version"),
|
||||
@@ -230,12 +389,23 @@ def _host() -> dict:
|
||||
|
||||
|
||||
def bericht() -> dict:
|
||||
arten = _speicherart()
|
||||
speicher = _speicher_liste()
|
||||
arten = {s["storage"]: s.get("type") for s in speicher}
|
||||
sicherung = sicherung_stand(speicher, SICHERUNG_SPEICHER)
|
||||
sicherungen: dict[int, list[str]] = {}
|
||||
if sicherung.get("speicher"):
|
||||
try:
|
||||
for e in _eigene_sicherungen(sicherung["speicher"]):
|
||||
sicherungen.setdefault(int(SICHERUNG_ARCHIV.match(e["volid"]).group(2)), []).append(e["volid"])
|
||||
except Exception:
|
||||
pass
|
||||
gaeste = []
|
||||
for eintrag in _pvesh("/cluster/resources", "--type", "vm") or []:
|
||||
if eintrag.get("node") == NODE and not eintrag.get("template"):
|
||||
gaeste.append(_gast(eintrag, arten))
|
||||
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": _host(),
|
||||
gaeste.append(_gast(eintrag, arten, sicherung, sicherungen))
|
||||
host = _host()
|
||||
host["sicherung"] = sicherung
|
||||
return {"zeit": datetime.now(timezone.utc).isoformat(timespec="seconds"), "host": host,
|
||||
"gaeste": sorted(gaeste, key=lambda g: g["vmid"])}
|
||||
|
||||
|
||||
@@ -312,6 +482,82 @@ def a_snapshot_loeschen(p: dict) -> tuple[int, str]:
|
||||
return _laufen([_werkzeug(art), "delsnapshot", str(vmid), name], 600)
|
||||
|
||||
|
||||
def _eigene_sicherung(vmid: int, volid: object) -> str:
|
||||
"""Die Sicherung, wenn sie eine des Orchestrators für genau diesen Gast ist — sonst ValueError. Nur solche
|
||||
werden zurückgespielt oder gelöscht: Archiv vzdump-lxc-<vmid>-…, Notiz mc2-sicherung, kein PBS-Speicher."""
|
||||
volid = str(volid or "")
|
||||
m = SICHERUNG_ARCHIV.match(volid)
|
||||
if not m or int(m.group(2)) != vmid:
|
||||
raise ValueError("nur Sicherungen des Orchestrators für diesen Gast (vzdump-lxc-<Gast>-…)")
|
||||
if _speicherart().get(m.group(1)) == "pbs":
|
||||
raise ValueError("Sicherungen auf einem PBS-Speicher fasst der Orchestrator nicht an")
|
||||
if not any(e["volid"] == volid for e in _eigene_sicherungen(m.group(1), vmid)):
|
||||
raise ValueError(f"{volid} fehlt oder ist keine Sicherung des Orchestrators (Notiz „{SICHERUNG_KENNUNG}“)")
|
||||
return volid
|
||||
|
||||
|
||||
def a_sichern(p: dict) -> tuple[int, str]:
|
||||
"""vzdump vor einem Update, wo kein Snapshot geht. Nur auf einen Speicher, den sicherungsspeicher() wählt, und
|
||||
nur mit genug Platz. Die neue Sicherung steht am Ende des Texts: sicherung=<volid>."""
|
||||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||||
if art != "lxc":
|
||||
raise ValueError("Sicherungen vor einem Update gibt es nur für Container")
|
||||
speicher = _speicher_liste()
|
||||
ziel, fehlt = sicherungsspeicher(speicher, SICHERUNG_SPEICHER)
|
||||
if ziel is None:
|
||||
raise ValueError(fehlt)
|
||||
konfig = _pvesh(f"/nodes/{NODE}/lxc/{vmid}/config")
|
||||
status = _pvesh(f"/nodes/{NODE}/lxc/{vmid}/status/current") or {}
|
||||
bedarf = sicherung_bedarf(konfig, status.get("disk") if status.get("status") == "running" else None)
|
||||
ok, grund = sicherung_pruefen({"speicher": ziel["storage"], "frei": ziel.get("avail")}, bedarf)
|
||||
if not ok:
|
||||
raise ValueError(grund)
|
||||
modus = sicherung_modus(konfig, {s["storage"]: s.get("type") for s in speicher})
|
||||
beginn = time.time()
|
||||
# --remove 0: keine Aufräumregeln des Speichers anwenden (die träfen auch fremde Sicherungen dieses Gasts).
|
||||
# --lockwait 10: läuft gerade eine andere Sicherung, nach zehn Minuten sauber aufgeben statt drei Stunden warten.
|
||||
code, text = _laufen(["vzdump", str(vmid), "--storage", ziel["storage"], "--mode", modus, "--compress", "zstd",
|
||||
"--remove", "0", "--lockwait", "10", "--notes-template", SICHERUNG_NOTIZ],
|
||||
ZEITLIMIT_SICHERUNG_S, sanft=True)
|
||||
if code != 0:
|
||||
return code, text
|
||||
neue = [e for e in _eigene_sicherungen(ziel["storage"], vmid) if (e.get("ctime") or 0) >= beginn - 120]
|
||||
if not neue:
|
||||
return 1, text + "\nDie neue Sicherung ist nicht zu finden oder trägt die Notiz mc2-sicherung nicht."
|
||||
return 0, f"{text}\nsicherung={neue[-1]['volid']}"
|
||||
|
||||
|
||||
def a_sicherung_zurueck(p: dict) -> tuple[int, str]:
|
||||
"""Rückweg bei Rot, wo kein Snapshot ging: Gast stoppen, die Sicherung auf den bisherigen Speicher der rootfs
|
||||
zurückspielen (pct restore --force), starten. Bind-Mounts (der Datenspeicher des PBS) bleiben unberührt."""
|
||||
art, vmid = _gast_pruefen(p.get("vmid"))
|
||||
if art != "lxc":
|
||||
raise ValueError("nur für Container")
|
||||
volid = _eigene_sicherung(vmid, p.get("sicherung"))
|
||||
ziel = str(_pvesh(f"/nodes/{NODE}/lxc/{vmid}/config").get("rootfs") or "").split(":")[0]
|
||||
if not ziel or ziel.startswith("/"):
|
||||
raise ValueError("Der Speicher der rootfs ließ sich nicht lesen")
|
||||
protokoll = []
|
||||
if (_pvesh(f"/nodes/{NODE}/lxc/{vmid}/status/current") or {}).get("status") == "running":
|
||||
code, text = _laufen(["pct", "shutdown", str(vmid), "--timeout", "120", "--forceStop", "1"], 300)
|
||||
protokoll.append(text)
|
||||
if code != 0:
|
||||
return code, "\n".join(protokoll + ["Der Gast ließ sich nicht stoppen; zurückgespielt wurde nichts."])
|
||||
code, text = _laufen(["pct", "restore", str(vmid), volid, "--force", "1", "--storage", ziel],
|
||||
ZEITLIMIT_SICHERUNG_S, sanft=True)
|
||||
protokoll.append(text)
|
||||
if code != 0:
|
||||
return code, "\n".join(protokoll + [f"Zurückspielen gescheitert; die Sicherung {volid} liegt weiter da."])
|
||||
code, text = _laufen(["pct", "start", str(vmid)], 120)
|
||||
protokoll.append(text)
|
||||
return code, "\n".join(t for t in protokoll if t)
|
||||
|
||||
|
||||
def a_sicherung_loeschen(p: dict) -> tuple[int, str]:
|
||||
_, vmid = _gast_pruefen(p.get("vmid"))
|
||||
return _laufen(["pvesm", "free", _eigene_sicherung(vmid, p.get("sicherung"))], 300)
|
||||
|
||||
|
||||
def a_host_update(p: dict) -> tuple[int, str]:
|
||||
# Kein Neustart: der ist ein eigener Knopf (User-Entscheid 24.09.2026).
|
||||
befehl = ("apt-get update -q && DEBIAN_FRONTEND=noninteractive apt-get -y "
|
||||
@@ -330,7 +576,8 @@ def a_bericht(p: dict) -> tuple[int, str]:
|
||||
AKTIONEN = {"snapshot": a_snapshot, "update": a_update, "os_update": a_os_update, "suchen": a_suchen,
|
||||
"zurueck": a_zurueck,
|
||||
"snapshot_loeschen": a_snapshot_loeschen, "host_update": a_host_update,
|
||||
"host_neustart": a_host_neustart, "bericht": a_bericht}
|
||||
"host_neustart": a_host_neustart, "bericht": a_bericht,
|
||||
"sichern": a_sichern, "sicherung_zurueck": a_sicherung_zurueck, "sicherung_loeschen": a_sicherung_loeschen}
|
||||
|
||||
|
||||
def ausfuehren(auftrag: dict) -> dict:
|
||||
@@ -344,6 +591,10 @@ def ausfuehren(auftrag: dict) -> dict:
|
||||
code, text = aktion(auftrag.get("parameter") or {})
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
return {"code": 2, "text": f"Abgelehnt: {exc}"}
|
||||
except Exception as exc:
|
||||
# Sonst bliebe der Auftrag ohne Antwort, und der Homelab-Teil wartete bis zu seinem Zeitlimit.
|
||||
log.exception("Auftrag %s gescheitert", auftrag.get("aktion"))
|
||||
return {"code": 1, "text": f"Fehler im Ausführer: {exc.__class__.__name__}: {exc}"[:500]}
|
||||
return {"code": code, "text": text[-20000:]}
|
||||
|
||||
|
||||
@@ -393,6 +644,7 @@ SERVER = str(_konfig.get("server") or "").rstrip("/")
|
||||
TOKEN = str(_konfig.get("token") or "")
|
||||
NODE = str(_konfig.get("node") or platform.node())
|
||||
NUR_LESEN = bool(_konfig.get("nur_lesen"))
|
||||
SICHERUNG_SPEICHER = str(_konfig.get("sicherung_speicher") or "").strip() or None
|
||||
|
||||
|
||||
def main() -> int:
|
||||
|
||||
Reference in New Issue
Block a user