Files
mission-control-v2/backend/services/maintenance.py
T
Hitonabi 45bede6579 Feat: reasoning-Rolle entfernt — Modell geloescht, UI bereinigt
- Nemotron-3-Nano-Omni-30B-A3B-Reasoning von der Box entfernt (25 GB freigegeben)
- llama-swap config: Modell-Eintrag und reasoning-Alias entfernt
- sources.py: reasoning-Kategorie aus CATEGORIES entfernt
- maintenance.py: reasoning->heavy Mapping aus ROLE_MAP entfernt
- llamaswap.py: reasoning aus ROLE_IDS entfernt
- Frontend: reasoning aus allen ROLES-Arrays entfernt (ModelBadges, RolesCard, Cockpit)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-27 00:11:15 +02:00

219 lines
8.3 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 subprocess
import time
from datetime import datetime
import httpx
import psutil
from services import discover, jobengine, llamaswap
# 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_CMD = os.environ.get("MC_ENGINE_UPDATE_CMD", "")
ENGINE_PATH = os.environ.get("MC_ENGINE_PATH", "/opt/llamacpp")
_engine_cache = {"ts": 0.0, "avail": False}
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("https://api.github.com/repos/lemonade-sdk/llamacpp-rocm/releases/latest",
timeout=6, headers={"User-Agent": "MissionControl2"}).json()
pub = datetime.fromisoformat(rel["published_at"].replace("Z", "+00:00")).timestamp()
inst = os.path.getmtime(ENGINE_PATH)
avail = pub > inst + 86400
except Exception:
avail = False
_engine_cache.update(ts=now, avail=avail)
return avail
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 = []
ROLE_MAP = {
"agent": "fast",
"scout": "fast",
"coder": "coder",
"vision": "vision"
}
for c in disc.get("categories", []):
disc_role = c["role"]
mapped_role = ROLE_MAP.get(disc_role, disc_role)
if mapped_role not in active_roles:
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": mapped_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()}
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
cmd = f"{ENGINE_UPDATE_CMD} && sudo systemctl restart llama-swap"
job_id = jobengine.start_job(["bash", "-c", cmd], "Engine-Update (llama.cpp)", 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)