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:
Hitonabi
2026-09-25 17:25:38 +02:00
co-authored by Claude Opus 5.5
parent f2bffc7203
commit c7c396f56d
13 changed files with 798 additions and 7 deletions
+29 -1
View File
@@ -12,6 +12,8 @@
POST /api/homelab/snapshots/rueckwege-loeschen Alle Rückwege von Updates löschen (nur per Klick)
POST /api/homelab/sicherungen/probe Probe-Wiederherstellung jetzt (nur lesend, sonst einmal die Woche)
GET /api/homelab/zertifikate https-Zertifikate (NPMplus, Proxmox-Host, PBS) mit Stufe
GET /api/homelab/neu „Neu entdeckt“: Geräte, die der Orchestrator noch nicht kennt
POST /api/homelab/neu/{id} {"wahl": freigeben | beobachten | ausschliessen} — übernehmen
GET /api/homelab/hinweise Hinweise des Wächters dieser Instanz
GET /api/homelab/ausfuehrer Lebenszeichen des Ausführers
GET /api/homelab/ausfuehrer/auftrag ┐
@@ -25,6 +27,7 @@ Dünn: die Logik liegt in services/homelab/.
import asyncio
import json
import logging
import time
from fastapi import APIRouter, Depends, Header, HTTPException, Request
@@ -33,6 +36,8 @@ from kern.einstellungen import einstellungen
from pydantic import BaseModel
from services.homelab import inventar, kanal, updates
log = logging.getLogger(__name__)
router = APIRouter(prefix="/api/homelab", tags=["homelab"])
strom_router = APIRouter(prefix="/api", tags=["homelab"])
@@ -159,6 +164,24 @@ def speicher_lage() -> dict:
return speicher.lage()
@router.get("/neu")
def neu_entdeckt() -> dict:
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
from services.homelab import entdecken
return {"geraete": entdecken.neue()}
class Wahl(BaseModel):
wahl: str
@router.post("/neu/{ziel_id}")
def neu_uebernehmen(ziel_id: str, w: Wahl) -> dict:
"""Übernehmen: freigeben (Etikett watcher), beobachten (kein Etikett) oder ausschliessen (watcher-aus)."""
from services.homelab import entdecken
return _antwort(entdecken.uebernehmen(ziel_id, w.wahl))
@router.get("/zertifikate")
def zertifikate_lage() -> dict:
"""Die https-Zertifikate mit Ablauf und Stufe (Zertifikats-Wache, seit 25.09.2026)."""
@@ -279,9 +302,14 @@ def auftrag_abholen() -> dict:
@router.post("/ausfuehrer/bericht", dependencies=[Depends(_nur_ausfuehrer)])
def bericht(daten: dict) -> dict:
from services.homelab import speicher
from services.homelab import entdecken, katalog, speicher
kanal.bericht_speichern(daten)
speicher.verlauf_merken(daten) # Verlauf für „voll in etwa N Tagen“ (seit 25.09.2026)
try: # „Neu entdeckt“ und der Katalog (seit 25.09.2026) halten den Bericht nie auf
entdecken.bericht_gesehen(daten)
katalog.ergaenzen(daten)
except Exception:
log.warning("homelab: Abgleich mit dem Register gescheitert", exc_info=True)
return {"ok": True}
+7 -2
View File
@@ -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())
+157
View File
@@ -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 "")}
+1 -1
View File
@@ -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 …
+160
View File
@@ -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()
+1
View File
@@ -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",
+207
View File
@@ -0,0 +1,207 @@
"""„Neu entdeckt“, der selbst gepflegte App-Katalog und das Etikett per Klick (Ausbauplan Welle 2, Punkt 7, 25.09.2026).
Die Skript-Auszüge stammen aus community-scripts/ProxmoxVE (ct/adguard.sh, gelesen am 25.09.)."""
import importlib.util
import json
from pathlib import Path
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from kern import einstellungen as einstellungen_mod
from services.homelab import apps, entdecken, kanal, katalog
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
JETZT = 1_790_340_000.0
ADGUARD_SH = """#!/usr/bin/env bash
# Copyright (c) 2021-2026 tteck
# License: MIT | https://github.com/community-scripts/ProxmoxVE/raw/main/LICENSE
# Source: https://adguard.com/ | Github: https://github.com/AdguardTeam/AdGuardHome
APP="Adguard"
var_tags="${var_tags:-adblock}"
function update_script() {
header_info
if [[ ! -d /opt/AdGuardHome ]]; then
msg_error "No ${APP} Installation Found!"
exit
fi
msg_error "Adguard Home can only be updated via the user interface."
exit
}
start
build_container
description
msg_ok "Completed Successfully!\\n"
echo -e "${INFO}${YW} Access it using the following URL:${CL}"
echo -e "${GATEWAY}${BGN}http://${IP}:3000${CL}"
"""
JELLYFIN_SH = """# Source: https://jellyfin.org/ | Github: https://github.com/jellyfin/jellyfin
APP="Jellyfin"
function update_script() {
msg_info "Updating ${APP} LXC"
$STD apt-get update
$STD apt-get -y upgrade
}
echo -e "${GATEWAY}${BGN}http://${IP}:8096${CL}"
"""
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
einstellungen_mod.einstellungen.cache_clear()
katalog._stand.update(mtime=None, daten={})
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
katalog._stand.update(mtime=None, daten={})
class _Antwort:
def __init__(self, text: str, status: int = 200):
self.text, self.status_code = text, status
def raise_for_status(self):
if self.status_code >= 400:
raise RuntimeError(f"HTTP {self.status_code}")
# --- Katalog -------------------------------------------------------------------------------------------------------
def test_katalog_liest_das_update_skript():
assert katalog.aus_skript("adguard", ADGUARD_SH) == {
"name": "Adguard", "quelle": "AdguardTeam/AdGuardHome", "port": 3000, "https": False, "pfad": "/",
"weg": "eigene-oberflaeche"}
jf = katalog.aus_skript("jellyfin", JELLYFIN_SH)
assert (jf["quelle"], jf["port"], jf["weg"]) == ("jellyfin/jellyfin", 8096, "skript")
leer = katalog.aus_skript("etwas", "#!/bin/bash\n")
assert leer == {"name": "etwas", "quelle": None, "port": None, "https": False, "pfad": "/", "weg": "skript"}
def test_katalog_prueft_den_port_und_merkt_sich_alles(daten, monkeypatch):
geholt: list[str] = []
monkeypatch.setattr(katalog, "_antwortet", lambda ip, port: False) # der User hat den Port geändert
e = katalog.eintragen("jellyfin", "192.168.178.170", holen=lambda k: geholt.append(k) or _Antwort(JELLYFIN_SH),
jetzt=JETZT)
assert geholt == ["jellyfin"] and (e["port"], e["port_skript"]) == (None, 8096)
app = apps.app_fuer("jellyfin", "jellyfin")
assert (app.name, app.quelle, app.port, app.weg) == ("Jellyfin", "jellyfin/jellyfin", None, "skript")
assert apps.adresse(app, "192.168.178.170") is None # keine Webprüfung auf Verdacht
# Was von Hand im Katalog steht, geht vor (AdGuard: Port 8080 statt der 3000 aus dem Skript).
assert apps.app_fuer("adguard", "adguard").port == 8080
katalog.eintragen("kaputt", None, holen=lambda k: _Antwort("", 404), jetzt=JETZT)
assert katalog.app("kaputt") is None
bericht = {"gaeste": [{"vmid": 1, "app": {"kennung": "jellyfin"}}, {"vmid": 2, "app": {"kennung": "kaputt"}},
{"vmid": 3, "app": {"kennung": "gitea"}}, {"vmid": 4, "app": {"kennung": "neu-da"}, "ip": "x"}]}
assert katalog.fehlende(bericht, JETZT + 3600) == [("neu-da", "x")]
assert katalog.fehlende(bericht, JETZT + 2 * 86400) == [("kaputt", None), ("neu-da", "x")] # Fehler nach 1 Tag
assert katalog.fehlende(bericht, JETZT + 8 * 86400)[0] == ("jellyfin", None)
# --- Register „Neu entdeckt“ -----------------------------------------------------------------------------------------
def _gast(vmid: int, name: str, **mehr) -> dict:
return {"vmid": vmid, "art": "lxc", "name": name, "status": "running", "etiketten": [], "erlaubt": False, **mehr}
def test_neue_geraete_werden_einmal_gemeldet(daten):
meldungen: list[str] = []
basis = [_gast(100, "adguard"), _gast(107, "homelab-orchestrator", etiketten=["mc2"])]
assert entdecken.bericht_gesehen({"gaeste": basis}, JETZT, meldungen.append) == [] # erster Bericht: alles bekannt
neu = [*basis, _gast(108, "jellyfin", app={"kennung": "jellyfin"}, etiketten=["community-script"], erlaubt=True)]
assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 600, meldungen.append) == ["ct-108"]
assert entdecken.bericht_gesehen({"gaeste": neu}, JETZT + 1200, meldungen.append) == []
assert len(meldungen) == 1 and meldungen[0].startswith("Neu im Homelab: Container 108 „jellyfin“")
# Dieselbe Nummer mit anderem Namen ist ein neues Gerät; ein verschwundenes fällt aus dem Register.
anders = [_gast(100, "adguard"), _gast(108, "immich")]
assert entdecken.bericht_gesehen({"gaeste": anders}, JETZT + 1800, meldungen.append) == ["ct-108"]
register = json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8"))
assert set(register) == {"ct-100", "ct-108"} and register["ct-108"]["name"] == "immich"
assert entdecken.bericht_gesehen({"gaeste": []}, JETZT + 2400, meldungen.append) == [] # nie leer räumen
assert set(json.loads((daten / "geraete-bekannt.json").read_text(encoding="utf-8"))) == {"ct-100", "ct-108"}
def test_uebernehmen(daten, monkeypatch):
from routers import homelab
entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None)
bericht = {"host": {"nicht_gesichert": [108]}, "gaeste": [
_gast(100, "adguard"), _gast(108, "jellyfin", ip="192.168.178.170", app={"kennung": "jellyfin"}),
{"vmid": 109, "art": "qemu", "name": "win11", "status": "stopped", "etiketten": [], "erlaubt": False}]}
entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None)
kanal.bericht_speichern(bericht)
app = FastAPI()
app.include_router(homelab.router)
client = TestClient(app)
geraete = {g["id"]: g for g in client.get("/api/homelab/neu").json()["geraete"]}
assert set(geraete) == {"ct-108", "vm-109"}
assert geraete["ct-108"]["ohne_sicherung"] is True and geraete["vm-109"]["art"] == "vm"
assert client.post("/api/homelab/neu/vm-109", json={"wahl": "beobachten"}).json()["ok"] is True
assert kanal.liste() == [] # kein Etikett, kein Auftrag
antworten = {"status": "fertig", "code": 0, "text": "etiketten=watcher"}
monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: antworten)
gut = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"}).json()
assert gut["ok"] is True and "für Updates freigegeben" in gut["text"]
(auftrag,) = kanal.liste()
assert (auftrag["aktion"], auftrag["parameter"]) == ("etikett_setzen", {"vmid": 108, "etikett": "watcher", "an": True})
assert client.get("/api/homelab/neu").json()["geraete"] == []
weg = client.post("/api/homelab/neu/ct-108", json={"wahl": "freigeben"})
assert weg.status_code == 409 and "nicht (mehr)" in weg.json()["detail"]
assert client.post("/api/homelab/neu/ct-100", json={"wahl": "irgendwas"}).status_code == 409
def test_etikett_scheitert_und_das_geraet_bleibt_neu(daten, monkeypatch):
entdecken.bericht_gesehen({"gaeste": [_gast(100, "adguard")]}, JETZT, lambda t: None)
bericht = {"gaeste": [_gast(100, "adguard"), _gast(108, "jellyfin")]}
entdecken.bericht_gesehen(bericht, JETZT + 60, lambda t: None)
kanal.bericht_speichern(bericht)
monkeypatch.setattr(kanal, "warten", lambda auftrag_id, zeitlimit_s, takt_s=2.0: None)
antwort = entdecken.uebernehmen("ct-108", "ausschliessen")
assert antwort == {"ok": False, "detail": "Das Etikett ließ sich nicht setzen: Der Ausführer hat nicht rechtzeitig "
"geantwortet."}
assert [g["id"] for g in entdecken.neue()] == ["ct-108"]
# --- Ausführer: etikett_setzen ---------------------------------------------------------------------------------------
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_etikett", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_etiketten_regeln(a):
assert a.etiketten_neu(["community-script", "adblock"], "watcher", True) == ["community-script", "adblock", "watcher"]
assert a.etiketten_neu(["watcher", "adblock"], "watcher-aus", True) == ["adblock", "watcher-aus"]
assert a.etiketten_neu(["watcher-aus", "adblock"], "watcher-aus", False) == ["adblock"]
with pytest.raises(ValueError):
a.etiketten_neu([], "community-script", True) # nur watcher und watcher-aus
def test_etikett_setzen_auch_ohne_freigabe(a, monkeypatch):
befehle: list[list[str]] = []
konfig = {"tags": "adblock;community-script"}
def pvesh(pfad, *parameter):
if pfad == "/cluster/resources":
return [{"vmid": 108, "type": "lxc", "node": "pve", "tags": konfig["tags"]}]
return dict(konfig)
monkeypatch.setattr(a, "NODE", "pve")
monkeypatch.setattr(a, "_pvesh", pvesh)
monkeypatch.setattr(a, "_laufen", lambda befehl, zeitlimit=60, sanft=False: befehle.append(befehl) or (0, ""))
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}})["code"] == 0
assert befehle == [["pvesh", "set", "/nodes/pve/lxc/108/config", "--tags", "adblock;community-script;watcher"]]
konfig["tags"] = "watcher"
a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": False}})
assert befehle[-1] == ["pvesh", "set", "/nodes/pve/lxc/108/config", "--delete", "tags"]
schon = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher", "an": True}})
assert schon["code"] == 0 and "Schon so." in schon["text"] and len(befehle) == 2
fremd = a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "prod", "an": True}})
assert fremd["code"] == 2 and "darf der Orchestrator nicht setzen" in fremd["text"]
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 999, "etikett": "watcher"}})["code"] == 2
monkeypatch.setattr(a, "NUR_LESEN", True)
assert a.ausfuehren({"aktion": "etikett_setzen", "parameter": {"vmid": 108, "etikett": "watcher"}})["code"] == 2