Ob es etwas Neues gibt, zeigt die PrΓΌfsumme des Images in der Registry β der Tag unified-cuda wandert mit. ' +
+ 'Das Image enthΓ€lt llama-swap und llama.cpp zusammen.
';
+ }
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
setInterval(() => {
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
diff --git a/diagnostic/server.js b/diagnostic/server.js
index 5287c24..eb12031 100644
--- a/diagnostic/server.js
+++ b/diagnostic/server.js
@@ -1672,6 +1672,8 @@ function connectRVS(forcePlain) {
broadcast({ type: "rooms_info", payload: msg.payload || {} });
} else if (msg.type === "llm_openai_config_result") {
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
+ } else if (msg.type === "llm_runtime_result" || msg.type === "llm_runtime_progress") {
+ broadcast({ type: msg.type, payload: msg.payload || {} });
} else if (msg.type === "sat_result") {
// Ergebnis einer Satelliten-Aktion β an den Browser (z.B. APK-Rollout).
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
@@ -3693,6 +3695,19 @@ wss.on("connection", (ws) => {
},
timestamp: Date.now(),
});
+ } else if (msg.action === "llm_runtime") {
+ // llama-swap/llama.cpp einer Box: Version pruefen, aktualisieren, zuruecksetzen.
+ const op = String(msg.op || "status");
+ if (!["status", "check", "update", "rollback"].includes(op)) return;
+ sendToRVS_raw({
+ type: "llm_runtime",
+ payload: {
+ instanceId: String(msg.instanceId || ""),
+ op,
+ requestId: String(msg.requestId || ""),
+ },
+ timestamp: Date.now(),
+ });
} else if (msg.action === "agent_history_query") {
// Browser will die gespeicherte Historie eines Agenten β die Bridge
// antwortet mit agent_history_list (auf derselben rvsWs).
diff --git a/rvs/server.js b/rvs/server.js
index e1c9e9c..2651cb5 100644
--- a/rvs/server.js
+++ b/rvs/server.js
@@ -135,6 +135,9 @@ const ALLOWED_TYPES = new Set([
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
// immer aus der .env der Box β er ist ein Docker-Port-Mapping).
"llm_openai_config", "llm_openai_config_result",
+ // llama-swap/llama.cpp einer Box pruefen/aktualisieren/zuruecksetzen
+ // (Diagnostic β Modell-Katalog β β llama.cpp; ausgefuehrt vom llm-updater).
+ "llm_runtime", "llm_runtime_result", "llm_runtime_progress",
// Modell auf eine LLM-Box laden (Diagnostic β Modell-Katalog β βLaden").
// Fehlten hier seit Stage D: jeder Klick auf βLaden" starb still an dieser
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
diff --git a/xtts/docker-compose.yml b/xtts/docker-compose.yml
index 69079e5..78c49f0 100644
--- a/xtts/docker-compose.yml
+++ b/xtts/docker-compose.yml
@@ -207,6 +207,23 @@ services:
- HF_TOKEN=${HF_TOKEN:-}
restart: unless-stopped
+ # βββ LLM-Updater β llama-swap/llama.cpp aktualisieren β
+ # Der EINZIGE Dienst mit Docker-Socket (= root auf der Box). Darum kann er nur
+ # drei feste Dinge fuer aria-llama-swap: Version pruefen, aktualisieren (altes
+ # Image sichern, ziehen, neu anlegen, Selbsttest, sonst zurueck) und auf die
+ # gesicherte Version zuruecksetzen. Kein Port nach aussen; der llm-adapter
+ # spricht ihn im Compose-Netz mit einem Token an (/models/.updater_token).
+ # Ausgeloest wird das aus der Diagnostic (Modell-Katalog β β llama.cpp).
+ llm-updater:
+ build: ./llm-updater
+ container_name: aria-llm-updater
+ profiles: ["llm"]
+ volumes:
+ - /var/run/docker.sock:/var/run/docker.sock
+ - .:/stack:ro # Compose-Datei + .env zum Neu-Anlegen
+ - ./models:/models:ro # Token + welche Modelle vorgeladen sind
+ restart: unless-stopped
+
# βββ OpenHands (optional, Standalone) ββββββββββββββββ
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
diff --git a/xtts/llm-adapter/adapter.py b/xtts/llm-adapter/adapter.py
index 5dc6cf3..86451eb 100644
--- a/xtts/llm-adapter/adapter.py
+++ b/xtts/llm-adapter/adapter.py
@@ -342,7 +342,7 @@ async def _reload_llama() -> None:
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
# Wiederverbinden aufheben.
-_RELIABLE_TYPES = {"llm_response"}
+_RELIABLE_TYPES = {"llm_response", "llm_runtime_result"}
_OUTBOX_TTL_S = 300
_outbox: list = [] # [(ts, mtype, payload)]
_current_ws = None # die aktuell offene RVS-Verbindung
@@ -1002,6 +1002,83 @@ async def _do_llm_request(ws, payload: dict) -> None:
})
+# ββ llama-swap/llama.cpp aktualisieren (ueber den llm-updater) ββ
+# Der Adapter hat bewusst KEINEN Docker-Zugriff. Pruefen, Aktualisieren und
+# Zuruecksetzen macht der llm-updater (eigener Container, nur im Compose-Netz);
+# der Adapter reicht die Diagnostic-Anfrage weiter und meldet den Fortschritt.
+UPDATER_URL = os.getenv("UPDATER_URL", "http://llm-updater:8090").rstrip("/")
+_UPDATER_TOKEN_FILE = "/models/.updater_token"
+
+
+def _updater_token() -> str:
+ """Gemeinsamer Token mit dem llm-updater. Liegt in /models (der Updater
+ haengt es nur lesend ein); fehlt er, legt der Adapter ihn an."""
+ try:
+ tok = open(_UPDATER_TOKEN_FILE).read().strip()
+ if tok:
+ return tok
+ except OSError:
+ pass
+ import secrets
+ tok = secrets.token_hex(24)
+ try:
+ fd = os.open(_UPDATER_TOKEN_FILE, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
+ with os.fdopen(fd, "w") as f:
+ f.write(tok)
+ except OSError as e:
+ logger.warning("Updater-Token nicht schreibbar: %s", e)
+ return tok
+
+
+async def _updater(method: str, path: str, timeout: float = 60) -> dict:
+ try:
+ async with httpx.AsyncClient(timeout=timeout) as c:
+ r = await c.request(method, UPDATER_URL + path,
+ headers={"X-Updater-Token": _updater_token()})
+ return r.json()
+ except httpx.ConnectError:
+ return {"ok": False, "error": "Der Update-Dienst (llm-updater) laeuft auf dieser Box "
+ "nicht. Mit `docker compose up -d llm-updater` starten."}
+ except Exception as e:
+ return {"ok": False, "error": f"Update-Dienst: {str(e)[:150]}"}
+
+
+async def _handle_runtime(ws, payload: dict) -> None:
+ """op = status | check | update | rollback (aus der Diagnostic)."""
+ if (payload.get("instanceId") or INSTANCE_ID) != INSTANCE_ID:
+ return
+ op = payload.get("op", "status")
+ base = {"requestId": payload.get("requestId", ""), "instanceId": INSTANCE_ID,
+ "node": NODE_NAME, "op": op}
+ if op in ("status", "check"):
+ res = await _updater("GET", "/" + op, timeout=60)
+ await _send(ws, "llm_runtime_result", {**base, **res})
+ return
+ if op not in ("update", "rollback"):
+ await _send(ws, "llm_runtime_result", {**base, "ok": False, "error": "unbekannte Aktion"})
+ return
+ start = await _updater("POST", "/" + op)
+ if not start.get("ok"):
+ await _send(ws, "llm_runtime_result", {**base, **start})
+ return
+ logger.info("llama-swap %s gestartet (%s)", op, start.get("job"))
+ seen = 0
+ while True:
+ await asyncio.sleep(3)
+ job = await _updater("GET", "/job", timeout=20)
+ if job.get("error") and "state" not in job:
+ continue # Updater kurz nicht erreichbar β weiter warten
+ lines = job.get("log") or []
+ if len(lines) > seen:
+ await _send(ws, "llm_runtime_progress", {**base, "lines": lines[seen:]})
+ seen = len(lines)
+ if job.get("state") == "done":
+ await _send(ws, "llm_runtime_result", {**base, **(job.get("result") or {})})
+ logger.info("llama-swap %s fertig: %s", op, (job.get("result") or {}).get("ok"))
+ await _announce(ws)
+ return
+
+
async def _handle_openai_config(ws, payload: dict) -> None:
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
@@ -1886,6 +1963,9 @@ async def _run() -> None:
if mtype == "llm_openai_config":
asyncio.create_task(_handle_openai_config(ws, payload))
continue
+ if mtype == "llm_runtime":
+ asyncio.create_task(_handle_runtime(ws, payload))
+ continue
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
"llm_unload_model", "llm_models_request"):
continue
diff --git a/xtts/llm-updater/Dockerfile b/xtts/llm-updater/Dockerfile
new file mode 100644
index 0000000..9228eda
--- /dev/null
+++ b/xtts/llm-updater/Dockerfile
@@ -0,0 +1,8 @@
+# Docker-CLI mit Compose-Plugin + Python (nur Standardbibliothek + PyYAML)
+FROM docker:29-cli
+
+RUN apk add --no-cache python3 py3-yaml
+WORKDIR /app
+COPY updater.py .
+
+CMD ["python3", "-u", "updater.py"]
diff --git a/xtts/llm-updater/updater.py b/xtts/llm-updater/updater.py
new file mode 100644
index 0000000..5705463
--- /dev/null
+++ b/xtts/llm-updater/updater.py
@@ -0,0 +1,398 @@
+"""
+ARIA LLM-Updater (AI-Box) β aktualisiert das llama-swap-Image (mit llama.cpp).
+
+Der einzige Dienst mit Docker-Socket. Darum kann er bewusst NUR drei feste
+Dinge, alle fuer genau einen Container (aria-llama-swap):
+
+ check installierte Version + Image-Pruefsumme, dazu die neueste aus der
+ Registry (Pruefsumme des Tags) und das letzte llama-swap-Release
+ update altes Image sichern (Tag vor-update-), neues ziehen,
+ Container neu anlegen, Selbsttest β scheitert der, automatisch zurueck
+ rollback das gesicherte Image wieder einsetzen, Container neu anlegen, Selbsttest
+
+Kein freier Befehl, keine anderen Container. Erreichbar nur im Compose-Netz
+(kein Port nach aussen), jede Anfrage braucht den Token, den der llm-adapter
+in /models/.updater_token ablegt.
+
+Neu angelegt wird ueber `docker compose β¦ up --no-deps --force-recreate
+llama-swap` mit der Compose-Datei des Stacks β so bleibt alles, was die
+Compose-Datei festlegt (GPU-Runtime, Volumes, LD_LIBRARY_PATH β¦), gleich.
+Relative Pfade loest Compose gegen --project-directory auf; das ist der
+Host-Pfad aus dem Label des laufenden Containers, er muss im Container hier
+nicht existieren.
+"""
+
+from __future__ import annotations
+
+import http.server
+import json
+import logging
+import os
+import re
+import subprocess
+import threading
+import time
+import urllib.error
+import urllib.request
+
+logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
+logger = logging.getLogger("llm-updater")
+
+CONTAINER = os.getenv("LLAMA_CONTAINER", "aria-llama-swap")
+SERVICE = os.getenv("LLAMA_SERVICE", "llama-swap")
+STACK_DIR = os.getenv("STACK_DIR", "/stack")
+TOKEN_FILE = os.getenv("TOKEN_FILE", "/models/.updater_token")
+GEN_CONFIG = os.getenv("LLAMA_GEN_CONFIG", "/models/llama-swap.config.yaml")
+LLAMA_URL = os.getenv("LLAMA_URL", "http://llama-swap:8080").rstrip("/")
+RELEASES_URL = os.getenv("RELEASES_URL", "https://api.github.com/repos/mostlygeek/llama-swap/releases/latest")
+BACKUP_REPO = "llama-swap" # gesicherte Images: llama-swap:vor-update-
+KEEP_BACKUPS = 1
+READY_TIMEOUT_S = int(os.getenv("READY_TIMEOUT_S", "900")) # Modell(e) wieder geladen
+PORT = int(os.getenv("PORT", "8090"))
+
+_job = {"id": "", "op": "", "state": "idle", "log": [], "result": None}
+_job_lock = threading.Lock()
+
+
+# ββ kleine Helfer βββββββββββββββββββββββββββββββββββββββββββββββ
+
+def _run(args: list[str], timeout: int = 120) -> tuple[int, str]:
+ p = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
+ return p.returncode, (p.stdout + p.stderr).strip()
+
+
+def _log(msg: str) -> None:
+ logger.info(msg)
+ with _job_lock:
+ _job["log"].append(f"{time.strftime('%H:%M:%S')} {msg}")
+ _job["log"] = _job["log"][-60:]
+
+
+def _inspect(ref: str, kind: str = "container") -> dict:
+ rc, out = _run(["docker", kind, "inspect", ref])
+ if rc != 0:
+ return {}
+ try:
+ return json.loads(out)[0]
+ except Exception:
+ return {}
+
+
+def _image_ref() -> str:
+ return (_inspect(CONTAINER).get("Config") or {}).get("Image", "")
+
+
+def _local_digest(ref: str) -> str:
+ """Pruefsumme, unter der das Image gezogen wurde (Index/Manifest des Tags)."""
+ repo = ref.rsplit(":", 1)[0] if ":" in ref.split("/")[-1] else ref
+ for d in _inspect(ref, "image").get("RepoDigests") or []:
+ if d.startswith(repo + "@"):
+ return d.split("@", 1)[1]
+ return ""
+
+
+def _versions() -> dict:
+ v = {}
+ rc, out = _run(["docker", "exec", CONTAINER, "llama-swap", "-version"], 30)
+ m = re.search(r"version:\s*(\S+)", out)
+ if rc == 0 and m:
+ v["llamaSwap"] = m.group(1)
+ b = re.search(r"built at\s*(\S+)", out)
+ if b:
+ v["llamaSwapBuilt"] = b.group(1)
+ rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--version"], 30)
+ m = re.search(r"version:\s*(.+)", out)
+ if rc == 0 and m:
+ v["llamaCpp"] = m.group(1).strip()
+ return v
+
+
+def _backups() -> list[dict]:
+ rc, out = _run(["docker", "images", BACKUP_REPO, "--format", "{{.Tag}}\t{{.ID}}\t{{.CreatedAt}}"])
+ res = []
+ for line in out.splitlines() if rc == 0 else []:
+ tag, iid, created = (line.split("\t") + ["", "", ""])[:3]
+ if tag.startswith("vor-update-"):
+ res.append({"tag": f"{BACKUP_REPO}:{tag}", "id": iid, "created": created})
+ return sorted(res, key=lambda b: b["tag"], reverse=True)
+
+
+def _http(url: str, method: str = "GET", headers: dict | None = None, timeout: int = 20):
+ req = urllib.request.Request(url, method=method, headers=headers or {})
+ return urllib.request.urlopen(req, timeout=timeout)
+
+
+def _remote_digest(ref: str) -> str:
+ """Pruefsumme des Tags in der Registry (ohne Docker, anonymer Token)."""
+ name, tag = ref.rsplit(":", 1) if ":" in ref.split("/")[-1] else (ref, "latest")
+ parts = name.split("/")
+ if "." in parts[0] or ":" in parts[0]:
+ host, path = parts[0], "/".join(parts[1:])
+ else:
+ host, path = "registry-1.docker.io", name if "/" in name else f"library/{name}"
+ url = f"https://{host}/v2/{path}/manifests/{tag}"
+ accept = ", ".join([
+ "application/vnd.oci.image.index.v1+json",
+ "application/vnd.docker.distribution.manifest.list.v2+json",
+ "application/vnd.oci.image.manifest.v1+json",
+ "application/vnd.docker.distribution.manifest.v2+json",
+ ])
+ try:
+ r = _http(url, "HEAD", {"Accept": accept})
+ return r.headers.get("Docker-Content-Digest", "")
+ except urllib.error.HTTPError as e:
+ if e.code != 401:
+ raise
+ chal = e.headers.get("WWW-Authenticate", "")
+ p = dict(re.findall(r'(\w+)="([^"]*)"', chal))
+ tok_url = f"{p['realm']}?service={p.get('service', '')}&scope={p.get('scope', f'repository:{path}:pull')}"
+ tok = json.load(_http(tok_url))
+ token = tok.get("token") or tok.get("access_token") or ""
+ r = _http(url, "HEAD", {"Accept": accept, "Authorization": f"Bearer {token}"})
+ return r.headers.get("Docker-Content-Digest", "")
+
+
+def _latest_release() -> dict:
+ try:
+ d = json.load(_http(RELEASES_URL, headers={"Accept": "application/vnd.github+json"}))
+ return {"tag": d.get("tag_name", ""), "published": d.get("published_at", ""),
+ "url": d.get("html_url", "")}
+ except Exception as e:
+ return {"error": str(e)[:120]}
+
+
+def _status(remote: bool) -> dict:
+ ref = _image_ref()
+ if not ref:
+ return {"ok": False, "error": f"Container {CONTAINER} nicht gefunden"}
+ img = _inspect(ref, "image")
+ st = {
+ "ok": True, "image": ref,
+ "installed": {**_versions(), "digest": _local_digest(ref),
+ "created": img.get("Created", "")},
+ "backups": _backups(),
+ "job": {k: _job[k] for k in ("id", "op", "state")},
+ }
+ if remote:
+ try:
+ st["latestDigest"] = _remote_digest(ref)
+ except Exception as e:
+ st["latestError"] = f"Registry nicht erreichbar: {str(e)[:120]}"
+ st["release"] = _latest_release()
+ if st.get("latestDigest") and st["installed"]["digest"]:
+ st["updateAvailable"] = st["latestDigest"] != st["installed"]["digest"]
+ return st
+
+
+# ββ Neu anlegen + Selbsttest ββββββββββββββββββββββββββββββββββββ
+
+def _compose_args() -> list[str]:
+ labels = (_inspect(CONTAINER).get("Config") or {}).get("Labels") or {}
+ proj = labels.get("com.docker.compose.project", "")
+ wdir = labels.get("com.docker.compose.project.working_dir", "")
+ if not proj or not wdir:
+ raise RuntimeError("Container wurde nicht mit Compose angelegt β Labels fehlen")
+ args = ["docker", "compose", "--project-directory", wdir, "-p", proj,
+ "-f", os.path.join(STACK_DIR, "docker-compose.yml"), "--profile", "llm"]
+ env = os.path.join(STACK_DIR, ".env")
+ if os.path.exists(env):
+ args += ["--env-file", env]
+ return args
+
+
+def _recreate() -> None:
+ rc, out = _run(_compose_args() + ["up", "-d", "--no-deps", "--force-recreate",
+ "--pull", "never", SERVICE], 300)
+ if rc != 0:
+ raise RuntimeError(f"Container neu anlegen fehlgeschlagen: {out[-400:]}")
+ _log("Container neu angelegt")
+
+
+def _preloaded() -> list[str]:
+ try:
+ import yaml
+ cfg = yaml.safe_load(open(GEN_CONFIG)) or {}
+ return list(((cfg.get("hooks") or {}).get("on_startup") or {}).get("preload") or [])
+ except Exception:
+ return []
+
+
+def _selftest() -> tuple[bool, str]:
+ """Karten sichtbar (sonst rechnet llama.cpp still auf der CPU) und die
+ vorher geladenen Modelle wieder bereit."""
+ for _ in range(30):
+ if (_inspect(CONTAINER).get("State") or {}).get("Running"):
+ break
+ time.sleep(2)
+ else:
+ return False, "Container laeuft nicht"
+ rc, out = _run(["docker", "exec", CONTAINER, "llama-server", "--list-devices"], 60)
+ cards = [l.strip() for l in out.splitlines() if re.match(r"\s*CUDA\d+", l)]
+ if not cards:
+ return False, f"llama.cpp sieht keine Grafikkarte (wuerde auf der CPU rechnen): {out[-300:]}"
+ _log(f"Karten sichtbar: {len(cards)} ({'; '.join(c[:60] for c in cards)})")
+ want = _preloaded()
+ if not want:
+ return True, f"{len(cards)} Karte(n) sichtbar, kein Modell vorgeladen"
+ _log(f"warte, bis {', '.join(want)} geladen ist β¦")
+ deadline = time.time() + READY_TIMEOUT_S
+ last = ""
+ while time.time() < deadline:
+ try:
+ run = json.load(_http(f"{LLAMA_URL}/running", timeout=10)).get("running") or []
+ states = {r.get("model"): r.get("state") for r in run}
+ last = ", ".join(f"{m}={states.get(m, '-')}" for m in want)
+ if all(states.get(m) == "ready" for m in want):
+ return True, f"{len(cards)} Karte(n), Modell(e) bereit: {', '.join(want)}"
+ except Exception as e:
+ last = str(e)[:80]
+ time.sleep(5)
+ return False, f"Modell nach {READY_TIMEOUT_S // 60} min nicht bereit ({last})"
+
+
+def _drop_untagged(image_id: str) -> None:
+ """Ein Image, das nach dem Zuruecksetzen keinen Namen mehr hat, belegt nur
+ noch Platz (das llama-swap-Image hat ~14 GB)."""
+ if image_id and not (_inspect(image_id, "image").get("RepoTags") or []):
+ rc, _ = _run(["docker", "rmi", image_id])
+ if rc == 0:
+ _log("verworfene Version geloescht")
+
+
+def _prune_backups() -> None:
+ for b in _backups()[KEEP_BACKUPS:]:
+ _run(["docker", "rmi", b["tag"]])
+ _log(f"alte Sicherung entfernt: {b['tag']}")
+
+
+# ββ Jobs ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def _do_update() -> dict:
+ ref = _image_ref()
+ before = _status(remote=False)
+ old_id = _inspect(ref, "image").get("Id", "")
+ backup = f"{BACKUP_REPO}:vor-update-{time.strftime('%Y%m%d-%H%M%S')}"
+ rc, out = _run(["docker", "tag", old_id, backup])
+ if rc != 0:
+ raise RuntimeError(f"Sicherung fehlgeschlagen: {out}")
+ _log(f"altes Image gesichert als {backup}")
+ _log(f"ziehe {ref} β¦ (mehrere GB, dauert)")
+ rc, out = _run(["docker", "pull", ref], 3600)
+ if rc != 0:
+ raise RuntimeError(f"Ziehen fehlgeschlagen: {out[-400:]}")
+ new_id = _inspect(ref, "image").get("Id", "")
+ if new_id == old_id:
+ _run(["docker", "rmi", backup])
+ return {"ok": True, "changed": False, "message": "Schon aktuell β nichts geaendert."}
+ _recreate()
+ ok, why = _selftest()
+ if ok:
+ _prune_backups()
+ after = _status(remote=False)
+ return {"ok": True, "changed": True, "before": before.get("installed"),
+ "after": after.get("installed"), "selftest": why, "backup": backup}
+ _log(f"Selbsttest fehlgeschlagen: {why} β setze zurueck")
+ _run(["docker", "tag", backup, ref])
+ _recreate()
+ _drop_untagged(new_id)
+ ok2, why2 = _selftest()
+ return {"ok": False, "rolledBack": True, "error": f"Selbsttest fehlgeschlagen: {why}",
+ "rollbackSelftest": why2 if ok2 else f"AUCH die alte Version laeuft nicht: {why2}"}
+
+
+def _do_rollback() -> dict:
+ bks = _backups()
+ if not bks:
+ raise RuntimeError("Keine gesicherte Version vorhanden")
+ ref = _image_ref()
+ cur_id = _inspect(ref, "image").get("Id", "")
+ b = bks[0]
+ rc, out = _run(["docker", "tag", b["tag"], ref])
+ if rc != 0:
+ raise RuntimeError(f"Zuruecksetzen fehlgeschlagen: {out}")
+ _log(f"{b['tag']} wieder als {ref} gesetzt")
+ _recreate()
+ _drop_untagged(cur_id)
+ ok, why = _selftest()
+ return {"ok": ok, "selftest": why, "restored": b["tag"],
+ "after": _status(remote=False).get("installed"),
+ **({} if ok else {"error": f"Selbsttest fehlgeschlagen: {why}"})}
+
+
+def _start_job(op: str) -> dict:
+ with _job_lock:
+ if _job["state"] == "running":
+ return {"ok": False, "error": f"Es laeuft schon: {_job['op']}", "job": _job["id"]}
+ _job.update(id=f"{op}-{int(time.time())}", op=op, state="running", log=[], result=None)
+ jid = _job["id"]
+
+ def work():
+ try:
+ res = _do_update() if op == "update" else _do_rollback()
+ except Exception as e:
+ logger.exception("%s fehlgeschlagen", op)
+ res = {"ok": False, "error": str(e)[:500]}
+ with _job_lock:
+ _job["result"] = res
+ _job["state"] = "done"
+
+ threading.Thread(target=work, daemon=True).start()
+ return {"ok": True, "job": jid}
+
+
+# ββ HTTP ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+def _token() -> str:
+ try:
+ return open(TOKEN_FILE).read().strip()
+ except OSError:
+ return ""
+
+
+class Handler(http.server.BaseHTTPRequestHandler):
+ def log_message(self, fmt, *a):
+ pass
+
+ def _reply(self, code: int, body: dict) -> None:
+ data = json.dumps(body).encode()
+ self.send_response(code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(data)))
+ self.end_headers()
+ self.wfile.write(data)
+
+ def _authorized(self) -> bool:
+ tok = _token()
+ if not tok or self.headers.get("X-Updater-Token", "") != tok:
+ self._reply(403, {"ok": False, "error": "Token fehlt oder falsch"})
+ return False
+ return True
+
+ def do_GET(self):
+ if not self._authorized():
+ return
+ try:
+ if self.path == "/status":
+ self._reply(200, _status(remote=False))
+ elif self.path == "/check":
+ self._reply(200, _status(remote=True))
+ elif self.path == "/job":
+ with _job_lock:
+ self._reply(200, dict(_job))
+ else:
+ self._reply(404, {"ok": False, "error": "unbekannt"})
+ except Exception as e:
+ self._reply(500, {"ok": False, "error": str(e)[:300]})
+
+ def do_POST(self):
+ if not self._authorized():
+ return
+ if self.path in ("/update", "/rollback"):
+ self._reply(200, _start_job(self.path[1:]))
+ else:
+ self._reply(404, {"ok": False, "error": "unbekannt"})
+
+
+if __name__ == "__main__":
+ logger.info("LLM-Updater auf :%d fuer Container %s", PORT, CONTAINER)
+ http.server.ThreadingHTTPServer(("0.0.0.0", PORT), Handler).serve_forever()