Files
mission-control-v2/backend/tests/test_entdecken.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

208 lines
11 KiB
Python

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