Files
duffyduckandClaude Opus 4.8 2832ab3dc9 fix(vm): global eindeutige VNC-Displays (kein Doppel-Binding) + Gateway per-boot
- 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>
2026-07-21 00:23:23 +02:00

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