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:
Dieter Schlüter 2026-06-20 18:39:59 +02:00
commit 5b6f0f8ef2
7 changed files with 219 additions and 6 deletions

View file

@ -1755,10 +1755,19 @@ Kategorie, Textausschnitt).
#### Status #### Status
Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider. Zeigt aktives Profil, Provider-Konfiguration, Laufzeit-Metriken und verfügbare Provider.
Zusätzlich eine **LLM-Backend-Karte** (read-only): aktives Backend (Ollama/llama.cpp), Zusätzlich eine **LLM-Backend-Karte**: aktives Backend (Ollama/llama.cpp), Modell, ob
Modell, ob die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je die Backends laufen, geladene Ollama-Modelle und die **GPU-Auslastung** je Karte als
Karte als Balken. Am Ende: **⬇ voice-assistant.db herunterladen** — lädt die Balken. Darunter eine **Steuerung**: Backend wählen (+ Ollama-Modell), **„Backend
SQLite-Datenbank als Backup. 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 #### Metriken

View file

@ -8,10 +8,18 @@ from __future__ import annotations
import asyncio import asyncio
import os import os
import re
import shutil import shutil
import subprocess
from pathlib import Path
from app.config import settings 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: 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.""" """Fuehrt ein Kommando aus (shell=False) und liefert stdout, oder None bei Fehler."""
@ -113,3 +121,69 @@ async def llm_status() -> dict:
"gpus": gpus, "gpus": gpus,
"gateway_service_active": gw, "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"}

View file

@ -6,7 +6,12 @@ from fastapi import APIRouter, Depends, HTTPException, Request, WebSocket, WebSo
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel 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.auth import is_admin_user, require_admin, require_admin_or_user
from app.config import settings from app.config import settings
from app.dependencies import get_store from app.dependencies import get_store
@ -196,6 +201,27 @@ async def get_llm_status():
return await 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 ───────────────────────────────────────────────── # ── Aussprache-Lexikon CRUD ─────────────────────────────────────────────────
def _read_pronunciation(lang: str) -> dict: def _read_pronunciation(lang: str) -> dict:

View file

@ -995,9 +995,66 @@ function renderLlmCard(llm) {
<div class="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-700"> <div class="space-y-2 pt-2 border-t border-slate-100 dark:border-slate-700">
${gpuBars} ${gpuBars}
</div> </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>`; </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() { async function loadStatus() {
const container = $("#status-content"); const container = $("#status-content");
container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>'; container.innerHTML = '<p class="text-sm text-slate-400">lade …</p>';
@ -1074,6 +1131,7 @@ async function loadStatus() {
</a> </a>
</div> </div>
`; `;
wireLlmControls(); // Buttons der LLM-Karte verdrahten (nach dem Rendern)
} catch (e) { } catch (e) {
console.error("loadStatus error:", e); console.error("loadStatus error:", e);
container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>'; container.innerHTML = '<p class="text-sm text-red-500">Fehler beim Laden.</p>';

View file

@ -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> <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> </footer>
<script src="/app.js?v=29"></script> <script src="/app.js?v=30"></script>
</body> </body>
</html> </html>

View file

@ -17,6 +17,16 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)" ROOT_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
ENV_FILE="$ROOT_DIR/.env" 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_BASE_URL="${OLLAMA_BASE_URL:-http://127.0.0.1:11434/v1}"
OLLAMA_MODEL="${OLLAMA_MODEL:-gemma3:latest}" OLLAMA_MODEL="${OLLAMA_MODEL:-gemma3:latest}"
LLAMACPP_BASE_URL="${LLAMACPP_BASE_URL:-http://127.0.0.1:8001/v1}" LLAMACPP_BASE_URL="${LLAMACPP_BASE_URL:-http://127.0.0.1:8001/v1}"

View file

@ -39,3 +39,39 @@ def test_status_schema():
assert isinstance(data["gpus"], list) assert isinstance(data["gpus"], list)
# backend wird aus der base_url abgeleitet. # backend wird aus der base_url abgeleitet.
assert data["backend"] in ("ollama", "llamacpp", "unknown") 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