38f0394166
- Rollen ueberall = fast/heavy/coder/vision/scout (eine Quelle der Wahrheit): sources.py CATEGORIES (agent/reasoning raus, fast/heavy rein), llamaswap.ROLE_IDS, maintenance ROLE_MAP entfernt (Discover-Rollen == Serving-Rollen), Discover.tsx ROLE_METADATA, ModelBadges.ROLES, ActiveModelsCard (stale reasoning-Farbe raus). Behebt: Discover zeigte "Reasoning"/"agent"; aus Discover installierte Modelle landeten in keinem Cockpit-Slot. - gguf_meta: Vocab-Check jetzt ECHT - sha256 ueber die vollstaendige Token-Liste statt nur Metadaten. Familienunabhaengig (Qwen/Llama/Mistral/...). Verifiziert: Coder + Qwen3-0.6B byte-identisch (kompatibel), Qwen3.6 abweichend (inkompatibel), 0.11s/Scan. - RolesCard SPEC-Badge -> spec_active (Konsistenz mit Cockpit). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
311 lines
12 KiB
Python
311 lines
12 KiB
Python
"""
|
|
Wartung: Updates (OS/Engine/Modelle), Dienst-Neustart (system- vs user-aware),
|
|
Reboot, Logs. Portiert/modernisiert aus Mission Control v1 (routers/maintenance.py).
|
|
|
|
Passwortfrei über NOPASSWD-Whitelist (sudo -n). OS-Update/Reboot brauchen einmalig
|
|
erweiterte sudoers (siehe docs/BEDIENUNG.md). Lange Ops laufen als jobengine-Job.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import time
|
|
from datetime import datetime
|
|
|
|
import httpx
|
|
import psutil
|
|
|
|
from config import ANYTHINGLLM_REPO, ANYTHINGLLM_URL, HERMES_AGENT_REPO
|
|
from services import discover, jobengine, llamaswap, system
|
|
|
|
# System-Dienste (root, via sudo -n NOPASSWD) vs. User-Dienste (systemctl --user).
|
|
SYSTEM_SERVICES = {"llama-swap"}
|
|
USER_SERVICES = {"mission-control-2", "hermes-gateway", "hermes-dashboard", "hermes-webui"}
|
|
|
|
# Engine-Update: lädt den neuesten Vulkan-Build (deploy/update-engine.sh, läuft als root).
|
|
_REPO_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
ENGINE_UPDATE_CMD = os.environ.get(
|
|
"MC_ENGINE_UPDATE_CMD", f"sudo bash {_REPO_ROOT}/deploy/update-engine.sh")
|
|
# Engine = offizieller Vulkan-Build von ggml-org/llama.cpp (RADV auf Strix Halo).
|
|
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp-vulkan")
|
|
ENGINE_REPO = os.environ.get("MC_ENGINE_REPO", "ggml-org/llama.cpp")
|
|
_engine_cache = {"ts": 0.0, "avail": False}
|
|
|
|
|
|
def _installed_engine_build() -> int | None:
|
|
"""Build-Nummer der installierten llama-server-Binary (z.B. 9821), oder None.
|
|
Vulkan-Build braucht LD_LIBRARY_PATH=ENGINE_PATH zum Start von --version."""
|
|
bin_path = os.path.join(ENGINE_PATH, "llama-server")
|
|
if not os.path.exists(bin_path):
|
|
return None
|
|
try:
|
|
env = dict(os.environ, LD_LIBRARY_PATH=ENGINE_PATH)
|
|
out = subprocess.run([bin_path, "--version"], capture_output=True, text=True,
|
|
timeout=20, env=env)
|
|
txt = (out.stderr or "") + (out.stdout or "")
|
|
if (m := re.search(r"build:\s*\S+\s*\((\d+)\)", txt)) or (m := re.search(r"\bb(\d{3,})\b", txt)):
|
|
return int(m.group(1))
|
|
except Exception:
|
|
return None
|
|
return None
|
|
|
|
|
|
def _ram_gb() -> float:
|
|
return psutil.virtual_memory().total / (1024 ** 3)
|
|
|
|
|
|
def _os_upgradable() -> int:
|
|
try:
|
|
out = subprocess.run(
|
|
["bash", "-c", "apt list --upgradable 2>/dev/null | grep -c upgradable || true"],
|
|
capture_output=True, text=True, timeout=10)
|
|
return int((out.stdout or "0").strip() or 0)
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _engine_update_available() -> bool:
|
|
now = time.time()
|
|
if now - _engine_cache["ts"] < 3600:
|
|
return _engine_cache["avail"]
|
|
avail = False
|
|
try:
|
|
rel = httpx.get(f"https://api.github.com/repos/{ENGINE_REPO}/releases/latest",
|
|
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
|
tag = str(rel.get("tag_name", ""))
|
|
latest = int(m.group(1)) if (m := re.search(r"(\d{3,})", tag)) else None
|
|
installed = _installed_engine_build()
|
|
if latest is not None and installed is not None:
|
|
avail = latest > installed # präziser Build-Nummer-Vergleich
|
|
else: # Fallback: Release-Datum vs. Engine-mtime
|
|
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
|
|
avail = pub > os.path.getmtime(ENGINE_PATH) + 86400
|
|
except Exception:
|
|
avail = False
|
|
_engine_cache.update(ts=now, avail=avail)
|
|
return avail
|
|
|
|
|
|
_comp_cache = {"ts": 0.0, "data": []}
|
|
|
|
|
|
def _gh_latest(repo: str) -> dict | None:
|
|
"""Neuestes GitHub-Release {tag, published} oder None."""
|
|
try:
|
|
rel = httpx.get(f"https://api.github.com/repos/{repo}/releases/latest",
|
|
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
|
|
return {"tag": str(rel.get("tag_name", "")), "published": rel.get("published_at")}
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _commit_ts(path: str) -> int | None:
|
|
try:
|
|
p = subprocess.run(["git", "-C", os.path.expanduser(path), "log", "-1", "--format=%ct"],
|
|
capture_output=True, text=True, timeout=8)
|
|
return int(p.stdout.strip()) if p.returncode == 0 and p.stdout.strip() else None
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def _hermes_agent_update() -> dict:
|
|
"""Hermes-Agent: neuestes Release vs. installiertes git-Commit-Datum."""
|
|
info = {"key": "hermes_agent", "name": "Hermes Agent", "current": None,
|
|
"latest": None, "update": False, "reachable": None}
|
|
git = system.find_hermes_agent_git()
|
|
if git:
|
|
info["current"] = git.get("hash")
|
|
gh = _gh_latest(HERMES_AGENT_REPO)
|
|
if gh:
|
|
info["latest"] = gh["tag"]
|
|
ts = _commit_ts(git["path"]) if (git and git.get("path")) else None
|
|
if gh["published"] and ts:
|
|
try:
|
|
pub = datetime.fromisoformat(gh["published"].replace("Z", "+00:00")).timestamp()
|
|
info["update"] = pub > ts + 86400 # Release > installiertes Commit (+1 Tag Toleranz)
|
|
except Exception:
|
|
pass
|
|
return info
|
|
|
|
|
|
def _anythingllm_update() -> dict:
|
|
"""AnythingLLM: erreichbar? + neueste verfügbare Version. Installierte Version ist
|
|
remote nicht unauth. abfragbar → update=None (nur Info: neueste verfügbar)."""
|
|
reachable = False
|
|
try:
|
|
reachable = httpx.get(f"{ANYTHINGLLM_URL}/api/ping", timeout=4).status_code == 200
|
|
except Exception:
|
|
reachable = False
|
|
info = {"key": "anythingllm", "name": "AnythingLLM", "current": None,
|
|
"latest": None, "update": None, "reachable": reachable}
|
|
gh = _gh_latest(ANYTHINGLLM_REPO)
|
|
if gh:
|
|
info["latest"] = gh["tag"]
|
|
return info
|
|
|
|
|
|
def _components_cached() -> list[dict]:
|
|
"""Update-Status von Hermes-Agent + AnythingLLM (1h-Cache → GitHub schonen)."""
|
|
now = time.time()
|
|
if now - _comp_cache["ts"] < 3600 and _comp_cache["data"]:
|
|
return _comp_cache["data"]
|
|
data = [_hermes_agent_update(), _anythingllm_update()]
|
|
_comp_cache.update(ts=now, data=data)
|
|
return data
|
|
|
|
|
|
def model_upgrades() -> list[dict]:
|
|
"""Dynamisch: je discover-Kategorie das empfohlene Modell, das NOCH NICHT installiert ist,
|
|
aber NUR wenn für diese Rolle bereits irgendein Modell konfiguriert ist.
|
|
→ Upgrade-Vorschlag für diese Rolle. Self-updating (kein Hardcode wie v1)."""
|
|
disc = discover.safe_discover(_ram_gb())
|
|
if not disc:
|
|
return []
|
|
|
|
installed = llamaswap.list_models()
|
|
active_roles = {m["role"] for m in installed if m.get("role")}
|
|
|
|
cmds = " ".join(str(s.get("cmd", "")).lower()
|
|
for s in (llamaswap.read_config().get("models") or {}).values())
|
|
out = []
|
|
|
|
# Discover-Rollen == Serving-Rollen (fast/heavy/coder/vision/scout) → kein Mapping mehr.
|
|
for c in disc.get("categories", []):
|
|
role = c["role"]
|
|
if role not in active_roles: # nur Rollen, die bereits ein Modell haben
|
|
continue
|
|
|
|
rec = c.get("recommended")
|
|
if not rec:
|
|
continue
|
|
base = rec.split("/")[-1].lower()
|
|
stem = base[:-5] if base.endswith("-gguf") else base
|
|
if base in cmds or (stem and stem in cmds):
|
|
continue
|
|
out.append({"role": role, "title": c["title"], "repo": rec})
|
|
return out
|
|
|
|
|
|
def _last_apt_update() -> float | None:
|
|
for path in ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin"]:
|
|
if os.path.exists(path):
|
|
try:
|
|
return os.path.getmtime(path)
|
|
except Exception:
|
|
pass
|
|
return None
|
|
|
|
|
|
def updates() -> dict:
|
|
ups = model_upgrades()
|
|
return {"os": _os_upgradable(), "engine": 1 if _engine_update_available() else 0,
|
|
"models": len(ups), "model_list": ups, "last_check": _last_apt_update(),
|
|
"components": _components_cached()}
|
|
|
|
|
|
def _run(cmd: list[str], sudo_password: str | None = None) -> dict:
|
|
actual_cmd = list(cmd)
|
|
has_sudo = False
|
|
|
|
if cmd and cmd[0] == "sudo":
|
|
has_sudo = True
|
|
# If we have a password, use -S instead of -n
|
|
if sudo_password is not None:
|
|
if "-n" in actual_cmd:
|
|
actual_cmd = [x for x in actual_cmd if x != "-n"]
|
|
if "-S" not in actual_cmd:
|
|
actual_cmd.insert(1, "-S")
|
|
else:
|
|
# Force -n to fail cleanly if password is required
|
|
if "-S" in actual_cmd:
|
|
actual_cmd = [x for x in actual_cmd if x != "-S"]
|
|
if "-n" not in actual_cmd:
|
|
actual_cmd.insert(1, "-n")
|
|
|
|
try:
|
|
input_data = (sudo_password + "\n") if (has_sudo and sudo_password is not None) else None
|
|
p = subprocess.run(actual_cmd, input=input_data, capture_output=True, text=True, timeout=120)
|
|
|
|
err_msg = p.stderr or ""
|
|
if p.returncode != 0 and ("a password is required" in err_msg or "password" in err_msg.lower() or "sudo:" in err_msg):
|
|
if sudo_password is not None:
|
|
return {"ok": False, "status": "incorrect_password", "out": p.stdout or "", "err": "Falsches Sudo-Passwort."}
|
|
return {"ok": False, "status": "password_required", "out": p.stdout or "", "err": "Sudo-Passwort erforderlich."}
|
|
|
|
return {"ok": p.returncode == 0, "out": (p.stdout or "")[-4000:], "err": (p.stderr or "")[-2000:]}
|
|
except Exception as exc: # noqa: BLE001
|
|
return {"ok": False, "out": "", "err": str(exc)}
|
|
|
|
|
|
def check_sudo_needs_password(sudo_password: str | None = None) -> dict | None:
|
|
"""Checks if sudo needs a password. Returns error dict if password required/incorrect, else None."""
|
|
res = _run(["sudo", "true"], sudo_password=sudo_password)
|
|
if not res["ok"]:
|
|
return res
|
|
return None
|
|
|
|
|
|
def restart_service(name: str, sudo_password: str | None = None) -> dict:
|
|
if name in SYSTEM_SERVICES:
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
return _run(["sudo", "systemctl", "restart", name], sudo_password=sudo_password)
|
|
if name in USER_SERVICES:
|
|
return _run(["systemctl", "--user", "restart", name])
|
|
return {"ok": False, "err": f"Dienst '{name}' nicht erlaubt."}
|
|
|
|
|
|
def logs(service: str, lines: int = 200, sudo_password: str | None = None) -> dict:
|
|
lines = max(1, min(lines, 1000))
|
|
if service in USER_SERVICES:
|
|
r = _run(["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"])
|
|
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
|
if service in SYSTEM_SERVICES:
|
|
# Journal-Lesen braucht i.d.R. KEIN sudo (User ist in Gruppe adm/systemd-journal).
|
|
# Erst ohne sudo versuchen; nur bei fehlenden Rechten auf sudo zurückfallen.
|
|
r = _run(["journalctl", "-u", service, "-n", str(lines), "--no-pager"])
|
|
if r["ok"]:
|
|
return {"ok": True, "text": r["out"] or "(keine Log-Einträge)"}
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
r = _run(["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"],
|
|
sudo_password=sudo_password)
|
|
return {"ok": r["ok"], "text": r["out"] or r["err"]}
|
|
return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."}
|
|
|
|
def check_updates_job(sudo_password: str | None = None) -> dict:
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
|
|
def on_done():
|
|
_engine_cache.update(ts=0.0, avail=False)
|
|
|
|
cmd = "sudo apt-get update"
|
|
job_id = jobengine.start_job(["bash", "-c", cmd], "Nach Updates suchen", on_done=on_done, sudo_password=sudo_password)
|
|
return {"ok": True, "job_id": job_id}
|
|
|
|
|
|
def os_update_job(sudo_password: str | None = None) -> dict:
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
cmd = "sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y"
|
|
job_id = jobengine.start_job(["bash", "-c", cmd], "OS-Update (apt)", sudo_password=sudo_password)
|
|
return {"ok": True, "job_id": job_id}
|
|
|
|
|
|
def engine_update_job(sudo_password: str | None = None) -> dict | None:
|
|
if not ENGINE_UPDATE_CMD:
|
|
return None
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
# update-engine.sh läuft via sudo als root und startet llama-swap am Ende selbst neu.
|
|
job_id = jobengine.start_job(["bash", "-c", ENGINE_UPDATE_CMD],
|
|
"Engine-Update (llama.cpp Vulkan)", sudo_password=sudo_password)
|
|
return {"ok": True, "job_id": job_id}
|
|
|
|
|
|
def reboot(sudo_password: str | None = None) -> dict:
|
|
if err := check_sudo_needs_password(sudo_password):
|
|
return err
|
|
return _run(["sudo", "reboot"], sudo_password=sudo_password)
|