- project_vms.add_vm: VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte). vnc_display<=0 oder belegtes Display → naechstes freies. Updates behalten ihr Display. Verhindert Port-Konflikt (5901) wenn mehrere VMs laufen. Getestet: vm1..3 → 1,2,3; Wunsch 2 (belegt) → 4; Update behaelt Display. - vm_register-Tool + Seed-Regel: vnc_display weglassen, wird auto-vergeben. - Gateway-IP: der Brain liest sie aus /proc/net/route bei JEDEM Boot frisch (Docker-IP-Aenderung nach Netz-Neuaufbau wird abgefangen); boot-Antwort enthaelt vnc_bind zur Transparenz. py_compile clean. Brain-only, Deploy: brain rebuild. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
119 lines
3.9 KiB
Python
119 lines
3.9 KiB
Python
"""
|
|
project_vms — Registry der QEMU-VMs PRO PROJEKT.
|
|
|
|
Persistenz: /shared/config/project_vms.json → { project_id: [ {vm}, ... ] }.
|
|
Eine VM = {name, arch, iso, vnc_display, mem, created_at, updated_at}. Der echte
|
|
Start/Stop laeuft ueber `aria-vm` auf dem Host (SSH aria-wohnung, siehe main.py);
|
|
diese Datei haelt nur die Zuordnung VM ↔ Projekt + die Startparameter, damit die
|
|
Liste im Desktop-Panel der App pro Projekt erscheint (auch wenn leer).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import re
|
|
import time
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
VMS_FILE = Path(os.environ.get("PROJECT_VMS_FILE", "/shared/config/project_vms.json"))
|
|
NAME_RE = re.compile(r"^[a-zA-Z0-9_-]{1,40}$")
|
|
VALID_ARCH = {"x86_64", "amd64", "i386", "i686", "x86", "arm", "aarch64",
|
|
"mips", "mipsel", "mips64", "ppc", "ppc64", "riscv64", "sparc"}
|
|
|
|
|
|
def _load() -> dict:
|
|
if not VMS_FILE.exists():
|
|
return {}
|
|
try:
|
|
data = json.loads(VMS_FILE.read_text(encoding="utf-8"))
|
|
return data if isinstance(data, dict) else {}
|
|
except Exception:
|
|
return {}
|
|
|
|
|
|
def _save(data: dict) -> None:
|
|
VMS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
tmp = VMS_FILE.with_suffix(".tmp")
|
|
tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8")
|
|
tmp.replace(VMS_FILE)
|
|
|
|
|
|
def list_vms(project_id: str) -> list[dict]:
|
|
return _load().get(project_id or "", [])
|
|
|
|
|
|
def get_vm(project_id: str, name: str) -> Optional[dict]:
|
|
for v in list_vms(project_id):
|
|
if v.get("name") == name:
|
|
return v
|
|
return None
|
|
|
|
|
|
def _used_displays(data: dict, exclude: object = None) -> set:
|
|
"""Alle VNC-Displays, die ueber ALLE Projekte belegt sind (exclude = eine
|
|
VM-Dict-Instanz, die ignoriert wird — fuer Updates)."""
|
|
used = set()
|
|
for lst in data.values():
|
|
for v in lst:
|
|
if v is exclude:
|
|
continue
|
|
try:
|
|
used.add(int(v.get("vnc_display", 1)))
|
|
except (TypeError, ValueError):
|
|
pass
|
|
return used
|
|
|
|
|
|
def add_vm(project_id: str, name: str, arch: str, iso: str = "",
|
|
floppy: str = "", disk: str = "",
|
|
vnc_display: int = 0, mem: int = 1024) -> dict:
|
|
"""Registriert/aktualisiert eine VM. Medien (disk/floppy/iso) optional —
|
|
leer = aria-vm erkennt disk.qcow2/floppy.img/cdrom.iso im VM-Ordner selbst.
|
|
|
|
Das VNC-Display wird GLOBAL eindeutig vergeben (ueber alle Projekte), damit
|
|
mehrere laufende VMs nicht denselben Port doppelt binden. vnc_display<=0 oder
|
|
ein bereits belegtes Display → automatisch das naechste freie."""
|
|
if not NAME_RE.match(name or ""):
|
|
raise ValueError(f"Ungueltiger VM-Name: {name!r} (nur a-z0-9_-, max 40)")
|
|
if arch not in VALID_ARCH:
|
|
raise ValueError(f"Unbekannte Architektur: {arch!r}")
|
|
data = _load()
|
|
lst = data.setdefault(project_id or "", [])
|
|
now = int(time.time())
|
|
existing = next((v for v in lst if v.get("name") == name), None)
|
|
|
|
used = _used_displays(data, exclude=existing)
|
|
req = int(vnc_display or 0)
|
|
if req <= 0 and existing: # Update ohne Display-Wunsch → behalten
|
|
req = int(existing.get("vnc_display", 0) or 0)
|
|
if req <= 0 or req in used: # frei/eindeutig machen
|
|
req = 1
|
|
while req in used:
|
|
req += 1
|
|
|
|
fields = {"arch": arch, "iso": iso, "floppy": floppy, "disk": disk,
|
|
"vnc_display": req, "mem": int(mem), "updated_at": now}
|
|
if existing:
|
|
existing.update(fields)
|
|
_save(data)
|
|
return existing
|
|
vm = {"name": name, "created_at": now, **fields}
|
|
lst.append(vm)
|
|
_save(data)
|
|
return vm
|
|
|
|
|
|
def remove_vm(project_id: str, name: str) -> bool:
|
|
data = _load()
|
|
lst = data.get(project_id or "")
|
|
if not lst:
|
|
return False
|
|
new = [v for v in lst if v.get("name") != name]
|
|
if len(new) == len(lst):
|
|
return False
|
|
data[project_id or ""] = new
|
|
_save(data)
|
|
return True
|