Welle 2 · „Was ist neu?“ vor dem Knopf: Release Notes, Debian-Änderungslisten und Lucys Kurzfassung

Vor einem App-Update sammelt der Orchestrator die Release Notes aller Versionen zwischen installiert und neu (GitHub),
beim Proxmox-Host die Debian-Änderungslisten der wichtigsten Pakete (neue Ausführer-Aktion changelog, nur lesend, nur
für anstehende Pakete). Lucy fasst sie über das Modell fast der KI-Box auf Deutsch zusammen: Handarbeit ganz oben,
höchstens fünf Stichpunkte, Brüche zuerst. Die Update-Tabelle hat „Was ist neu?“ je Gerät, und die Rückfrage vor dem
Update zeigt die Kurzfassung gleich mit. Gemerkt je Version; 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:47:04 +02:00
co-authored by Claude Opus 5.5
parent 0190c25d5a
commit 45f11bc3dc
11 changed files with 604 additions and 7 deletions
+9
View File
@@ -6,6 +6,7 @@
POST /api/homelab/ziele/{id}/suchen Paketlisten im Gast erneuern
POST /api/homelab/ziele/{id}/docker Docker über Arcane (zuerst Probelauf)
POST /api/homelab/ziele/{id}/rueckweg-probe Rückweg testen: Snapshot, absichtlich rot, zurück, nachprüfen
GET /api/homelab/ziele/{id}/neuerungen?baustein=app|pakete Was ist neu? Release Notes + Lucys Kurzfassung
POST /api/homelab/ziele/pve/neustart Proxmox-Host neu starten (eigener Knopf)
GET /api/homelab/laeufe Die letzten Läufe (Updates, Snapshot-Aktionen) mit ihren Schritten
GET /api/homelab/snapshots Snapshots und Sicherungen je Gerät (Aktionen: siehe unten)
@@ -166,6 +167,14 @@ def speicher_lage() -> dict:
return speicher.lage()
@router.get("/ziele/{ziel_id}/neuerungen")
def neuerungen(ziel_id: str, baustein: str = "app") -> dict:
"""Release Notes bzw. Änderungslisten vor dem Update, mit Lucys Kurzfassung (seit 25.09.2026). Kann beim ersten
Mal einige Sekunden dauern (GitHub, Ausführer, Modell); danach ist es gemerkt."""
from services.homelab import neuerungen as neu
return neu.fuer(ziel_id, baustein)
@router.get("/neu")
def neu_entdeckt() -> dict:
"""Geräte, die der Orchestrator noch nicht kennt, mit Vorschlägen (seit 25.09.2026)."""
+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", "etikett_setzen"}
"kernel_aufraeumen", "sicherung_probe", "etikett_setzen", "changelog"}
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 …
+212
View File
@@ -0,0 +1,212 @@
"""Was ein Update bringt, vor dem Knopf (Ausbauplan Welle 2, Punkt 9, seit 25.09.2026).
App (Community-Script mit GitHub-Projekt) die Release Notes aller Versionen zwischen installiert und neu
(GitHub-API, höchstens zehn, je auf 4 000 Zeichen gekürzt)
Proxmox-Host (Pakete) die Debian-Änderungslisten der wichtigsten Pakete (Proxmox, Kernel,
QEMU, LXC, ZFS …, höchstens acht) — der Ausführer holt sie nur lesend
(Aktion changelog), jeweils bis zur installierten Version
Lucy fasst das auf Deutsch zusammen (Modell `fast` der KI-Box über deren /v1, Aufbau wie bei den Updates der Box):
eine erste Zeile, ob der User selbst etwas tun muss („Handarbeit“), dann höchstens fünf Stichpunkte, Brüche und
geänderte Einstellungen zuerst und mit ⚠️. Fakten kommen aus den Notes, die Worte vom Modell. Antwortet die Box
nicht, stehen die Notes ohne Kurzfassung da. Ergebnisse werden je (Gerät, Baustein, von, nach) gemerkt
(<Datenordner>/neuerungen.json) — dieselbe Version fasst Lucy nur einmal zusammen.
"""
import json
import logging
import re
import threading
import time
from pathlib import Path
import httpx
from kern.einstellungen import einstellungen
from kern.github import github_json
from services.homelab import apps, inventar, kanal
log = logging.getLogger(__name__)
MAX_VERSIONEN = 10
MAX_ZEICHEN = 4000
MAX_PAKETE = 8
WICHTIG = re.compile(r"pve|proxmox|qemu|lxc|kernel|zfs|ceph|corosync|spiceterm|novnc|libpve")
ZEITLIMIT_LLM = httpx.Timeout(120.0, connect=5.0)
FEHLER_MERKEN_S = 600
_AKTION = re.compile(r"^\s*AKTION:\s*(JA|NEIN)\b\s*[—–-]?\s*(.*)$", re.IGNORECASE)
_lock = threading.Lock()
def _pfad() -> Path:
return einstellungen().daten_dir / "neuerungen.json"
def _gemerkt() -> dict:
try:
daten = json.loads(_pfad().read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}
return daten if isinstance(daten, dict) else {}
def _merken(schluessel: str, ergebnis: dict) -> None:
with _lock:
daten = _gemerkt()
daten[schluessel] = {"zeit": time.time(), "ergebnis": ergebnis}
# Nur die jüngsten 100 Einträge: alte Versionen braucht niemand mehr.
daten = dict(sorted(daten.items(), key=lambda t: t[1].get("zeit", 0))[-100:])
pfad = _pfad()
pfad.parent.mkdir(parents=True, exist_ok=True)
tmp = pfad.with_suffix(".tmp")
tmp.write_text(json.dumps(daten, ensure_ascii=False), encoding="utf-8")
tmp.replace(pfad)
# --- Quellen -----------------------------------------------------------------------------------------------------
def zwischen(releases: object, installiert: str, neu: str) -> list[dict]:
"""Die Releases mit installiert < Version <= neu, neueste zuerst (ohne Entwürfe und Vorabversionen)."""
von, bis = inventar._teile(installiert), inventar._teile(neu)
if not von or not bis:
return []
auswahl = []
for r in releases if isinstance(releases, list) else []:
if not isinstance(r, dict) or r.get("draft") or r.get("prerelease"):
continue
version = inventar._teile(str(r.get("tag_name") or ""))
if version and von < version <= bis:
auswahl.append((version, {"version": str(r.get("tag_name")), "titel": r.get("name") or r.get("tag_name"),
"datum": r.get("published_at"), "url": r.get("html_url"),
"text": str(r.get("body") or "").strip()[:MAX_ZEICHEN]}))
auswahl.sort(key=lambda t: t[0], reverse=True)
return [r for _, r in auswahl[:MAX_VERSIONEN]]
def wichtige_pakete(updates: list[dict]) -> list[str]:
"""Die Pakete, deren Änderungsliste zählt: Proxmox, Kernel und Co. zuerst, höchstens MAX_PAKETE."""
namen = [str(u.get("paket")) for u in updates if isinstance(u, dict) and u.get("paket")]
return sorted(namen, key=lambda n: (not WICHTIG.search(n), n))[:MAX_PAKETE]
def _changelogs(pakete: list[str]) -> dict[str, str]:
"""Über den Ausführer (Aktion changelog, nur lesend). Leer, wenn er nicht antwortet."""
auftrag = kanal.anlegen("changelog", {"pakete": pakete})
ergebnis = kanal.warten(auftrag, 120)
if not ergebnis or ergebnis.get("status") != "fertig":
return {}
try:
daten = json.loads(ergebnis.get("text") or "{}")
except ValueError:
return {}
return {k: str(v) for k, v in daten.items()} if isinstance(daten, dict) else {}
# --- Lucys Kurzfassung ---------------------------------------------------------------------------------------------
def auswerten(text: str) -> dict:
"""Die Antwort des Modells: {"aktion": bool|None, "aktion_text", "punkte": […]}. Tolerant: Die AKTION-Zeile darf
irgendwo stehen, Stichpunkte beginnen mit „-“ oder „•“."""
aktion, aktion_text, punkte = None, "", []
for zeile in text.splitlines():
if aktion is None and (m := _AKTION.match(zeile)):
aktion, aktion_text = m.group(1).upper() == "JA", m.group(2).strip()
elif zeile.strip().startswith(("-", "•", "*")):
punkte.append(zeile.strip().lstrip("-•* ").strip())
return {"aktion": aktion, "aktion_text": aktion_text, "punkte": [p for p in punkte if p][:5]}
def zusammenfassen(was: str, von: str, nach: str, quellen: str) -> dict | None:
"""Lucy fasst zusammen (Modell `fast` der KI-Box). None, wenn es keine Box gibt oder sie nicht antwortet."""
box = einstellungen().partner_url
if not box or not quellen.strip():
return None
prompt = (
f"Du bist Lucy und betreust ein Homelab. Vor einem Update von {was} ({von} → {nach}) liest du die "
"Änderungen und sagst dem Besitzer, was ihn betrifft. Die Änderungen:\n\n" + quellen[:14000] + "\n\n"
"Antworte auf DEUTSCH, knapp und klar, GENAU in diesem Format:\n"
"Zeile 1 beginnt mit 'AKTION: ':\n"
" • 'AKTION: NEIN' — wenn er nichts selbst tun muss (der Orchestrator legt vorher einen Snapshot an und geht "
"bei Problemen von allein zurück). Das ist der Normalfall.\n"
" • 'AKTION: JA — <was er konkret tun muss>' — NUR bei Handarbeit: Migration, geänderte oder entfernte "
"Einstellungen, neue Pflichtangaben, Brüche.\n"
"Danach höchstens 5 kurze Stichpunkte (je mit '- '), Wichtigstes zuerst: Brüche und geänderte Einstellungen "
"ZUERST und mit '⚠️', dann Sicherheitskorrekturen, dann lohnende Neuerungen. Keine Einleitung, keine "
"Überschrift, nichts erfinden, was nicht in den Änderungen steht.")
try:
r = httpx.post(f"{box}/v1/chat/completions", timeout=ZEITLIMIT_LLM, json={
"model": "fast", "max_tokens": 600, "temperature": 0.2,
"chat_template_kwargs": {"enable_thinking": False},
"messages": [{"role": "user", "content": prompt}]})
r.raise_for_status()
wahl = (r.json().get("choices") or [{}])[0]
text = ((wahl.get("message") or {}).get("content") or "").strip()
except (httpx.HTTPError, ValueError, AttributeError):
log.warning("neuerungen: Lucy war nicht zu erreichen", exc_info=True)
return None
if wahl.get("finish_reason") == "length":
text = text.rpartition("\n")[0]
ergebnis = auswerten(text)
return ergebnis if ergebnis["aktion"] is not None or ergebnis["punkte"] else None
# --- Für die Oberfläche --------------------------------------------------------------------------------------------
def _baustein(zid: str, baustein: str) -> tuple[dict | None, dict | None]:
ziel = next((z for z in inventar.ziele()["ziele"] if z["id"] == zid), None)
b = next((x for x in (ziel or {}).get("bausteine") or [] if x["id"] == baustein), None)
return ziel, b
def fuer(zid: str, baustein: str) -> dict:
"""GET /api/homelab/ziele/{zid}/neuerungen?baustein=…: {"verfuegbar", "grund", "von", "nach", "versionen" |
"pakete", "zusammenfassung", "quelle_url"}."""
ziel, b = _baustein(zid, baustein)
if not ziel or not b or b.get("zustand") != "neu":
return {"verfuegbar": False, "grund": "Für diesen Baustein steht kein Update an."}
von, nach = str(b.get("installiert") or ""), str(b.get("verfuegbar") or b.get("kurz") or "")
schluessel = f"{zid}:{baustein}:{von}:{nach}"
alt = _gemerkt().get(schluessel)
if alt and (alt["ergebnis"].get("zusammenfassung") or time.time() - alt.get("zeit", 0) < FEHLER_MERKEN_S):
return alt["ergebnis"]
if zid == "pve" and baustein == "pakete":
ergebnis = _host(von, nach)
elif baustein == "app":
ergebnis = _app(ziel, von, nach)
else:
return {"verfuegbar": False, "grund": "Für Paket-Updates im Gast und Docker-Images gibt es keine Release Notes."}
if ergebnis.get("verfuegbar"):
_merken(schluessel, ergebnis)
return ergebnis
def _app(ziel: dict, von: str, nach: str) -> dict:
gast = inventar.gast(ziel["id"]) or {}
app = apps.app_fuer((gast.get("app") or {}).get("kennung"), gast.get("name"))
if not app or not app.quelle:
return {"verfuegbar": False, "grund": "Zu dieser App kennt der Orchestrator kein GitHub-Projekt."}
url = f"https://github.com/{app.quelle}/releases"
try:
versionen = zwischen(github_json(f"repos/{app.quelle}/releases?per_page=40"), von, nach)
except Exception:
return {"verfuegbar": False, "grund": "GitHub antwortet gerade nicht.", "quelle_url": url}
if not versionen:
return {"verfuegbar": False, "grund": "Zu diesen Versionen stehen auf GitHub keine Release Notes.", "quelle_url": url}
quellen = "\n\n".join(f"## {v['titel']} ({v['version']})\n{v['text']}" for v in versionen)
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach, "versionen": versionen, "quelle_url": url,
"zusammenfassung": zusammenfassen(app.name, von, nach, quellen)}
def _host(von: str, nach: str) -> dict:
host = ((kanal.bericht() or {}).get("bericht") or {}).get("host") or {}
pakete = wichtige_pakete(host.get("updates") or [])
if not pakete:
return {"verfuegbar": False, "grund": "Der Bericht nennt keine Pakete."}
logs = _changelogs(pakete)
if not logs:
return {"verfuegbar": False, "grund": "Der Ausführer hat die Änderungslisten nicht geliefert."}
alle = len(host.get("updates") or [])
quellen = "\n\n".join(f"## {name}\n{text}" for name, text in logs.items())
return {"verfuegbar": True, "grund": None, "von": von, "nach": nach,
"pakete": [{"name": n, "text": t} for n, t in logs.items()], "weitere": max(0, alle - len(logs)),
"zusammenfassung": zusammenfassen(f"Proxmox VE ({alle} Pakete)", von or "installiert", nach or "neu",
quellen)}
+3 -1
View File
@@ -70,8 +70,10 @@ _AKTION = {
"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"),
"changelog": ("Änderungslisten gelesen", "Änderungslisten nicht gelesen", "Änderungslisten werden gelesen",
"Änderungslisten"),
}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe"}
_HOST_AKTIONEN = {"bericht", "host_update", "host_neustart", "kernel_aufraeumen", "sicherung_probe", "changelog"}
_LAUF_WAS = {"app": "Update", "os": "Paket-Update", "pakete": "Paket-Update", "docker": "Docker-Update",
"neustart": "Neustart", "kernel": "Kernel-Aufräumen", "probe": "Rückweg-Probe"}
_SCHRITT_WORT = {"wartet": "wartet", "laeuft": "läuft", "eingespielt": "eingespielt",
+157
View File
@@ -0,0 +1,157 @@
"""„Was ist neu?“ vor dem Knopf (Ausbauplan Welle 2, Punkt 9, 25.09.2026): Release Notes zwischen installiert und neu,
Debian-Änderungslisten des Proxmox-Hosts über den Ausführer und Lucys Kurzfassung. Die Änderungsliste ist ein Auszug
von pvesh get /nodes/pve/apt/changelog --name pve-manager vom 25.09."""
import importlib.util
import json
from pathlib import Path
import pytest
from kern import einstellungen as einstellungen_mod
from services.homelab import inventar, kanal, neuerungen
AUSFUEHRER = Path(__file__).resolve().parents[2] / "deploy" / "homelab" / "ausfuehrer.py"
RELEASES = [
{"tag_name": "v1.27.0-rc1", "prerelease": True, "body": "Vorab"},
{"tag_name": "v1.26.1", "name": "Gitea 1.26.1", "published_at": "2026-09-20T10:00:00Z", "html_url": "u1",
"body": "* Security: fix XSS in markdown renderer"},
{"tag_name": "v1.26.0", "name": "Gitea 1.26.0", "published_at": "2026-09-01T10:00:00Z", "html_url": "u0",
"body": "BREAKING: `[server] LFS_JWT_SECRET` moved to `[lfs] JWT_SECRET`"},
{"tag_name": "v1.25.4", "body": "alt"},
{"tag_name": "v1.26.2", "draft": True, "body": "Entwurf"},
]
CHANGELOG = """pve-manager (9.2.20) trixie; urgency=medium
* Revert "partially fix #1989: ui: qemu: disk: add qcow2 cache size config"
-- Proxmox Support Team <support@proxmox.com> Fri, 11 Sep 2026 14:10:20 +0200
pve-manager (9.2.19) trixie; urgency=medium
* pvesh: fix startup failure caused by API endpoints with empty parameter schemas
-- Proxmox Support Team <support@proxmox.com> Tue, 01 Sep 2026 10:00:00 +0200
pve-manager (9.2.18) trixie; urgency=medium
* älter
"""
def test_releases_zwischen_installiert_und_neu():
versionen = neuerungen.zwischen(RELEASES, "1.25.4", "1.26.1")
assert [v["version"] for v in versionen] == ["v1.26.1", "v1.26.0"] # ohne Vorab und Entwurf
assert versionen[1]["text"].startswith("BREAKING") and versionen[0]["url"] == "u1"
assert neuerungen.zwischen(RELEASES, "Image vom 2026-08-27", "") == [] # Datum statt Version
assert neuerungen.zwischen("kaputt", "1.0", "2.0") == []
def test_wichtige_pakete_zuerst():
updates = [{"paket": n} for n in ("libc6", "pve-manager", "zlib1g", "proxmox-kernel-7.0", "qemu-server", "curl")]
assert neuerungen.wichtige_pakete(updates)[:3] == ["proxmox-kernel-7.0", "pve-manager", "qemu-server"]
def test_lucys_antwort_auswerten():
a = neuerungen.auswerten("AKTION: JA — In app.ini [lfs] JWT_SECRET setzen\n- ⚠️ LFS-Schlüssel umgezogen\n"
"- Sicherheitskorrektur im Markdown\n\n* noch was")
assert a == {"aktion": True, "aktion_text": "In app.ini [lfs] JWT_SECRET setzen",
"punkte": ["⚠️ LFS-Schlüssel umgezogen", "Sicherheitskorrektur im Markdown", "noch was"]}
assert neuerungen.auswerten("\nAKTION: NEIN\n- nur Fehlerkorrekturen")["aktion"] is False
@pytest.fixture
def daten(tmp_path, monkeypatch):
monkeypatch.setenv("MC_DATEN_DIR", str(tmp_path))
monkeypatch.setenv("MC_PARTNER_URL", "http://box:9001")
einstellungen_mod.einstellungen.cache_clear()
yield tmp_path
einstellungen_mod.einstellungen.cache_clear()
class _Antwort:
def __init__(self, daten: dict):
self._daten = daten
def raise_for_status(self):
pass
def json(self):
return self._daten
def test_kurzfassung_von_der_box(daten, monkeypatch):
gesendet: list[dict] = []
monkeypatch.setattr(neuerungen.httpx, "post", lambda url, timeout, json: gesendet.append({"url": url, **json}) or _Antwort(
{"choices": [{"message": {"content": "AKTION: NEIN\n- Fehler behoben"}, "finish_reason": "stop"}]}))
z = neuerungen.zusammenfassen("Gitea", "1.25.4", "1.26.1", "## v1.26.1\n* fix")
assert z == {"aktion": False, "aktion_text": "", "punkte": ["Fehler behoben"]}
assert gesendet[0]["url"] == "http://box:9001/v1/chat/completions" and gesendet[0]["model"] == "fast"
assert "Gitea (1.25.4 → 1.26.1)" in gesendet[0]["messages"][0]["content"]
monkeypatch.delenv("MC_PARTNER_URL")
einstellungen_mod.einstellungen.cache_clear()
assert neuerungen.zusammenfassen("Gitea", "1", "2", "x") is None # ohne Box keine Kurzfassung
def test_fuer_eine_app_und_gemerkt(daten, monkeypatch):
ziel = {"id": "ct-104", "bausteine": [{"id": "app", "zustand": "neu", "installiert": "1.25.4", "verfuegbar": "1.26.1"}]}
monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]})
monkeypatch.setattr(inventar, "gast", lambda zid: {"vmid": 104, "name": "gitea", "app": {"kennung": "gitea"}})
abrufe: list[str] = []
monkeypatch.setattr(neuerungen, "github_json", lambda pfad: abrufe.append(pfad) or RELEASES)
monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: {
"aktion": True, "aktion_text": "JWT_SECRET umziehen", "punkte": [quellen.count("## ")]})
e = neuerungen.fuer("ct-104", "app")
assert e["verfuegbar"] and [v["version"] for v in e["versionen"]] == ["v1.26.1", "v1.26.0"]
assert e["zusammenfassung"]["punkte"] == [2] and e["quelle_url"] == "https://github.com/go-gitea/gitea/releases"
assert abrufe == ["repos/go-gitea/gitea/releases?per_page=40"]
assert neuerungen.fuer("ct-104", "app") == e and len(abrufe) == 1 # gemerkt
assert neuerungen.fuer("ct-104", "os")["verfuegbar"] is False
def test_fuer_den_proxmox_host(daten, monkeypatch):
ziel = {"id": "pve", "bausteine": [{"id": "pakete", "zustand": "neu", "installiert": "9.2.19", "kurz": "3 Pakete"}]}
monkeypatch.setattr(inventar, "ziele", lambda: {"ziele": [ziel]})
monkeypatch.setattr(kanal, "bericht", lambda: {"bericht": {"host": {"updates": [
{"paket": "pve-manager"}, {"paket": "curl"}, {"paket": "libc6"}]}}})
angefragt: list[list[str]] = []
monkeypatch.setattr(neuerungen, "_changelogs", lambda pakete: angefragt.append(pakete) or {"pve-manager": "* Revert"})
monkeypatch.setattr(neuerungen, "zusammenfassen", lambda was, von, nach, quellen: {"aktion": False, "aktion_text": "",
"punkte": [was]})
e = neuerungen.fuer("pve", "pakete")
assert angefragt == [["pve-manager", "curl", "libc6"]]
assert e["pakete"] == [{"name": "pve-manager", "text": "* Revert"}] and e["weitere"] == 2
assert e["zusammenfassung"]["punkte"] == ["Proxmox VE (3 Pakete)"]
# --- Ausführer: changelog -------------------------------------------------------------------------------------------
@pytest.fixture(scope="module")
def a():
spec = importlib.util.spec_from_file_location("ausfuehrer_changelog", AUSFUEHRER)
modul = importlib.util.module_from_spec(spec)
spec.loader.exec_module(modul)
return modul
def test_changelog_bis_zur_installierten_version(a):
neu = a.changelog_neu(CHANGELOG, "9.2.19")
assert neu.startswith("pve-manager (9.2.20)") and "9.2.19" not in neu
assert a.changelog_neu(CHANGELOG, None) == CHANGELOG.strip()
assert a.changelog_neu(CHANGELOG, "1.0") == CHANGELOG.strip() # unbekannt: alles
def test_changelog_nur_fuer_anstehende_pakete_und_nur_lesend(a, monkeypatch):
def pvesh(pfad, *parameter):
if pfad.endswith("/apt/update"):
return [{"Package": "pve-manager", "OldVersion": "9.2.19"}]
return CHANGELOG
monkeypatch.setattr(a, "NODE", "pve")
monkeypatch.setattr(a, "_pvesh", pvesh)
monkeypatch.setattr(a, "NUR_LESEN", True)
antwort = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve-manager", "curl"]}})
assert antwort["code"] == 0
assert list(json.loads(antwort["text"])) == ["pve-manager"] # curl steht nicht an
kaputt = a.ausfuehren({"aktion": "changelog", "parameter": {"pakete": ["pve; rm -rf /"]}})
assert kaputt["code"] == 2 and "ungültige Paketliste" in kaputt["text"]