feat(llm): llama.cpp/llama-swap aus der Diagnostic aktualisieren
Modell-Katalog → ⚙ llama.cpp zeigt je LLM-Box die installierte Version, sucht nach Updates (Pruefsumme des Image-Tags in der Registry + letztes llama-swap-Release) und aktualisiert auf Knopfdruck: altes Image sichern, neues ziehen, Container per Compose neu anlegen, Selbsttest (llama.cpp sieht die Karten, vorgeladene Modelle wieder bereit) — sonst automatisch zurueck. Zuruecksetzen auf die Vorversion per Knopf. Docker-Zugriff hat nur der neue llm-updater (feste Aktionen fuer aria-llama-swap, kein Port nach aussen, Token in /models). Der llm-adapter reicht llm_runtime weiter und meldet den Fortschritt. RVS-Allow-List um llm_runtime/_result/_progress ergaenzt. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -952,6 +952,7 @@
|
|||||||
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
|
<button class="btn secondary" onclick="refreshLlmCatalog()" style="padding:4px 10px;font-size:11px;">Von HuggingFace aktualisieren</button>
|
||||||
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
|
<button class="btn secondary" id="llm-dl-btn" onclick="openLlmDownloads()" title="Fortschritt der Modell-Downloads auf die LLM-Boxen" style="padding:4px 10px;font-size:11px;">⬇ Downloads <span id="llm-dl-badge" style="display:none;margin-left:4px;padding:0 6px;border-radius:8px;background:#FFD60A;color:#000;font-weight:700;"></span></button>
|
||||||
<button class="btn secondary" onclick="openModelBoards()" title="Ranglisten der besten Modelle + was gerade auf HuggingFace im Trend ist (direkt ladbar)" style="padding:4px 10px;font-size:11px;">🏆 Bestenlisten</button>
|
<button class="btn secondary" onclick="openModelBoards()" title="Ranglisten der besten Modelle + was gerade auf HuggingFace im Trend ist (direkt ladbar)" style="padding:4px 10px;font-size:11px;">🏆 Bestenlisten</button>
|
||||||
|
<button class="btn secondary" onclick="openLlmRuntime()" title="Installierte llama.cpp-/llama-swap-Version der LLM-Boxen, nach Updates suchen, aktualisieren" style="padding:4px 10px;font-size:11px;">⚙ llama.cpp</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="card" style="max-width:1100px;">
|
<div class="card" style="max-width:1100px;">
|
||||||
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
|
<p style="color:#8888AA;font-size:12px;margin:0 0 8px;">
|
||||||
@@ -2691,6 +2692,8 @@
|
|||||||
if (msg.type === 'rooms_info') { rooms = (msg.payload && msg.payload.rooms) || []; renderRooms(); return; }
|
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 === 'sat_result') { onSatResult(msg.payload || {}); return; }
|
||||||
if (msg.type === 'llm_openai_config_result') { onOpenAiConfigResult(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 (msg.type === 'agent_history_list') {
|
||||||
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
|
if (agentHistoryHost && msg.payload && msg.payload.host === agentHistoryHost) {
|
||||||
renderAgentHistory(msg.payload.events || []);
|
renderAgentHistory(msg.payload.events || []);
|
||||||
@@ -9972,6 +9975,152 @@
|
|||||||
function closeLlmDownloads() {
|
function closeLlmDownloads() {
|
||||||
document.getElementById('llm-dl-modal').classList.remove('open');
|
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 = '<div style="background:#12121E;border:1px solid #444466;border-radius:8px;padding:16px 18px;max-width:640px;width:100%;max-height:85vh;overflow-y:auto;color:#E0E0F0;font-size:13px;line-height:1.5;">' +
|
||||||
|
'<div style="display:flex;align-items:center;margin-bottom:10px;"><b style="font-size:15px;">⚙ llama.cpp auf den LLM-Boxen</b>' +
|
||||||
|
'<button class="btn secondary" onclick="closeLlmRuntime()" style="margin-left:auto;padding:2px 10px;font-size:11px;">Schließen</button></div>' +
|
||||||
|
'<div id="llmrt-body"></div></div>';
|
||||||
|
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 = '<span style="color:#6a6a88;">Keine LLM-Box online.</span>'; 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 = '<div style="background:#0D0D1A;border-radius:6px;padding:10px 12px;margin-bottom:10px;">' +
|
||||||
|
'<div style="font-weight:600;color:#AAB;margin-bottom:6px;">🖥️ ' + escapeHtml(b.node || id) + '</div>';
|
||||||
|
if (st.error) h += '<div style="color:#FF6E6E;margin-bottom:6px;">✗ ' + escapeHtml(st.error) + '</div>';
|
||||||
|
if (!s.image && !st.error) h += '<div style="color:#8888AA;">frage Version ab …</div>';
|
||||||
|
if (s.image) {
|
||||||
|
h += '<div style="font-size:12px;"><b>Installiert:</b> llama-swap ' + escapeHtml(inst.llamaSwap || '?') +
|
||||||
|
' · llama.cpp ' + escapeHtml(inst.llamaCpp || '?') + '</div>' +
|
||||||
|
'<div style="font-size:11px;color:#8888AA;">Image gebaut ' + escapeHtml(_rtDate(inst.created)) +
|
||||||
|
' · <code>' + escapeHtml(s.image) + '</code></div>';
|
||||||
|
if (st.checked) {
|
||||||
|
const rel = s.release || {};
|
||||||
|
const relTxt = rel.tag ? 'llama-swap <b>' + escapeHtml(rel.tag) + '</b> vom ' + escapeHtml(_rtDate(rel.published)) +
|
||||||
|
(rel.url ? ' · <a href="' + escapeHtml(rel.url) + '" target="_blank" style="color:#0A84FF;">Änderungen</a>' : '') : '';
|
||||||
|
if (s.latestError) h += '<div style="color:#FF9500;margin-top:6px;">⚠ ' + escapeHtml(s.latestError) + '</div>';
|
||||||
|
else if (s.updateAvailable) h += '<div style="color:#FFD60A;margin-top:6px;">⬆ Update verfügbar' + (relTxt ? ': ' + relTxt : '') + '</div>';
|
||||||
|
else h += '<div style="color:#34C759;margin-top:6px;">✓ Aktuell' + (relTxt ? ' (neuestes Release: ' + relTxt + ')' : '') + '</div>';
|
||||||
|
}
|
||||||
|
const bk = (s.backups || [])[0];
|
||||||
|
if (bk) h += '<div style="font-size:11px;color:#8888AA;margin-top:4px;">Gesicherte Vorversion: <code>' + escapeHtml(bk.tag) + '</code></div>';
|
||||||
|
if (!st.busy) {
|
||||||
|
h += '<div style="display:flex;gap:6px;flex-wrap:wrap;margin-top:8px;">' +
|
||||||
|
'<button class="btn secondary" onclick="llmRtCheck(' + q + ')" style="padding:3px 10px;font-size:11px;"' + (st.checking ? ' disabled' : '') + '>' +
|
||||||
|
(st.checking ? 'suche …' : 'Nach Updates suchen') + '</button>' +
|
||||||
|
(st.checked && s.updateAvailable ? '<button class="btn" onclick="llmRtUpdate(' + q + ')" style="padding:3px 10px;font-size:11px;">Jetzt aktualisieren</button>' : '') +
|
||||||
|
(bk ? '<button class="btn secondary" onclick="llmRtRollback(' + q + ')" style="padding:3px 10px;font-size:11px;">Zurück auf Vorversion</button>' : '') +
|
||||||
|
'</div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (st.busy) h += '<div style="color:#FFD60A;margin-top:8px;">⏳ ' + (st.busy === 'update' ? 'Aktualisiere' : 'Setze zurück') +
|
||||||
|
' … das dauert ein paar Minuten (Download + Modell neu laden).</div>';
|
||||||
|
if ((st.log || []).length && (st.busy || st.result)) {
|
||||||
|
h += '<pre style="background:#08080F;color:#9a9ab5;font-size:10.5px;padding:6px 8px;border-radius:4px;max-height:160px;overflow-y:auto;white-space:pre-wrap;margin:6px 0 0;">' +
|
||||||
|
escapeHtml(st.log.join('\n')) + '</pre>';
|
||||||
|
}
|
||||||
|
const r = st.result;
|
||||||
|
if (r && !st.busy) {
|
||||||
|
if (r.ok && r.changed === false) h += '<div style="color:#34C759;margin-top:6px;">✓ ' + escapeHtml(r.message || 'nichts geändert') + '</div>';
|
||||||
|
else if (r.ok) h += '<div style="color:#34C759;margin-top:6px;">✓ Fertig. ' + escapeHtml(r.selftest || '') +
|
||||||
|
(r.before && r.after ? '<br>llama-swap ' + escapeHtml(r.before.llamaSwap || '?') + ' → ' + escapeHtml(r.after.llamaSwap || '?') +
|
||||||
|
' · llama.cpp ' + escapeHtml(r.before.llamaCpp || '?') + ' → ' + escapeHtml(r.after.llamaCpp || '?') : '') + '</div>';
|
||||||
|
else h += '<div style="color:#FF6E6E;margin-top:6px;">✗ ' + escapeHtml(r.error || 'fehlgeschlagen') +
|
||||||
|
(r.rolledBack ? '<br><span style="color:#FF9500;">Automatisch zurückgesetzt: ' + escapeHtml(r.rollbackSelftest || '') + '</span>' : '') + '</div>';
|
||||||
|
}
|
||||||
|
return h + '</div>';
|
||||||
|
}).join('') +
|
||||||
|
'<div style="font-size:11px;color:#6a6a88;">Ob es etwas Neues gibt, zeigt die Prüfsumme des Images in der Registry — der Tag <code>unified-cuda</code> wandert mit. ' +
|
||||||
|
'Das Image enthält llama-swap und llama.cpp zusammen.</div>';
|
||||||
|
}
|
||||||
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
|
// "Box antwortet nicht" braucht ein Ticken, auch wenn keine Meldung kommt.
|
||||||
setInterval(() => {
|
setInterval(() => {
|
||||||
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
if (Object.values(_llmJobs).some(j => j.phase === 'queued')) renderLlmDownloads();
|
||||||
|
|||||||
@@ -1672,6 +1672,8 @@ function connectRVS(forcePlain) {
|
|||||||
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
broadcast({ type: "rooms_info", payload: msg.payload || {} });
|
||||||
} else if (msg.type === "llm_openai_config_result") {
|
} else if (msg.type === "llm_openai_config_result") {
|
||||||
broadcast({ type: "llm_openai_config_result", payload: msg.payload || {} });
|
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") {
|
} else if (msg.type === "sat_result") {
|
||||||
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
// Ergebnis einer Satelliten-Aktion → an den Browser (z.B. APK-Rollout).
|
||||||
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
// Klartext-Kennwoerter (fritzbox.backup_password) nur an den Fragenden.
|
||||||
@@ -3693,6 +3695,19 @@ wss.on("connection", (ws) => {
|
|||||||
},
|
},
|
||||||
timestamp: Date.now(),
|
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") {
|
} else if (msg.action === "agent_history_query") {
|
||||||
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
// Browser will die gespeicherte Historie eines Agenten — die Bridge
|
||||||
// antwortet mit agent_history_list (auf derselben rvsWs).
|
// antwortet mit agent_history_list (auf derselben rvsWs).
|
||||||
|
|||||||
@@ -135,6 +135,9 @@ const ALLOWED_TYPES = new Set([
|
|||||||
// OpenAI-Endpunkt einer LLM-Box: Diagnostic setzt den API-Key (der PORT kommt
|
// 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).
|
// immer aus der .env der Box — er ist ein Docker-Port-Mapping).
|
||||||
"llm_openai_config", "llm_openai_config_result",
|
"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").
|
// Modell auf eine LLM-Box laden (Diagnostic → Modell-Katalog → „Laden").
|
||||||
// Fehlten hier seit Stage D: jeder Klick auf „Laden" starb still an dieser
|
// 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 %.
|
// Liste, die Box hat nie davon erfahren. _progress meldet den Download in %.
|
||||||
|
|||||||
@@ -207,6 +207,23 @@ services:
|
|||||||
- HF_TOKEN=${HF_TOKEN:-}
|
- HF_TOKEN=${HF_TOKEN:-}
|
||||||
restart: unless-stopped
|
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) ────────────────
|
# ─── OpenHands (optional, Standalone) ────────────────
|
||||||
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
# Eigenstaendige Entwickler-Agenten-Oberflaeche, die das LOKALE LLM ueber den
|
||||||
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
# OpenAI-Endpunkt von llama-swap nutzt. Bewusst ein eigenes Profil: wer nur
|
||||||
|
|||||||
@@ -342,7 +342,7 @@ async def _reload_llama() -> None:
|
|||||||
# Fragende (Schmiede, ARIA) wartet dann bis zu seiner Frist ins Leere (am
|
# 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
|
# 28.10. 11 Minuten). Jetzt: ueber die NEUE Verbindung senden, notfalls bis zum
|
||||||
# Wiederverbinden aufheben.
|
# Wiederverbinden aufheben.
|
||||||
_RELIABLE_TYPES = {"llm_response"}
|
_RELIABLE_TYPES = {"llm_response", "llm_runtime_result"}
|
||||||
_OUTBOX_TTL_S = 300
|
_OUTBOX_TTL_S = 300
|
||||||
_outbox: list = [] # [(ts, mtype, payload)]
|
_outbox: list = [] # [(ts, mtype, payload)]
|
||||||
_current_ws = None # die aktuell offene RVS-Verbindung
|
_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:
|
async def _handle_openai_config(ws, payload: dict) -> None:
|
||||||
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
"""Setzt den API-Key des OpenAI-Endpunkts (aus der Diagnostic).
|
||||||
|
|
||||||
@@ -1886,6 +1963,9 @@ async def _run() -> None:
|
|||||||
if mtype == "llm_openai_config":
|
if mtype == "llm_openai_config":
|
||||||
asyncio.create_task(_handle_openai_config(ws, payload))
|
asyncio.create_task(_handle_openai_config(ws, payload))
|
||||||
continue
|
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",
|
if mtype not in ("llm_request", "llm_provision_model", "llm_remove_model",
|
||||||
"llm_unload_model", "llm_models_request"):
|
"llm_unload_model", "llm_models_request"):
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -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"]
|
||||||
@@ -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-<Zeit>), 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-<Zeit>
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user