Files
mission-control-v2/backend/services/homelab/katalog.py
T
HitonabiandClaude Opus 5.5 c7c396f56d Welle 2 · Neu entdeckt: neue Geräte mit Vorschlägen, Übernehmen per Klick (Etikett), App-Katalog pflegt sich selbst
Jeder Bericht wird mit einem Register bekannter Geräte verglichen; ein neuer Container oder eine neue VM erscheint im
Homelab-Cockpit unter „Neu entdeckt“, und Lucy meldet ihn einmal über Telegram. Übernehmen: „Für Updates freigeben“
(der Ausführer setzt das Etikett watcher — neue Aktion etikett_setzen, sie darf nur watcher und watcher-aus setzen oder
entfernen), „Nur beobachten“ oder „Nicht anfassen“ (watcher-aus). Unbekannte Community-Scripts-Kennungen trägt
katalog.py aus ihrem Update-Skript nach (Name, GitHub-Projekt, Port, Weg); ein Port, der nicht antwortet, schaltet die
Webprüfung nicht ein. Der Ausführer braucht ausfuehrer-einrichten.sh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 17:25:38 +02:00

161 lines
6.9 KiB
Python

"""Der App-Katalog pflegt sich selbst (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
Bis zum 25.09. kannte der Orchestrator nur die Apps, die in apps.KATALOG von Hand stehen; ein neuer Container zeigte
sich ohne Versionsvergleich und ohne Webprüfung. Jetzt liest er für jede unbekannte Community-Scripts-Kennung einmal
deren Update-Skript (ct/<kennung>.sh, öffentlich auf GitHub, derselbe Ort, den `update` im Gast lädt) und nimmt daraus:
• den Namen (APP="…"),
• das GitHub-Projekt für die neueste Version (Kopfzeile „Github: https://github.com/<owner>/<repo>“),
• die Adresse der Oberfläche (die letzte Zeile „http(s)://${IP}:<port>“, die das Skript am Ende ausgibt),
• den Weg des Updates: sagt das Skript, die App lasse sich nur über ihre eigene Oberfläche aktualisieren, dann
„eigene-oberflaeche“ (kein Knopf), sonst „skript“ (`update` im Gast, mit der Wartezeit aus karenz.py).
Den Port prüft er gleich mit: Antwortet dort nichts (der User hat ihn geändert), bleibt die Webprüfung aus — ein
geratener Port darf keinen roten Hinweis „antwortet nicht“ auslösen. Die Angaben liegen in
<Datenordner>/app-katalog.json; gelungene werden nach 7 Tagen neu gelesen, gescheiterte nach einem Tag. Die Angaben
der Community-Scripts selbst (JSON) sind im Sommer 2026 aus dem Repository verschwunden, das Skript bleibt.
"""
import json
import logging
import re
import socket
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from services.homelab import apps
log = logging.getLogger(__name__)
ROH = "https://raw.githubusercontent.com/community-scripts/ProxmoxVE/main/ct/{kennung}.sh"
NEU_LESEN_S, FEHLER_NEU_S = 7 * 86400, 86400
ZEITLIMIT = httpx.Timeout(15.0, connect=5.0)
_KENNUNG = re.compile(r"[a-z0-9][a-z0-9-]{0,62}")
_GITHUB = re.compile(r"Github:\s*https?://github\.com/([\w.-]+/[\w.-]+?)(?:\.git)?/?\s*$", re.IGNORECASE | re.MULTILINE)
_APP = re.compile(r'^APP="([^"]+)"', re.MULTILINE)
_ADRESSE = re.compile(r"(https?)://\$\{IP\}:(\d{2,5})(/[^\s$\"']*)?")
_NUR_OBERFLAECHE = re.compile(r"only be updated via|update(?:d)? (?:it )?(?:via|through|from) the (?:web ?)?(?:ui|user interface|"
r"web interface|dashboard)", re.IGNORECASE)
_lock = threading.Lock()
_stand: dict = {"mtime": None, "daten": {}}
_laufend: set[str] = set()
def _pfad() -> Path:
return einstellungen().daten_dir / "app-katalog.json"
def _lesen() -> dict:
"""Der Katalog aus der Datei, gemerkt solange sie sich nicht ändert (app_fuer fragt oft)."""
pfad = _pfad()
try:
mtime = pfad.stat().st_mtime_ns
except OSError:
return {}
with _lock:
if _stand["mtime"] == mtime:
return _stand["daten"]
try:
daten = json.loads(pfad.read_text(encoding="utf-8"))
except (OSError, ValueError):
daten = {}
daten = daten if isinstance(daten, dict) else {}
with _lock:
_stand.update(mtime=mtime, daten=daten)
return daten
def _schreiben(daten: dict) -> None:
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False, indent=1), encoding="utf-8")
tmp.replace(pfad)
def aus_skript(kennung: str, text: str) -> dict:
"""Was das Update-Skript über die App verrät: {"name", "quelle", "port", "https", "pfad", "weg"}."""
name = _APP.search(text)
quelle = _GITHUB.search(text)
adressen = _ADRESSE.findall(text)
https, port, pfad = (adressen[-1][0] == "https", int(adressen[-1][1]), adressen[-1][2] or "/") if adressen \
else (False, None, "/")
return {"name": name.group(1).strip() if name else kennung, "quelle": quelle.group(1) if quelle else None,
"port": port, "https": https, "pfad": pfad,
"weg": "eigene-oberflaeche" if _NUR_OBERFLAECHE.search(text) else "skript"}
def _antwortet(ip: str, port: int) -> bool:
try:
with socket.create_connection((ip, port), timeout=3):
return True
except OSError:
return False
def app(kennung: str | None) -> apps.App | None:
"""Die App zu einer Kennung aus dem selbst gepflegten Katalog (None, wenn unbekannt oder das Lesen scheiterte)."""
e = _lesen().get(kennung or "")
if not isinstance(e, dict) or e.get("fehler") or not e.get("name"):
return None
return apps.App(kennung, str(e["name"]), e.get("quelle"), port=e.get("port"), https=bool(e.get("https")),
pfad=str(e.get("pfad") or "/"), weg=str(e.get("weg") or "skript"))
def eintragen(kennung: str, ip: str | None, holen=None, jetzt: float | None = None) -> dict:
"""Eine Kennung (neu) eintragen: Skript lesen, auswerten, Port prüfen. Rückgabe: der Eintrag."""
jetzt = time.time() if jetzt is None else jetzt
holen = holen or (lambda k: httpx.get(ROH.format(kennung=k), timeout=ZEITLIMIT, follow_redirects=True))
try:
antwort = holen(kennung)
antwort.raise_for_status()
eintrag = {**aus_skript(kennung, antwort.text), "geholt": jetzt}
except Exception as exc:
eintrag = {"fehler": f"{exc.__class__.__name__}: {exc}"[:200], "geholt": jetzt}
if eintrag.get("port") and ip and not _antwortet(ip, eintrag["port"]):
eintrag.update(port_skript=eintrag["port"], port=None)
daten = dict(_lesen())
daten[kennung] = eintrag
_schreiben(daten)
log.info("katalog: %s eingetragen (%s)", kennung, eintrag.get("fehler") or eintrag.get("name"))
return eintrag
def fehlende(bericht: dict, jetzt: float | None = None) -> list[tuple[str, str | None]]:
"""(Kennung, IP) der Apps im Bericht, die weder von Hand im Katalog stehen noch frisch gelesen sind."""
jetzt = time.time() if jetzt is None else jetzt
daten = _lesen()
ergebnis = []
for g in bericht.get("gaeste") or []:
kennung = ((g.get("app") if isinstance(g.get("app"), dict) else {}) or {}).get("kennung")
if not kennung or not _KENNUNG.fullmatch(kennung) or kennung in apps.KATALOG:
continue
e = daten.get(kennung)
alter = jetzt - float((e or {}).get("geholt") or 0)
if e is None or alter > (FEHLER_NEU_S if e.get("fehler") else NEU_LESEN_S):
ergebnis.append((kennung, g.get("ip")))
return ergebnis
def ergaenzen(bericht: dict) -> None:
"""Nach jedem Bericht: fehlende Apps im eigenen Faden nachtragen — der Bericht wartet nie auf GitHub."""
for kennung, ip in fehlende(bericht):
with _lock:
if kennung in _laufend:
continue
_laufend.add(kennung)
def los(k: str = kennung, adresse: str | None = ip) -> None:
try:
eintragen(k, adresse)
except Exception:
log.warning("katalog: %s ließ sich nicht eintragen", k, exc_info=True)
finally:
with _lock:
_laufend.discard(k)
threading.Thread(target=los, name=f"katalog-{kennung}", daemon=True).start()