"""„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