Files
mission-control-v2/backend/services/homelab/kanal.py
T
HitonabiandClaude Opus 5.5 afa3a476b5 Welle 1 · Sicherungs-Abdeckung: Gäste ohne Auftrag, Prüfung durch den PBS, Probe-Wiederherstellung je Gerät
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>
2026-09-25 16:37:21 +02:00

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()))