feat(admin): Backend-Wechsel + Gateway-Neustart aus dem Admin-Panel — Plan-Schritt 3
- admin_llm: switch_backend() mit strikter Allowlist (backend ∈ {ollama,llamacpp},
Modell gegen 'ollama list' + Format-Regex), detached (Self-Restart-sicher),
niemals shell=True. restart_gateway_detached() für systemd-User-Dienst.
- switch-llm.sh: flock-Lock gegen parallele Backend-Wechsel (Exit 75).
- Endpunkte POST /api/admin/llm/backend (422 bei ungültig) und
POST /api/admin/gateway/restart (require_admin).
- Status-Tab: Steuerung (Backend-Dropdown + Modell, Wechseln/Neustart) mit
Poll bis das Gateway wieder antwortet; Hinweis auf systemd-Voraussetzung.
- Tests: Auth + Allowlist (Shell-Metazeichen/unbekanntes Modell -> 422). 165 grün.
- Doku §7.5.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
97ae0a5d34
commit
5b6f0f8ef2
7 changed files with 219 additions and 6 deletions
|
|
@ -1755,10 +1755,19 @@ Kategorie, Textausschnitt).
|
|||
#### Status
|
||||
|
||||
Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider.
|
||||
Zusätzlich eine **LLM-Backend-Karte** (read-only): aktives Backend (Ollama/llama.cpp),
|
||||
Modell, ob die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je
|
||||
Karte als Balken. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die
|
||||
SQLite-Datenbank als Backup.
|
||||
Zusätzlich eine **LLM-Backend-Karte**: aktives Backend (Ollama/llama.cpp), Modell, ob
|
||||
die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je Karte als
|
||||
Balken. Darunter eine **Steuerung**: Backend wählen (+ Ollama-Modell), **„Backend
|
||||
wechseln"** und **„Gateway neu starten"**.
|
||||
|
||||
> Sicherheit: Backend ist auf `ollama|llamacpp` beschränkt, Modellnamen werden gegen
|
||||
> `ollama list` und ein striktes Format geprüft (kein Shell-Zugriff, kein sudo). Der
|
||||
> Wechsel läuft losgelöst; die Seite pollt, bis das Gateway wieder antwortet.
|
||||
> **Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft** (→ § 4.10) —
|
||||
> im Vordergrund-Betrieb werden `.env`/Backend umgestellt, der Gateway muss aber manuell
|
||||
> neu gestartet werden.
|
||||
|
||||
Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die SQLite-Datenbank als Backup.
|
||||
|
||||
#### Metriken
|
||||
|
||||
|
|
|
|||
|
|
@ -8,10 +8,18 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
from app.config import settings
|
||||
|
||||
ALLOWED_BACKENDS = {"ollama", "llamacpp"}
|
||||
_SCRIPT = Path(__file__).resolve().parent.parent / "scripts" / "llm-server" / "switch-llm.sh"
|
||||
# Erlaubtes Format fuer Ollama-Modellnamen (zusaetzlich zur Pruefung gegen 'ollama list').
|
||||
_MODEL_RE = re.compile(r"^[A-Za-z0-9._:/-]{1,100}$")
|
||||
|
||||
|
||||
async def _run(cmd: list[str], timeout: float = 6.0) -> str | None:
|
||||
"""Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler."""
|
||||
|
|
@ -113,3 +121,69 @@ async def llm_status() -> dict:
|
|||
"gpus": gpus,
|
||||
"gateway_service_active": gw,
|
||||
}
|
||||
|
||||
|
||||
# ── Schreibende Steuerung (Backend-Wechsel / Gateway-Neustart) ───────────────
|
||||
|
||||
class LlmControlError(ValueError):
|
||||
"""Validierungs-/Steuerfehler -> wird vom Endpoint als 400/422 gemeldet."""
|
||||
|
||||
|
||||
async def available_ollama_models() -> list[str]:
|
||||
"""Modellnamen aus `ollama list` (erste Spalte), oder []."""
|
||||
out = await _run(["ollama", "list"])
|
||||
if out is None:
|
||||
return []
|
||||
names: list[str] = []
|
||||
for ln in out.splitlines()[1:]:
|
||||
ln = ln.strip()
|
||||
if ln:
|
||||
names.append(ln.split()[0])
|
||||
return names
|
||||
|
||||
|
||||
async def switch_backend(backend: str, model: str | None = None) -> dict:
|
||||
"""Validiert streng und startet den Backend-Wechsel als losgelösten Prozess.
|
||||
|
||||
Allowlist: backend ∈ {ollama, llamacpp}. Bei ollama muss `model` (falls gesetzt)
|
||||
exakt in `ollama list` vorkommen. Nie freie Strings an die Shell — Aufruf mit
|
||||
fester Argumentliste (shell=False); das Modell geht ausschließlich als Env-Var.
|
||||
Der Wechsel läuft detached weiter (er startet ggf. das Gateway neu).
|
||||
"""
|
||||
if backend not in ALLOWED_BACKENDS:
|
||||
raise LlmControlError(f"Unbekanntes Backend: {backend!r}")
|
||||
if not _SCRIPT.exists():
|
||||
raise LlmControlError("switch-llm.sh nicht gefunden")
|
||||
|
||||
env = {**os.environ}
|
||||
if backend == "ollama" and model:
|
||||
if not _MODEL_RE.match(model):
|
||||
raise LlmControlError("Ungültiger Modellname")
|
||||
available = await available_ollama_models()
|
||||
if available and model not in available:
|
||||
raise LlmControlError(f"Modell nicht in 'ollama list': {model!r}")
|
||||
env["OLLAMA_MODEL"] = model
|
||||
|
||||
# Detached starten: der Wechsel kann das Gateway neu starten -> wir würden uns
|
||||
# sonst selbst killen, bevor die HTTP-Antwort raus ist.
|
||||
subprocess.Popen(
|
||||
["bash", str(_SCRIPT), backend],
|
||||
env=env,
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"status": "switching", "backend": backend, "model": model}
|
||||
|
||||
|
||||
def restart_gateway_detached() -> dict:
|
||||
"""Startet das Gateway als systemd-User-Dienst neu (losgelöst, Self-Restart-sicher)."""
|
||||
xdg = os.environ.get("XDG_RUNTIME_DIR", f"/run/user/{os.getuid()}")
|
||||
subprocess.Popen(
|
||||
["bash", "-c",
|
||||
f"sleep 1; XDG_RUNTIME_DIR={xdg} systemctl --user restart voice-assistant.service"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True,
|
||||
)
|
||||
return {"status": "restarting"}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
|
|||
from fastapi.responses import FileResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.admin_llm import llm_status
|
||||
from app.admin_llm import (
|
||||
LlmControlError,
|
||||
llm_status,
|
||||
restart_gateway_detached,
|
||||
switch_backend,
|
||||
)
|
||||
from app.auth import is_admin_user, require_admin, require_admin_or_user
|
||||
from app.config import settings
|
||||
from app.dependencies import get_store
|
||||
|
|
@ -196,6 +201,27 @@ async def get_llm_status():
|
|||
return await llm_status()
|
||||
|
||||
|
||||
class BackendSwitch(BaseModel):
|
||||
backend: str
|
||||
model: str | None = None
|
||||
|
||||
|
||||
@router.post("/admin/llm/backend", dependencies=[Depends(require_admin)])
|
||||
async def post_llm_backend(payload: BackendSwitch):
|
||||
"""Wechselt das LLM-Backend (Allowlist-validiert, detached). Greift voll erst,
|
||||
wenn das Gateway als systemd-Dienst läuft; sonst muss es manuell neu starten."""
|
||||
try:
|
||||
return await switch_backend(payload.backend, payload.model)
|
||||
except LlmControlError as exc:
|
||||
raise HTTPException(status_code=422, detail=str(exc))
|
||||
|
||||
|
||||
@router.post("/admin/gateway/restart", dependencies=[Depends(require_admin)])
|
||||
async def post_gateway_restart():
|
||||
"""Startet das Gateway (systemd-User-Dienst) neu — losgelöst, Self-Restart-sicher."""
|
||||
return restart_gateway_detached()
|
||||
|
||||
|
||||
# ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
|
||||
|
||||
def _read_pronunciation(lang: str) -> dict:
|
||||
|
|
|
|||
|
|
@ -995,9 +995,66 @@ function renderLlmCard(llm) {
|
|||
<div class="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-700">
|
||||
${gpuBars}
|
||||
</div>
|
||||
|
||||
<!-- Steuerung (schreibend) -->
|
||||
<div class="mt-4 pt-3 border-t border-slate-100 dark:border-slate-700 flex flex-wrap items-center gap-2">
|
||||
<select id="llm-backend-sel" class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5">
|
||||
<option value="ollama" ${llm.backend === "ollama" ? "selected" : ""}>Ollama</option>
|
||||
<option value="llamacpp" ${llm.backend === "llamacpp" ? "selected" : ""}>llama.cpp</option>
|
||||
</select>
|
||||
<input id="llm-model-inp" type="text" placeholder="Modell (Ollama)" value="${escHtml(llm.backend === "ollama" ? llm.model : "")}"
|
||||
class="text-sm rounded-lg border border-slate-300 dark:border-slate-600 bg-transparent px-2 py-1.5 flex-1 min-w-32 font-mono" />
|
||||
<button id="llm-switch-btn" class="rounded-lg bg-blue-600 hover:bg-blue-700 text-white text-sm px-3 py-1.5 font-medium transition-colors">Backend wechseln</button>
|
||||
<button id="gw-restart-btn" class="rounded-lg border border-amber-300 dark:border-amber-700 text-amber-700 dark:text-amber-300 text-sm px-3 py-1.5 hover:bg-amber-50 dark:hover:bg-amber-900/20 transition-colors">Gateway neu starten</button>
|
||||
<span id="llm-ctrl-msg" class="text-xs text-slate-400"></span>
|
||||
</div>
|
||||
<p class="mt-2 text-xs text-slate-400">Hinweis: Wirkt vollständig nur, wenn das Gateway als systemd-Dienst läuft (sonst .env/Backend umgestellt, aber Gateway manuell neu starten).</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Wartet, bis das Gateway nach einem Neustart wieder antwortet, dann Status neu laden.
|
||||
async function pollGatewayBack(msgEl, label) {
|
||||
for (let i = 0; i < 40; i++) {
|
||||
await new Promise((r) => setTimeout(r, 1500));
|
||||
try {
|
||||
const r = await fetch("/api/config", { cache: "no-store" });
|
||||
if (r.ok) { if (msgEl) msgEl.textContent = label + " ✓"; loadStatus(); return; }
|
||||
} catch (e) { /* noch nicht oben */ }
|
||||
if (msgEl) msgEl.textContent = label + " … (" + (i + 1) + ")";
|
||||
}
|
||||
if (msgEl) msgEl.textContent = label + " — Zeitüberschreitung, bitte Status manuell prüfen.";
|
||||
}
|
||||
|
||||
function wireLlmControls() {
|
||||
const sel = $("#llm-backend-sel");
|
||||
const switchBtn = $("#llm-switch-btn");
|
||||
const restartBtn = $("#gw-restart-btn");
|
||||
const msg = $("#llm-ctrl-msg");
|
||||
if (!switchBtn) return;
|
||||
|
||||
switchBtn.addEventListener("click", async () => {
|
||||
const backend = sel.value;
|
||||
const model = ($("#llm-model-inp").value || "").trim();
|
||||
if (!confirm(`Backend auf „${backend}"${backend === "ollama" && model ? " (" + model + ")" : ""} umstellen?\nDie GPU des anderen Backends wird freigegeben.`)) return;
|
||||
switchBtn.disabled = true;
|
||||
msg.textContent = "stelle um …";
|
||||
const body = { backend };
|
||||
if (backend === "ollama" && model) body.model = model;
|
||||
const res = await adminFetch("/api/admin/llm/backend", "POST", body);
|
||||
if (res === null) { msg.textContent = "Fehler (Modell/Backend ungültig?)"; switchBtn.disabled = false; return; }
|
||||
pollGatewayBack(msg, "Backend gewechselt");
|
||||
});
|
||||
|
||||
restartBtn.addEventListener("click", async () => {
|
||||
if (!confirm("Gateway-Dienst jetzt neu starten?")) return;
|
||||
restartBtn.disabled = true;
|
||||
msg.textContent = "starte neu …";
|
||||
const res = await adminFetch("/api/admin/gateway/restart", "POST");
|
||||
if (res === null) { msg.textContent = "Fehler beim Neustart"; restartBtn.disabled = false; return; }
|
||||
pollGatewayBack(msg, "Neu gestartet");
|
||||
});
|
||||
}
|
||||
|
||||
async function loadStatus() {
|
||||
const container = $("#status-content");
|
||||
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
|
||||
|
|
@ -1074,6 +1131,7 @@ async function loadStatus() {
|
|||
</a>
|
||||
</div>
|
||||
`;
|
||||
wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern)
|
||||
} catch (e) {
|
||||
console.error("loadStatus error:", e);
|
||||
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';
|
||||
|
|
|
|||
|
|
@ -250,6 +250,6 @@
|
|||
<div id="status" class="w-full max-w-3xl mx-auto mt-1 min-h-[1rem] text-xs text-slate-500 dark:text-slate-400"></div>
|
||||
</footer>
|
||||
|
||||
<script src="/app.js?v=29"></script>
|
||||
<script src="/app.js?v=30"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|||
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
|
||||
ENV_FILE="$ROOT_DIR/.env"
|
||||
|
||||
# Lock gegen parallele Backend-Wechsel (z. B. zwei Admin-Klicks gleichzeitig).
|
||||
# flock haelt das Lock fuer die gesamte Laufzeit dieses Skripts; ein zweiter
|
||||
# Aufruf scheitert sofort mit Exit 75 (-> Endpoint meldet "läuft bereits").
|
||||
LOCK_FILE="${LOCK_FILE:-/tmp/va-llm-switch.lock}"
|
||||
exec 9>"$LOCK_FILE"
|
||||
if ! flock -n 9; then
|
||||
echo "[!] Ein Backend-Wechsel laeuft bereits (Lock: $LOCK_FILE)." >&2
|
||||
exit 75
|
||||
fi
|
||||
|
||||
OLLAMA_BASE_URL="${OLLAMA_BASE_URL:-http://127.0.0.1:11434/v1}"
|
||||
OLLAMA_MODEL="${OLLAMA_MODEL:-gemma3:latest}"
|
||||
LLAMACPP_BASE_URL="${LLAMACPP_BASE_URL:-http://127.0.0.1:8001/v1}"
|
||||
|
|
|
|||
|
|
@ -39,3 +39,39 @@ def test_status_schema():
|
|||
assert isinstance(data["gpus"], list)
|
||||
# backend wird aus der base_url abgeleitet.
|
||||
assert data["backend"] in ("ollama", "llamacpp", "unknown")
|
||||
|
||||
|
||||
# ── Schreibende Steuerung: Validierung & Auth (ohne echten Switch) ───────────
|
||||
|
||||
def test_backend_switch_requires_admin():
|
||||
assert client.post("/api/admin/llm/backend", json={"backend": "ollama"}).status_code == 401
|
||||
|
||||
|
||||
def test_backend_switch_rejects_unknown_backend():
|
||||
# Ungültiges Backend -> 422, KEIN Prozess wird gestartet.
|
||||
r = client.post("/api/admin/llm/backend", json={"backend": "boese; rm -rf /"}, headers=ADM_HDR)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_backend_switch_rejects_bad_model(monkeypatch):
|
||||
# Modell, das nicht in 'ollama list' ist -> 422 (sofern eine Liste vorhanden ist).
|
||||
import app.admin_llm as al
|
||||
|
||||
async def fake_models():
|
||||
return ["gemma3:latest", "qwen2.5:latest"]
|
||||
|
||||
monkeypatch.setattr(al, "available_ollama_models", fake_models)
|
||||
r = client.post("/api/admin/llm/backend",
|
||||
json={"backend": "ollama", "model": "gibtsnicht:99b"}, headers=ADM_HDR)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_backend_switch_rejects_malformed_model(monkeypatch):
|
||||
# Modellname mit Shell-Metazeichen -> 422 (Format-Regex), kein Subprozess.
|
||||
r = client.post("/api/admin/llm/backend",
|
||||
json={"backend": "ollama", "model": "a; rm -rf /"}, headers=ADM_HDR)
|
||||
assert r.status_code == 422
|
||||
|
||||
|
||||
def test_gateway_restart_requires_admin():
|
||||
assert client.post("/api/admin/gateway/restart").status_code == 401
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue