""" 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 = [] for c in disc.get("categories", []): role = c["role"] if 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": 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 SYSTEM_SERVICES: if err := check_sudo_needs_password(sudo_password): return err cmd = ["sudo", "journalctl", "-u", service, "-n", str(lines), "--no-pager"] r = _run(cmd, sudo_password=sudo_password) elif service in USER_SERVICES: cmd = ["journalctl", "--user", "-u", service, "-n", str(lines), "--no-pager"] r = _run(cmd) else: return {"ok": False, "text": "", "err": "Dienst nicht erlaubt."} return {"ok": r["ok"], "text": r["out"] or r["err"]} 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)