Der Ausführer meldet jetzt, welche Gäste kein Sicherungsauftrag erfasst (wie Proxmox unter Rechenzentrum → Backup), je Gast die jüngste Prüfung durch den PBS samt beschädigter Sicherungen, die Schätzung des PBS, wann sein Datastore voll ist, und öffnet einmal die Woche die jüngste Sicherung jedes Gasts probeweise (nur lesend: Konfiguration, bei Containern der Dateibaum). Der Wächter meldet: ohne Auftrag gelb (sofort), beschädigt rot, seit 15 Tagen nicht geprüft gelb, Probe gescheitert gelb, Sicherungsziel laut PBS bald voll. Die Speicherkarte zeigt „Sicherungen je Gerät“ mit Knopf „Jetzt prüfen“. Der Ausführer braucht ausfuehrer-einrichten.sh. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
243 lines
9.9 KiB
Python
243 lines
9.9 KiB
Python
"""Kanal zum Ausführer auf dem Proxmox-Host (Phase 3/4, 24.09.2026).
|
|
|
|
Der Ausführer (deploy/homelab/ausfuehrer.py) holt sich Aufträge hier ab, liefert die Ergebnisse und
|
|
alle zehn Minuten einen Bericht. Er weist sich mit einem gemeinsamen Geheimnis aus (Kopfzeile
|
|
X-MC2-Ausfuehrer). Das erzeugt dieser Teil beim ersten Bedarf in <Datenordner>/ausfuehrer.token (nur
|
|
für den Dienst lesbar); die Einrichtung auf dem Proxmox-Host kopiert es in /etc/mc2-ausfuehrer.json.
|
|
|
|
Aufträge liegen in <Datenordner>/ausfuehrer-auftraege.json und überleben so einen Neustart dieses Teils.
|
|
Zwei Prozesse schreiben hinein: die Oberfläche (Knöpfe, Abholen durch den Ausführer) und seit 24.09.2026 der
|
|
Steward (wöchentliches Suchen, pflege.py). Lesen, ändern, schreiben geschieht deshalb unter einer Dateisperre.
|
|
|
|
Erledigte Aufträge bleiben (seit 24.09.2026) bis zu BEHALTEN Stück stehen — das Protokoll der Oberfläche
|
|
(protokoll.py) liest sie. Die jüngsten VOLLTEXT behalten ihre ganze Ausgabe, ältere nur deren Ende (TEXT_KURZ
|
|
Zeichen, so viel zeigt das Protokoll): Die Datei wird bei jedem Auftrag gelesen und geschrieben.
|
|
"""
|
|
|
|
import hmac
|
|
import json
|
|
import os
|
|
import secrets
|
|
import threading
|
|
import time
|
|
import uuid
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from pathlib import Path
|
|
|
|
from kern.einstellungen import einstellungen
|
|
|
|
try:
|
|
import fcntl # Linux: Sperre über Prozessgrenzen
|
|
except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
|
fcntl = None
|
|
|
|
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
|
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
|
"kernel_aufraeumen", "sicherung_probe"}
|
|
VERLOREN_S = 3 * 3600 # abgeholt, aber nie beantwortet (Ausführer abgestürzt, Host neu gestartet)
|
|
BEHALTEN = 500 # so viele erledigte Aufträge bleiben sichtbar (Protokoll; bis 24.09.2026: 60)
|
|
VOLLTEXT = 60 # die jüngsten erledigten behalten ihre ganze Ausgabe …
|
|
TEXT_KURZ = 4000 # … ältere nur deren letzte Zeichen
|
|
BERICHTE_BEHALTEN = 150 # Eingänge der Berichte, die der Ausführer von sich aus schickt (alle 10 min: gut ein Tag)
|
|
|
|
_lock = threading.Lock()
|
|
_kontakt: dict = {"zuletzt": None}
|
|
|
|
|
|
def _dir() -> Path:
|
|
return einstellungen().daten_dir
|
|
|
|
|
|
@contextmanager
|
|
def _sperre() -> Iterator[None]:
|
|
"""Für Lesen-ändern-schreiben der Auftragsliste: Thread-Sperre, unter Linux zusätzlich flock. Ohne sie
|
|
überschrieben sich Oberfläche und Steward gegenseitig (ein abgeholter Auftrag stünde wieder auf „wartet“)."""
|
|
with _lock:
|
|
if fcntl is None:
|
|
yield
|
|
return
|
|
pfad = _dir() / "ausfuehrer-auftraege.lock"
|
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
|
with open(pfad, "a", encoding="utf-8") as f:
|
|
fcntl.flock(f, fcntl.LOCK_EX)
|
|
try:
|
|
yield
|
|
finally:
|
|
fcntl.flock(f, fcntl.LOCK_UN)
|
|
|
|
|
|
def _json_schreiben(pfad: Path, daten: object) -> None:
|
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = pfad.with_suffix(pfad.suffix + ".tmp")
|
|
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(pfad)
|
|
|
|
|
|
def _json_lesen(pfad: Path, leer: object) -> object:
|
|
try:
|
|
return json.loads(pfad.read_text(encoding="utf-8"))
|
|
except (OSError, ValueError):
|
|
return leer
|
|
|
|
|
|
# --- Gemeinsames Geheimnis ---------------------------------------------------------------
|
|
|
|
def token() -> str:
|
|
"""Das Geheimnis des Kanals; beim ersten Aufruf erzeugt (0600). MC_AUSFUEHRER_TOKEN geht vor."""
|
|
if wert := os.environ.get("MC_AUSFUEHRER_TOKEN", "").strip():
|
|
return wert
|
|
pfad = _dir() / "ausfuehrer.token"
|
|
try:
|
|
return pfad.read_text(encoding="utf-8").strip()
|
|
except OSError:
|
|
pass
|
|
wert = secrets.token_urlsafe(32)
|
|
pfad.parent.mkdir(parents=True, exist_ok=True)
|
|
try:
|
|
fd = os.open(pfad, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
|
except FileExistsError: # gleichzeitig erzeugt: den anderen nehmen
|
|
return pfad.read_text(encoding="utf-8").strip()
|
|
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
f.write(wert + "\n")
|
|
return wert
|
|
|
|
|
|
def token_gueltig(wert: str | None) -> bool:
|
|
return bool(wert) and hmac.compare_digest(str(wert), token())
|
|
|
|
|
|
def kontakt() -> None:
|
|
_kontakt["zuletzt"] = time.time()
|
|
|
|
|
|
def zuletzt() -> float | None:
|
|
"""Letztes Lebenszeichen des Ausführers (nach einem Neustart: Zeit des letzten Berichts)."""
|
|
return _kontakt["zuletzt"] or (bericht() or {}).get("empfangen")
|
|
|
|
|
|
# --- Bericht ------------------------------------------------------------------------------
|
|
|
|
def bericht_speichern(daten: dict, aus_auftrag: bool = False) -> None:
|
|
"""Den neuesten Bericht ablegen. Schickt ihn der Ausführer von sich aus (alle 10 min und nach jedem Auftrag),
|
|
wird sein Eingang kurz vermerkt — das Protokoll zeigt ihn auf Wunsch. aus_auftrag: ein Lauf hat ihn als Auftrag
|
|
„bericht“ geholt (updates.py); dann steht er dort schon."""
|
|
jetzt = time.time()
|
|
with _lock:
|
|
_json_schreiben(_dir() / "pve-bericht.json", {"empfangen": jetzt, "bericht": daten})
|
|
if aus_auftrag:
|
|
return
|
|
host = daten.get("host") if isinstance(daten.get("host"), dict) else {}
|
|
gaeste = [g for g in daten.get("gaeste") or [] if isinstance(g, dict)]
|
|
eingang = {"empfangen": jetzt, "host_version": host.get("version"), "gaeste": len(gaeste),
|
|
"laufend": sum(1 for g in gaeste if g.get("status") == "running"),
|
|
"host_updates": len(host["updates"]) if isinstance(host.get("updates"), list) else None}
|
|
pfad = _dir() / "ausfuehrer-berichte.json"
|
|
eingaenge = _json_lesen(pfad, [])
|
|
eingaenge = eingaenge if isinstance(eingaenge, list) else []
|
|
_json_schreiben(pfad, (eingaenge + [eingang])[-BERICHTE_BEHALTEN:])
|
|
|
|
|
|
def berichte() -> list[dict]:
|
|
"""Die vermerkten Eingänge von Berichten (älteste zuerst): Zeit, Host-Version, Zahl der Gäste und Host-Updates."""
|
|
with _lock:
|
|
daten = _json_lesen(_dir() / "ausfuehrer-berichte.json", [])
|
|
return [e for e in daten if isinstance(e, dict)] if isinstance(daten, list) else []
|
|
|
|
|
|
def bericht() -> dict | None:
|
|
"""{"empfangen": Unix-Sekunden, "bericht": {...}} oder None, solange nie einer kam."""
|
|
with _lock:
|
|
daten = _json_lesen(_dir() / "pve-bericht.json", None)
|
|
return daten if isinstance(daten, dict) and isinstance(daten.get("bericht"), dict) else None
|
|
|
|
|
|
# --- Aufträge -------------------------------------------------------------------------------
|
|
|
|
def _pfad() -> Path:
|
|
return _dir() / "ausfuehrer-auftraege.json"
|
|
|
|
|
|
def _alle() -> list[dict]:
|
|
daten = _json_lesen(_pfad(), [])
|
|
return daten if isinstance(daten, list) else []
|
|
|
|
|
|
def _merken(auftraege: list[dict]) -> None:
|
|
offen = [a for a in auftraege if a["status"] in ("wartet", "laeuft")]
|
|
fertig = [a for a in auftraege if a["status"] not in ("wartet", "laeuft")][-BEHALTEN:]
|
|
for a in fertig[:-VOLLTEXT]:
|
|
text = a.get("text")
|
|
if isinstance(text, str) and len(text) > TEXT_KURZ:
|
|
a["text"] = "…" + text[-(TEXT_KURZ - 1):]
|
|
_json_schreiben(_pfad(), sorted(offen + fertig, key=lambda a: a["erstellt"]))
|
|
|
|
|
|
def anlegen(aktion: str, parameter: dict | None = None) -> str:
|
|
if aktion not in AKTIONEN:
|
|
raise ValueError(f"Unbekannte Aktion: {aktion}")
|
|
auftrag = {"id": uuid.uuid4().hex[:12], "aktion": aktion, "parameter": parameter or {},
|
|
"status": "wartet", "erstellt": time.time(), "abgeholt": None, "fertig": None,
|
|
"code": None, "text": None}
|
|
with _sperre():
|
|
auftraege = _alle()
|
|
auftraege.append(auftrag)
|
|
_merken(auftraege)
|
|
return auftrag["id"]
|
|
|
|
|
|
def naechster() -> dict | None:
|
|
"""Für den Ausführer: der älteste wartende Auftrag, ab jetzt „läuft“. Hängengebliebene werden
|
|
vorher als verloren abgeschlossen. Geschrieben wird nur, wenn sich etwas ändert: Der Ausführer fragt alle
|
|
fünf Sekunden."""
|
|
jetzt = time.time()
|
|
with _sperre():
|
|
auftraege = _alle()
|
|
geaendert = False
|
|
for a in auftraege:
|
|
if a["status"] == "laeuft" and jetzt - (a["abgeholt"] or jetzt) > VERLOREN_S:
|
|
a.update(status="verloren", fertig=jetzt, text="Der Ausführer hat nie geantwortet.")
|
|
geaendert = True
|
|
auftrag = next((a for a in auftraege if a["status"] == "wartet"), None)
|
|
if auftrag:
|
|
auftrag.update(status="laeuft", abgeholt=jetzt)
|
|
geaendert = True
|
|
if geaendert:
|
|
_merken(auftraege)
|
|
return {k: auftrag[k] for k in ("id", "aktion", "parameter")} if auftrag else None
|
|
|
|
|
|
def ergebnis(auftrag_id: str, code: int, text: str) -> bool:
|
|
with _sperre():
|
|
auftraege = _alle()
|
|
auftrag = next((a for a in auftraege if a["id"] == auftrag_id), None)
|
|
if auftrag is None or auftrag["status"] != "laeuft":
|
|
return False
|
|
auftrag.update(status="fertig" if code == 0 else "fehler", fertig=time.time(), code=code,
|
|
text=str(text)[-20000:])
|
|
_merken(auftraege)
|
|
return True
|
|
|
|
|
|
def auftrag(auftrag_id: str) -> dict | None:
|
|
# Lesen unter derselben Sperre wie das Schreiben: Unter Windows scheitert das atomare Ersetzen,
|
|
# solange ein anderer Thread die Datei offen hat.
|
|
with _lock:
|
|
return next((a for a in _alle() if a["id"] == auftrag_id), None)
|
|
|
|
|
|
def warten(auftrag_id: str, zeitlimit_s: float, takt_s: float = 2.0) -> dict | None:
|
|
"""Bis der Auftrag fertig ist (oder das Zeitlimit abläuft). Rückgabe: der Auftrag, None bei Zeitablauf."""
|
|
ende = time.time() + zeitlimit_s
|
|
while time.time() < ende:
|
|
a = auftrag(auftrag_id)
|
|
if a and a["status"] not in ("wartet", "laeuft"):
|
|
return a
|
|
time.sleep(takt_s)
|
|
return None
|
|
|
|
|
|
def liste() -> list[dict]:
|
|
with _lock:
|
|
return list(reversed(_alle()))
|