diff --git a/diagnostic/index.html b/diagnostic/index.html index 3f557a5..59f9d3b 100644 --- a/diagnostic/index.html +++ b/diagnostic/index.html @@ -952,6 +952,7 @@ +

@@ -2691,6 +2692,8 @@ if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; } if (msg.type === 'sat_result') { onSatResult(msg.payload || {}); return; } if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(msg.payload || {}); return; } + if (msg.type === 'llm_runtime_result') { onLlmRuntimeResult(msg.payload || {}); return; } + if (msg.type === 'llm_runtime_progress') { onLlmRuntimeProgress(msg.payload || {}); return; } if (msg.type === 'agent_history_list') { if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) { renderAgentHistory(msg.payload.events || []); @@ -9972,6 +9975,152 @@ function closeLlmDownloads() { document.getElementById('llm-dl-modal').classList.remove('open'); } + + // ── llama.cpp/llama-swap der LLM-Boxen (Modell-Katalog β†’ βš™ llama.cpp) ── + // Pruefen/Aktualisieren/Zuruecksetzen macht auf der Box der llm-updater + // (einziger Dienst mit Docker-Zugriff). Ob es etwas Neues gibt, entscheidet + // die Pruefsumme des Image-Tags in der Registry, nicht die Versionsnummer. + const _llmRt = {}; // instanceId -> {status, checked, busy, log, result, error} + function openLlmRuntime() { + let bg = document.getElementById('llmrt-modal'); + if (!bg) { + bg = document.createElement('div'); + bg.id = 'llmrt-modal'; + bg.style.cssText = 'position:fixed;inset:0;z-index:10000;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;padding:16px;'; + bg.innerHTML = '

' + + '
βš™ llama.cpp auf den LLM-Boxen' + + '
' + + '
'; + bg.addEventListener('click', e => { if (e.target === bg) closeLlmRuntime(); }); + document.body.appendChild(bg); + } + bg.style.display = 'flex'; + for (const b of onlineLlmBoxes()) { + const st = _llmRt[b.instanceId] || (_llmRt[b.instanceId] = { log: [] }); + if (!st.busy) llmRtSend(b.instanceId, 'status'); + } + renderLlmRuntime(); + } + function closeLlmRuntime() { + const bg = document.getElementById('llmrt-modal'); + if (bg) bg.style.display = 'none'; + } + function llmRtSend(instanceId, op) { + send({ action: 'llm_runtime', instanceId, op, requestId: 'rt_' + Date.now() }); + } + function llmRtCheck(id) { + const st = _llmRt[id] || (_llmRt[id] = { log: [] }); + st.checking = true; st.error = ''; + llmRtSend(id, 'check'); + renderLlmRuntime(); + } + async function llmRtUpdate(id) { + const st = _llmRt[id] || {}; + const rel = (st.status && st.status.release) || {}; + if (!await uiConfirm('llama.cpp jetzt aktualisieren?\n\n' + + 'Die Box zieht das neue llama-swap-Image' + (rel.tag ? ' (' + rel.tag + ')' : '') + ' (mehrere GB) und legt den Container neu an. ' + + 'Die geladenen Modelle fliegen dabei kurz von den Karten und laden danach neu β€” laufende Anfragen (z.B. die Schmiede) brechen ab.\n\n' + + 'Vorher wird die alte Version gesichert. Sieht llama.cpp danach keine Grafikkarte oder lΓ€dt das Modell nicht, wird automatisch zurΓΌckgesetzt.')) return; + Object.assign(st, { busy: 'update', log: [], result: null, error: '' }); + _llmRt[id] = st; + llmRtSend(id, 'update'); + renderLlmRuntime(); + } + async function llmRtRollback(id) { + const st = _llmRt[id] || {}; + const bk = ((st.status && st.status.backups) || [])[0]; + if (!bk) return; + if (!await uiConfirm('Auf die gesicherte Version zurΓΌcksetzen?\n\n' + + 'Setzt ' + bk.tag + ' wieder ein und legt den Container neu an. Die Modelle laden danach neu, laufende Anfragen brechen ab.')) return; + Object.assign(st, { busy: 'rollback', log: [], result: null, error: '' }); + llmRtSend(id, 'rollback'); + renderLlmRuntime(); + } + function onLlmRuntimeProgress(p) { + const st = _llmRt[p.instanceId] || (_llmRt[p.instanceId] = { log: [] }); + st.busy = st.busy || p.op; + st.log = (st.log || []).concat(p.lines || []).slice(-40); + renderLlmRuntime(); + } + function onLlmRuntimeResult(p) { + const st = _llmRt[p.instanceId] || (_llmRt[p.instanceId] = { log: [] }); + if (p.op === 'status' || p.op === 'check') { + st.checking = false; + if (p.ok === false) { st.error = p.error || 'fehlgeschlagen'; } + else { + st.status = Object.assign({}, st.status || {}, p); + if (p.op === 'check') st.checked = true; + st.error = ''; + if (p.job && p.job.state === 'running' && !st.busy) st.busy = p.job.op; + } + } else { + st.busy = ''; + st.result = p; + llmRtSend(p.instanceId, 'check'); // neuen Stand anzeigen + } + renderLlmRuntime(); + } + function _rtDate(s) { + if (!s) return ''; + const d = new Date(s); + return isNaN(d) ? String(s) : d.toLocaleDateString('de-DE') + ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' }); + } + function renderLlmRuntime() { + const body = document.getElementById('llmrt-body'); + if (!body) return; + const boxes = onlineLlmBoxes(); + if (!boxes.length) { body.innerHTML = 'Keine LLM-Box online.'; return; } + body.innerHTML = boxes.map(b => { + const id = b.instanceId, st = _llmRt[id] || {}, s = st.status || {}, inst = s.installed || {}; + const q = "'" + escapeHtml(id) + "'"; + let h = '
' + + '
πŸ–₯️ ' + escapeHtml(b.node || id) + '
'; + if (st.error) h += '
βœ— ' + escapeHtml(st.error) + '
'; + if (!s.image && !st.error) h += '
frage Version ab …
'; + if (s.image) { + h += '
Installiert: llama-swap ' + escapeHtml(inst.llamaSwap || '?') + + ' Β· llama.cpp ' + escapeHtml(inst.llamaCpp || '?') + '
' + + '
Image gebaut ' + escapeHtml(_rtDate(inst.created)) + + ' Β· ' + escapeHtml(s.image) + '
'; + if (st.checked) { + const rel = s.release || {}; + const relTxt = rel.tag ? 'llama-swap ' + escapeHtml(rel.tag) + ' vom ' + escapeHtml(_rtDate(rel.published)) + + (rel.url ? ' Β· Γ„nderungen' : '') : ''; + if (s.latestError) h += '
⚠ ' + escapeHtml(s.latestError) + '
'; + else if (s.updateAvailable) h += '
⬆ Update verfΓΌgbar' + (relTxt ? ': ' + relTxt : '') + '
'; + else h += '
βœ“ Aktuell' + (relTxt ? ' (neuestes Release: ' + relTxt + ')' : '') + '
'; + } + const bk = (s.backups || [])[0]; + if (bk) h += '
Gesicherte Vorversion: ' + escapeHtml(bk.tag) + '
'; + if (!st.busy) { + h += '
' + + '' + + (st.checked && s.updateAvailable ? '' : '') + + (bk ? '' : '') + + '
'; + } + } + if (st.busy) h += '
⏳ ' + (st.busy === 'update' ? 'Aktualisiere' : 'Setze zurΓΌck') + + ' … das dauert ein paar Minuten (Download + Modell neu laden).
'; + if ((st.log || []).length && (st.busy || st.result)) { + h += '
' +
+            escapeHtml(st.log.join('\n')) + '
'; + } + const r = st.result; + if (r && !st.busy) { + if (r.ok && r.changed === false) h += '
βœ“ ' + escapeHtml(r.message || 'nichts geΓ€ndert') + '
'; + else if (r.ok) h += '
βœ“ Fertig. ' + escapeHtml(r.selftest || '') + + (r.before && r.after ? '
llama-swap ' + escapeHtml(r.before.llamaSwap || '?') + ' β†’ ' + escapeHtml(r.after.llamaSwap || '?') + + ' Β· llama.cpp ' + escapeHtml(r.before.llamaCpp || '?') + ' β†’ ' + escapeHtml(r.after.llamaCpp || '?') : '') + '
'; + else h += '
βœ— ' + escapeHtml(r.error || 'fehlgeschlagen') + + (r.rolledBack ? '
Automatisch zurΓΌckgesetzt: ' + escapeHtml(r.rollbackSelftest || '') + '' : '') + '
'; + } + return h + '
'; + }).join('') + + '
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()