"""Konfig-Sicherung ins eigene Gitea (Ausbauplan „Später“, Punkt 20, 26.09.2026). User-Entscheid: privates Repo, Geheimnisse geschwärzt. Der Ausführer schwärzt schon auf dem Proxmox-Host (dieselben Regeln wie kern/schwaerzen.py), die KI-Box committet nur bei Änderungen.""" import importlib.util import io import json import shutil import subprocess import tarfile from pathlib import Path import pytest from kern import einstellungen as einstellungen_mod from kern.schwaerzen import ERSATZ, REGELN, schwaerzen AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py" GIT = shutil.which("git") ADGUARD = """http: address: 0.0.0.0:80 session_ttl: 720h users: - name: admin password: $2y$10$abcdefghijklmnopqrstuuWXYZ0123456789abcdefghijklmnopq tls: private_key: '' certificate_path: /opt/cert.pem """ PEM = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow\nabc\n-----END RSA PRIVATE KEY-----" COMPOSE = """services: db: image: postgres@sha256:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef environment: - POSTGRES_PASSWORD=supergeheim - POSTGRES_USER=rippy """ NGINX = """location / { proxy_set_header Authorization "Basic dXNlcjpwYXNzd29yZA=="; auth_basic_user_file /data/access/1; proxy_pass http://paperless:Geheim99@192.168.178.40:8000; } """ @pytest.fixture(scope="module") def a(): spec = importlib.util.spec_from_file_location("ausfuehrer_konfig", AUSFUEHRER) modul = importlib.util.module_from_spec(spec) spec.loader.exec_module(modul) return modul def test_schwaerzen(): text, n = schwaerzen(ADGUARD) assert "$2y$" not in text and f"password: {ERSATZ}" in text and f"private_key: {ERSATZ}" in text assert "session_ttl: 720h" in text and "certificate_path: /opt/cert.pem" in text and n == 2 assert schwaerzen(PEM)[0] == f"-----BEGIN PRIVATE KEY-----\n{ERSATZ}\n-----END PRIVATE KEY-----" compose, _ = schwaerzen(COMPOSE) assert f"- POSTGRES_PASSWORD={ERSATZ}" in compose and "- POSTGRES_USER=rippy" in compose assert "0123456789abcdef" not in compose # lange Hex-Ketten (hier ein Digest) fallen mit nginx, _ = schwaerzen(NGINX) assert f'proxy_set_header Authorization "{ERSATZ}";' in nginx and "Geheim99" not in nginx assert "auth_basic_user_file /data/access/1;" in nginx assert schwaerzen("Hitonabi:$apr1$xyz$abcdefgh")[0] == f"Hitonabi:{ERSATZ}" assert schwaerzen("keyboard: de\nkey: abc\napi_key: sk-1234567890abcdefgh\n")[0] == \ f"keyboard: de\nkey: {ERSATZ}\napi_key: {ERSATZ}\n" assert schwaerzen("hf_token: hf_abcdefghijklmnopqrstu")[0] == f"hf_token: {ERSATZ}" assert schwaerzen("password:\n - x")[0] == "password:\n - x" # leerer Wert (Liste folgt) bleibt def test_ausfuehrer_schwaerzt_genauso(a): assert [(m.pattern, m.flags, e) for m, e in a.SCHWAERZEN] == [(m.pattern, m.flags, e) for m, e in REGELN] for probe in (ADGUARD, PEM, COMPOSE, NGINX): assert a.schwaerzen(probe) == schwaerzen(probe) def test_ausfuehrer_liest_aus_dem_container(a, monkeypatch): puffer = io.BytesIO() with tarfile.open(fileobj=puffer, mode="w") as archiv: for name, inhalt in (("opt/npmplus/nginx/proxy_host/14.conf", b"server { }"), ("opt/npmplus/access/1", b"u:$apr1$x"), ("opt/npmplus/bild.png", b"\x89PNG\x00\x00")): teil = tarfile.TarInfo(name) teil.size = len(inhalt) archiv.addfile(teil, io.BytesIO(inhalt)) befehle = [] def lauf(befehl, capture_output, timeout): befehle.append(befehl) return subprocess.CompletedProcess(befehl, 0, stdout=puffer.getvalue(), stderr=b"") monkeypatch.setattr(a.subprocess, "run", lauf) dateien = a._gast_konfig(101, a.KONFIG_GAST["npmplus"], "npmplus") assert dateien == {"npmplus/nginx/proxy_host/14.conf": "server { }", "npmplus/access/1": "u:$apr1$x"} assert befehle[0][:5] == ["pct", "exec", "101", "--", "sh"] and "opt/npmplus/tls" not in befehle[0][-1] # --- Homelab-Teil ---------------------------------------------------------------------------------------------- @pytest.fixture def daten(tmp_path, monkeypatch): monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path)) einstellungen_mod.einstellungen.cache_clear() yield tmp_path einstellungen_mod.einstellungen.cache_clear() def test_homelab_nimmt_an_und_legt_arcane_dazu(daten, monkeypatch): from services.homelab import konfig assert konfig.speichern({"zeit": 1.0, "dateien": {"proxmox/storage.cfg": "dir: local", "../boese": "x", "/etc/x": "y"}, "geschwaerzt": 3, "fehler": []}) == {"ok": True, "dateien": 1} monkeypatch.setattr(konfig, "arcane_projekte", lambda: ({"arcane/rippy/.env": f"TOKEN={ERSATZ}"}, 1, [])) lage = konfig.lage() assert lage["dateien"] == {"proxmox/storage.cfg": "dir: local", "arcane/rippy/.env": f"TOKEN={ERSATZ}"} assert lage["geschwaerzt"] == 4 and lage["fehler"] == [] def test_arcane_projekte_geschwaerzt(daten, monkeypatch): from services.homelab import einstellungen as he from services.homelab import konfig antworten = { "/environments/0/projects?limit=200": {"data": [{"id": "p1", "name": "rippy"}, {"id": "p2", "name": "../x"}]}, "/environments/0/projects/p1/compose": {"data": {"composeFileName": "compose.yaml", "composeContent": COMPOSE, "envContent": "TMDB_KEY=abc123\nZEITZONE=Europe/Berlin\n"}}, } monkeypatch.setattr(he, "arcane_url", lambda: "http://arcane:3552") monkeypatch.setattr(konfig.arcane, "schluessel", lambda: "k") monkeypatch.setattr(konfig.arcane, "umgebungen", lambda url: [{"id": 0}]) monkeypatch.setattr(konfig.arcane, "_get", lambda url, pfad: antworten[pfad]) dateien, zahl, fehler = konfig.arcane_projekte() assert sorted(dateien) == ["arcane/rippy/.env", "arcane/rippy/compose.yaml"] and fehler == [] assert dateien["arcane/rippy/.env"] == f"TMDB_KEY={ERSATZ}\nZEITZONE=Europe/Berlin\n" and zahl == 3 # --- KI-Box: Git ------------------------------------------------------------------------------------------------ @pytest.mark.skipif(GIT is None, reason="braucht git") def test_box_committet_nur_aenderungen(daten, tmp_path, monkeypatch): from services import konfig_sicherung as ks fern = tmp_path / "fern.git" subprocess.run([GIT, "init", "-q", "--bare", "-b", "main", str(fern)], check=True) monkeypatch.setattr(ks, "ARBEIT", tmp_path / "arbeit") monkeypatch.setattr(ks, "zugang", lambda: ("Hitonabi", "t")) monkeypatch.setattr(ks, "repo_sicherstellen", lambda benutzer, token: fern.as_posix()) monkeypatch.setattr(ks, "box_dateien", lambda: {"ki-box/hermes/config.yaml": "model: hirn\napi_key: sk-abcdefghijklmnopqrs\n"}) homelab = {"dateien": {"proxmox/storage.cfg": "dir: local", "adguard/AdGuardHome.yaml": "dns: {}"}, "geschwaerzt": 0, "fehler": [], } monkeypatch.setattr(ks, "_homelab", lambda frisch: homelab) erstes = ks.sichern(jetzt=1_790_450_000.0) assert erstes["ok"] and erstes["text"].startswith("4 Änderungen gesichert (0 geändert, 4 neu, 0 weg)") log = subprocess.run([GIT, "--git-dir", str(fern), "log", "--format=%s", "main"], capture_output=True, text=True).stdout assert log.startswith("Konfig-Sicherung ") inhalt = subprocess.run([GIT, "--git-dir", str(fern), "show", "main:ki-box/hermes/config.yaml"], capture_output=True, text=True, encoding="utf-8").stdout assert inhalt == f"model: hirn\napi_key: {ERSATZ}\n" assert ks.sichern(jetzt=1_790_450_100.0) == {"ok": True, "text": "Keine Änderung (3 Dateien)."} homelab["dateien"] = {"proxmox/storage.cfg": "dir: local\npbs: pbs-qnap", "adguard/AdGuardHome.yaml": "dns: {}"} drittes = ks.sichern(jetzt=1_790_450_200.0) assert drittes["text"].startswith("1 Änderungen gesichert (1 geändert, 0 neu, 0 weg)") stand = ks.stand() assert stand["zuletzt"]["ergebnis"] == "gesichert" and stand["repo"].endswith("/Hitonabi/homelab-konfig") def test_box_ohne_dateien_oder_zugang(daten, tmp_path, monkeypatch): from services import konfig_sicherung as ks monkeypatch.setattr(ks, "box_dateien", dict) monkeypatch.setattr(ks, "_homelab", lambda frisch: {"dateien": {}, "fehler": ["Homelab nicht erreichbar (ConnectError)."]}) assert ks.sichern() == {"ok": False, "detail": "Keine Einstellungen bekommen. Homelab nicht erreichbar (ConnectError)."} monkeypatch.setattr(ks, "_homelab", lambda frisch: {"dateien": {"proxmox/a.cfg": "x"}}) monkeypatch.setattr(ks, "zugang", lambda: None) assert ks.sichern()["detail"] == "Kein Gitea-Zugang in ~/.git-credentials." assert json.loads((daten / "konfig-sicherung.json").read_text(encoding="utf-8"))["ergebnis"] == "fehler" def test_zugang_aus_git_credentials(tmp_path, monkeypatch): from services import konfig_sicherung as ks datei = tmp_path / "creds" datei.write_text("https://Hitonabi:anderer@git.tobisniceshomelab.ddnsfree.com\n" "http://Hitonabi:t%40ken@192.168.178.153%3a3000\n", encoding="utf-8") monkeypatch.setattr(ks, "GIT_ZUGANG", datei) assert ks.zugang() == ("Hitonabi", "t@ken")