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>
161 lines
6.9 KiB
Python
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()
|