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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
f2bffc7203
commit
c7c396f56d
@@ -8,8 +8,9 @@ Wie eine App aktualisiert wird, ist je Community-Script verschieden (nachgelesen
|
||||
eigene-oberflaeche das Skript verweist auf den eingebauten Updater der App (AdGuard, PVE Scripts Local)
|
||||
pakete die App kommt als Paket; das Update der Pakete im Gast ist ihr Update (PBS)
|
||||
Nur beim Weg „skript“ bietet die Übersicht „Jetzt updaten“ für die App an.
|
||||
Neue Container mit dem Etikett erscheinen von selbst; fehlt ihre Kennung hier, zeigt die Übersicht
|
||||
sie ohne Versionsvergleich und ohne Webprüfung — nachtragen genügt.
|
||||
Neue Container mit dem Etikett erscheinen von selbst. Fehlt ihre Kennung hier, liest katalog.py (seit 25.09.2026)
|
||||
Name, GitHub-Projekt, Port und Weg aus ihrem Update-Skript; was hier von Hand steht, geht vor (etwa ein geänderter
|
||||
Port wie bei AdGuard).
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
@@ -46,6 +47,10 @@ NACH_NAME: dict[str, App] = {
|
||||
def app_fuer(kennung: str | None, name: str | None) -> App | None:
|
||||
if kennung and kennung in KATALOG:
|
||||
return KATALOG[kennung]
|
||||
if kennung:
|
||||
from services.homelab import katalog # der Katalog baut Apps aus diesem Modul — erst hier
|
||||
if gefunden := katalog.app(kennung):
|
||||
return gefunden
|
||||
return NACH_NAME.get(str(name or "").lower())
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
"""„Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt (Ausbauplan Welle 2, Punkt 7, seit 25.09.2026).
|
||||
|
||||
Jeder Bericht des Ausführers wird mit dem Register <Datenordner>/geraete-bekannt.json verglichen. Taucht ein Gast auf,
|
||||
den es noch nicht gibt (oder dessen Nummer jetzt einen anderen Namen trägt), wird er „neu“: Lucy meldet ihn einmal
|
||||
über Telegram, und das Homelab-Cockpit zeigt ihn als Karte „Neu entdeckt“ mit Vorschlägen — welche App darin steckt
|
||||
(katalog.py), ob ihn ein Sicherungsauftrag erfasst, ob seine Oberfläche geprüft wird. Ein Klick übernimmt ihn:
|
||||
• „Für Updates freigeben“ setzt das Etikett watcher (Ausführer: etikett_setzen) — damit darf der Orchestrator
|
||||
Snapshots anlegen und Updates einspielen. Hat er schon community-script, genügt Übernehmen.
|
||||
• „Nur beobachten“ kein Etikett; er steht in der Liste, Updates gibt es für ihn nicht.
|
||||
• „Nicht anfassen“ setzt watcher-aus: auch ein Community-Script bleibt dann unberührt.
|
||||
Beim allerersten Bericht (noch kein Register) gilt alles als bekannt — sonst stünden alle Geräte auf einmal da.
|
||||
Verschwindet ein Gast, fällt er aus dem Register; kommt unter derselben Nummer ein neuer, ist er wieder „neu“.
|
||||
Der eigene Container (Etikett mc2) erscheint nie.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from kern.einstellungen import einstellungen
|
||||
|
||||
from services import announce
|
||||
from services.homelab import apps, kanal
|
||||
from services.homelab.inventar import EIGENES_ETIKETT
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
BETREFF = "[Homelab]"
|
||||
WAHL = {"freigeben": ("watcher", True), "ausschliessen": ("watcher-aus", True), "beobachten": (None, None)}
|
||||
ZEITLIMIT_S = 60
|
||||
_lock = threading.Lock()
|
||||
|
||||
|
||||
def _pfad() -> Path:
|
||||
return einstellungen().daten_dir / "geraete-bekannt.json"
|
||||
|
||||
|
||||
def _lesen() -> dict | None:
|
||||
try:
|
||||
daten = json.loads(_pfad().read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
return daten if isinstance(daten, dict) else None
|
||||
|
||||
|
||||
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 _zid(g: dict) -> str:
|
||||
return f"{'ct' if g.get('art') == 'lxc' else 'vm'}-{g['vmid']}"
|
||||
|
||||
|
||||
def _anzeigename(g: dict) -> str:
|
||||
app = apps.app_fuer((g.get("app") or {}).get("kennung"), g.get("name"))
|
||||
return app.name if app else str(g.get("name") or _zid(g))
|
||||
|
||||
|
||||
def bericht_gesehen(bericht: dict, jetzt: float | None = None, melden=None) -> list[str]:
|
||||
"""Den Bericht mit dem Register abgleichen. Rückgabe: die Ziel-IDs, die gerade neu dazukamen."""
|
||||
jetzt = time.time() if jetzt is None else jetzt
|
||||
melden = melden or _melden
|
||||
gaeste = {_zid(g): g for g in bericht.get("gaeste") or []
|
||||
if isinstance(g, dict) and "vmid" in g and EIGENES_ETIKETT not in (g.get("etiketten") or [])}
|
||||
if not gaeste:
|
||||
return [] # ein leerer oder kaputter Bericht löscht nie das Register
|
||||
with _lock:
|
||||
register = _lesen()
|
||||
if register is None:
|
||||
_schreiben({zid: {"name": g.get("name"), "erstmals": jetzt, "zustand": "bekannt"} for zid, g in gaeste.items()})
|
||||
return []
|
||||
neu = []
|
||||
for zid, g in gaeste.items():
|
||||
alt = register.get(zid)
|
||||
if alt and alt.get("name") == g.get("name"):
|
||||
continue
|
||||
register[zid] = {"name": g.get("name"), "erstmals": jetzt, "zustand": "neu"}
|
||||
neu.append(zid)
|
||||
for zid in [z for z in register if z not in gaeste]:
|
||||
register.pop(zid)
|
||||
_schreiben(register)
|
||||
for zid in neu:
|
||||
g = gaeste[zid]
|
||||
art = "Container" if g.get("art") == "lxc" else "VM"
|
||||
melden(f"Neu im Homelab: {art} {g['vmid']} „{g.get('name') or zid}“ ({_anzeigename(g)}). Ich habe ihn noch "
|
||||
"nicht übernommen — im Homelab-Cockpit unter „Neu entdeckt“ entscheidest du, ob der Orchestrator ihn "
|
||||
"aktualisieren darf.")
|
||||
return neu
|
||||
|
||||
|
||||
def _melden(text: str) -> None:
|
||||
try:
|
||||
announce.notify_telegram(BETREFF, text)
|
||||
except Exception:
|
||||
log.warning("entdecken: Meldung ging nicht raus", exc_info=True)
|
||||
|
||||
|
||||
def neue() -> list[dict]:
|
||||
"""Für GET /api/homelab/neu: je neu entdecktem Gerät, was der Orchestrator darüber weiß und vorschlägt."""
|
||||
register = _lesen() or {}
|
||||
eingang = (kanal.bericht() or {}).get("bericht") or {}
|
||||
host = eingang.get("host") or {}
|
||||
ohne_auftrag = set(host.get("nicht_gesichert") or [])
|
||||
liste = []
|
||||
for g in eingang.get("gaeste") or []:
|
||||
zid = _zid(g) if isinstance(g, dict) and "vmid" in g else None
|
||||
if not zid or (register.get(zid) or {}).get("zustand") != "neu":
|
||||
continue
|
||||
kennung = (g.get("app") or {}).get("kennung")
|
||||
app = apps.app_fuer(kennung, g.get("name"))
|
||||
etiketten = list(g.get("etiketten") or [])
|
||||
liste.append({
|
||||
"id": zid, "vmid": g["vmid"], "name": app.name if app else str(g.get("name") or zid),
|
||||
"hostname": g.get("name"), "art": "container" if g.get("art") == "lxc" else "vm",
|
||||
"status": g.get("status"), "erstmals": register[zid].get("erstmals"), "etiketten": etiketten,
|
||||
"freigegeben": bool(g.get("erlaubt")), "ausgeschlossen": "watcher-aus" in etiketten,
|
||||
"app": ({"kennung": kennung, "name": app.name, "quelle": app.quelle, "weg": app.weg,
|
||||
"adresse": apps.adresse(app, g.get("ip"))} if app else None),
|
||||
"community_script": "community-script" in etiketten, "ip": g.get("ip"),
|
||||
"ohne_sicherung": g["vmid"] in ohne_auftrag})
|
||||
return liste
|
||||
|
||||
|
||||
def _markieren(zid: str) -> None:
|
||||
with _lock:
|
||||
register = _lesen() or {}
|
||||
if zid in register:
|
||||
register[zid]["zustand"] = "bekannt"
|
||||
_schreiben(register)
|
||||
|
||||
|
||||
def uebernehmen(zid: str, wahl: str) -> dict:
|
||||
"""POST /api/homelab/neu/{zid}: „freigeben“, „beobachten“ oder „ausschliessen“."""
|
||||
if wahl not in WAHL:
|
||||
return {"ok": False, "detail": "Unbekannte Wahl."}
|
||||
geraet = next((g for g in neue() if g["id"] == zid), None)
|
||||
if not geraet:
|
||||
return {"ok": False, "detail": "Dieses Gerät steht nicht (mehr) unter „Neu entdeckt“."}
|
||||
etikett, an = WAHL[wahl]
|
||||
schon = (wahl == "freigeben" and geraet["freigegeben"]) or (wahl == "ausschliessen" and geraet["ausgeschlossen"])
|
||||
if etikett and not schon:
|
||||
auftrag = kanal.anlegen("etikett_setzen", {"vmid": geraet["vmid"], "etikett": etikett, "an": an})
|
||||
ergebnis = kanal.warten(auftrag, ZEITLIMIT_S)
|
||||
if not ergebnis or ergebnis.get("status") != "fertig":
|
||||
grund = (ergebnis or {}).get("text") or "Der Ausführer hat nicht rechtzeitig geantwortet."
|
||||
return {"ok": False, "detail": f"Das Etikett ließ sich nicht setzen: {str(grund)[-300:]}"}
|
||||
_markieren(zid)
|
||||
text = {"freigeben": f"{geraet['name']} ist übernommen und für Updates freigegeben.",
|
||||
"beobachten": f"{geraet['name']} ist übernommen; der Orchestrator beobachtet ihn nur.",
|
||||
"ausschliessen": f"{geraet['name']} ist übernommen und bleibt unberührt (Etikett watcher-aus)."}[wahl]
|
||||
return {"ok": True, "text": text + (" Der nächste Bericht zeigt es in der Geräteliste." if etikett else "")}
|
||||
@@ -34,7 +34,7 @@ except ImportError: # Windows (Entwicklung): nur die Thread-Sperre
|
||||
|
||||
AKTIONEN = {"bericht", "snapshot", "update", "os_update", "suchen", "zurueck", "snapshot_loeschen",
|
||||
"host_update", "host_neustart", "sichern", "sicherung_zurueck", "sicherung_loeschen",
|
||||
"kernel_aufraeumen", "sicherung_probe"}
|
||||
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen"}
|
||||
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 …
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
"""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()
|
||||
@@ -69,6 +69,7 @@ _AKTION = {
|
||||
"Kernel aufräumen"),
|
||||
"sicherung_probe": ("Probe-Wiederherstellung gelungen", "Probe-Wiederherstellung gescheitert",
|
||||
"Probe-Wiederherstellung läuft", "Probe-Wiederherstellung"),
|
||||
"etikett_setzen": ("Etikett gesetzt", "Etikett setzen gescheitert", "Etikett wird gesetzt", "Etikett setzen"),
|
||||
}
|
||||
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
|
||||
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
|
||||
|
||||
Reference in New Issue
Block a user