[ADD] proxmox deploy : accélération 3D, bibliothèques sondées et posées

A Proxmox VM always used the serial console as its display, so nothing inside
it could render. The form offers 3D: the VM gets « --vga virtio-gl » and the
account joins the render and video groups in the guest, which « qm set »
cannot write; without them GL falls back to software while VIRGL reports
success. The box is offered only where the host has a render node, VIRGL, GL
and EGL, Proxmox refusing to start the VM otherwise once its disk is written.
What is missing is named with its package, and a button installs it, then
reads the host again. Covered by test/test_proxmox_form.py.

--- FR ---

Une VM Proxmox prenait toujours la console série pour affichage : rien ne
pouvait y rendre. Le formulaire offre la 3D : la VM reçoit « --vga virtio-gl »
et le compte entre dans les groupes render et video dans l'invité, ce que
« qm set » ne sait pas écrire ; sans eux, GL retombe en logiciel alors que
VIRGL annonce un succès. La case n'est offerte que si l'hôte a un nœud de
rendu, VIRGL, GL et EGL, Proxmox refusant sinon de démarrer la VM une fois son
disque écrit. Ce qui manque est nommé avec son paquet, et un bouton le pose
puis relit l'hôte. Couvert par test/test_proxmox_form.py.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-14 14:41:20 -04:00
parent 01c5290e30
commit 977722b7cd
6 changed files with 613 additions and 54 deletions

View file

@ -963,17 +963,25 @@ def create_cmds(vmid: int, spec: dict) -> list:
nom = spec["name"]
stockage = spec["storage"]
image = f"{spec.get('image_dir', IMAGE_DIR)}/{spec['image']}"
# L'écran de la VM. « serial0 » fait de la console série l'affichage :
# c'est ce que « qm terminal » attend, et c'est le défaut d'une machine
# de serveur. La 3D demande un vrai périphérique vidéo — « virtio-gl »
# pose un virtio-gpu que le VIRGL de l'hôte accélère. Le port série reste
# posé dans les deux cas, donc la console série ne se perd jamais ; seule
# la nature de l'écran change.
vga = "virtio-gl" if spec.get("gpu3d") else "serial0"
cmds = [
# 1. La coquille : processeur, mémoire, réseau, contrôleur, agent.
"qm create {id} --name {nom} --memory {mem} --cores {cpu}"
" --cpu host --ostype l26 --scsihw virtio-scsi-single"
" --net0 virtio,bridge={pont} --agent enabled=1"
" --serial0 socket --vga serial0".format(
" --serial0 socket --vga {vga}".format(
id=vmid,
nom=shlex.quote(nom),
mem=int(spec["memory"]),
cpu=int(spec["vcpus"]),
pont=spec["bridge"],
vga=vga,
),
# 2. Le disque, importé DEPUIS l'image cloud. « import-from » (PVE 8+)
# remplace l'ancien « qm importdisk » en une seule étape et attache

View file

@ -115,6 +115,8 @@ def build_spec(vms, existants, form):
# La coupure d'amont demandée pour TOUT le déploiement, installation
# comprise. Absente de la spec, le déploiement part en ligne.
"offline": bool(form.get("offline")),
# L'écran accéléré, posé à la création de chaque VM.
"gpu3d": bool(form.get("gpu3d")),
}
@ -158,6 +160,15 @@ def run_proxmox_form(ctx, run_app: bool = True):
# un effet : l'hôte Proxmox est alors une VM de notre pont, et le menu
# l'a établi avant d'ouvrir cet écran.
cache_offert = bool(ctx.get("cache_offert"))
# La 3D ne s'offre que là où l'hôte distant peut la rendre : nœud de
# rendu et VIRGL. Le menu l'a sondé avant d'ouvrir cet écran.
gpu_offert = bool(ctx.get("gpu_offert"))
# Ce qui manque à l'hôte, nommé par la sonde : sans lui, la case
# disparaîtrait sans que rien ne dise pourquoi.
gpu_manque = str(ctx.get("gpu_manque") or "")
# Ce qui s'INSTALLE là-dedans : le nœud de rendu vient du matériel ou
# d'un GPU transmis, et « apt install noeud » enverrait dans le mur.
paquets_gpu = " ".join(p for p in gpu_manque.split() if p != "noeud")
# {système: (libellé, commande)} — ce qu'un système impose d'installer.
distro_profiles = ctx.get("distro_profiles") or {}
# Les commandes qui ne posent PAS ERPLibre : sa marge disque ne les suit
@ -319,6 +330,40 @@ def run_proxmox_form(ctx, run_app: bool = True):
placeholder="100",
id="f_vmid",
)
# L'écran de la VM se choisit à la CRÉATION : « qm » pose
# « --vga virtio-gl » au lieu de la console série, et le
# changer ensuite demande d'éteindre la machine. La
# console série reste posée dans les deux cas.
if gpu_offert:
yield Checkbox(
t(
"3D acceleration (host GPU), even without a"
" screen"
),
value=False,
id="f_gpu3d",
)
elif gpu_manque:
# Une case qui disparaît sans un mot se lit comme une
# régression : on dit ce qui manque, et le paquet à
# poser sur l'HÔTE, pas dans la VM.
yield Static(
f" {t('No 3D: the host lacks')} {gpu_manque}",
id="t_gpu_manque",
)
if paquets_gpu:
yield Static(
f" sudo apt install {paquets_gpu}",
id="t_gpu_geste",
)
# Le bouton n'existe que si le menu a fourni de
# quoi poser : un bouton sans effet vaut moins
# qu'une commande à recopier.
if ctx.get("installer_gpu"):
yield Button(
t("Install on the host"),
id="f_gpu_poser",
)
yield Static(t("Access"), classes="grouptitle")
yield Static(f" {t('SSH public key')}")
yield Input(
@ -899,9 +944,50 @@ def run_proxmox_form(ctx, run_app: bool = True):
self._set_override(index, champ, event.value)
self._refresh_after()
def _poser_gpu(self) -> None:
"""Pose ce qui manque sur l'hôte, terminal rendu, puis RELIT.
« suspend() » rend le clavier à sudo, qui peut demander un mot de
passe, et laisse apt s'afficher : la moitié de la confiance tient
à voir le travail se faire.
L'hôte est SONDÉ de nouveau au retour. Croire apt sur parole
offrirait une case que Proxmox refuserait ensuite — et il ne la
refuse qu'après avoir écrit le disque de la VM.
"""
poser = ctx.get("installer_gpu")
if not poser:
return
with self.suspend():
poser(paquets_gpu)
sonder = ctx.get("sonder_gpu")
possible, reste = sonder() if sonder else (False, gpu_manque)
if not possible:
self.notify(
f"{t('Still missing on the host:')} {reste or gpu_manque}",
severity="warning",
)
return
# Monter la case AVANT de retirer les lignes : le bloc n'est
# jamais vide, et l'œil suit ce qui remplace quoi.
self.query_one("#fields").mount(
Checkbox(
t("3D acceleration (host GPU), even without a screen"),
value=True,
id="f_gpu3d",
),
after=self.query_one("#t_gpu_manque"),
)
for sel in ("#t_gpu_manque", "#t_gpu_geste", "#f_gpu_poser"):
for widget in self.query(sel):
widget.remove()
self.notify(t("3D is now available: the box is here."))
def on_button_pressed(self, event) -> None:
ident = event.button.id or ""
if ident == "go":
if ident == "f_gpu_poser":
self._poser_gpu()
elif ident == "go":
self.action_deploy()
elif ident == "no":
self.action_cancel()
@ -950,6 +1036,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
self.query("#f_offline")
and self.query_one("#f_offline", Checkbox).value
)
# Même règle que pour la coupure : la case n'existe que là où
# elle a un effet, et son absence vaut « non ».
gpu3d = bool(
self.query("#f_gpu3d")
and self.query_one("#f_gpu3d", Checkbox).value
)
return {
"host": ctx["host"],
"storage": self._storage(),
@ -964,6 +1056,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
# Le suivi est demandé au NIVEAU DU DÉPLOIEMENT : une VM sans
# ERPLibre se suit aussi (cloud-init, puis relevé système).
"offline": offline,
"gpu3d": gpu3d,
# Hors ligne, le suivi est d'office : seule sa voie confie la
# levée de la coupure au guet, qui la tient jusqu'à la fin de
# la dernière installation.

View file

@ -361,6 +361,55 @@ class ProxmoxMenuMixin:
print(f" ⚠ {t('exit code')} {code}")
return code, out
# Ce que « --vga virtio-gl » exige de l'hôte : le chemin qui le prouve,
# et le nom qui sert à le poser. Les noms sont ceux des paquets, car
# c'est ce qu'un opérateur tape ; « noeud » n'en est pas un, le nœud de
# rendu venant du matériel ou d'un GPU transmis.
_PVE_GPU_PIECES = (
("/dev/dri/renderD*", "noeud"),
("/usr/lib/*/libvirglrenderer.so.*", "libvirglrenderer1"),
("/usr/lib/*/libGL.so.1", "libgl1"),
("/usr/lib/*/libEGL.so.1", "libegl1"),
)
def _pve_gpu_dispo(self):
"""Ce qui MANQUE à l'hôte Proxmox pour donner de la 3D à ses invités.
Rend (possible, manque) : « manque » nomme les pièces absentes et
vaut "" quand tout est là. Une case qui disparaît sans un mot ne se
devine pas — le formulaire s'en sert pour DIRE pourquoi la 3D n'est
pas offerte, et quoi installer.
Une sonde qui n'aboutit pas rend (False, "") : on ne promet rien, et
on n'accuse rien non plus. Le jeton « FIN » distingue une sonde qui a
tout trouvé — donc muette — d'une sonde qui n'a pas tourné.
Quatre pièces, et il les faut TOUTES. Un NŒUD DE RENDU
(« /dev/dri/renderD* ») : un hôte sans GPU, ou lui-même virtualisé
sans GPU transmis, n'en expose aucun. Puis les trois bibliothèques
que Proxmox charge pour « --vga virtio-gl » : VIRGL, GL et EGL. Il
les réclame nommément — « missing libraries for 'virtio-gl'
detected! Please install 'libgl1' and 'libegl1' » — et refuse de
démarrer la machine, une fois son disque écrit et sa configuration
posée. La case promettrait alors une accélération que rien ne
fournit, et le déploiement échouerait à la dernière étape.
Un hôte peut en porter une sans l'autre : VIRGL et GL viennent avec
d'autres paquets, EGL non.
"""
sonde = "; ".join(
f"ls {chemin} >/dev/null 2>&1 || echo {jeton}"
for chemin, jeton in self._PVE_GPU_PIECES
)
code, sortie = self._pve_show(f"{sonde}; echo FIN", quiet=True)
dites = [l.strip() for l in (sortie or "").splitlines() if l.strip()]
if code or "FIN" not in dites:
return False, ""
manque = [
jeton for _c, jeton in self._PVE_GPU_PIECES if jeton in dites
]
return (not manque), " ".join(manque)
def _pve_vms(self):
"""[{vmid, name, status, …}] des VM de l'hôte, ou []."""
from script.proxmox import proxmox_deploy as pve
@ -1169,6 +1218,38 @@ class ProxmoxMenuMixin:
"""Les commandes qui seraient lancées pour CETTE VM."""
return self._pve_vm_commands(mod, vm, spec)
# UNE sonde, deux réponses : ce que l'hôte peut faire, et ce qui lui
# manque pour le faire. Sondé deux fois, l'écran pourrait offrir la
# case et nommer en même temps ce qui l'empêche.
gpu_possible, gpu_manque = self._pve_gpu_dispo()
def poser_gpu(paquets):
"""Pose les paquets manquants SUR L'HÔTE. Rend True si apt a fini.
Interactive à dessein : le terminal est rendu par l'écran avant
l'appel, « ssh -t » ouvre un vrai terminal distant, et sudo peut
donc demander son mot de passe. Rien n'est capturé — l'opérateur
voit apt travailler, ce qui est la moitié de la confiance.
Le nœud de rendu n'est pas un paquet et ne s'installe pas : il
est écarté, et une liste qui n'en contient pas d'autre ne lance
rien plutôt que d'appeler « apt install » les mains vides.
"""
noms = [
p
for p in str(paquets or "").split()
if p != "noeud" and re.fullmatch(r"[A-Za-z0-9.+_-]+", p)
]
if not noms:
return False
remote = pve.wrap_privilege(
"apt-get update && apt-get install -y " + " ".join(noms),
host.get("sudo") or "",
)
argv = pve.ssh_argv(host, remote, tty=True)
print("\n" + " ".join(shlex.quote(a) for a in argv) + "\n")
return subprocess.call(argv) == 0
return {
"host": dict(host, label=self._pve_label(host)),
"node": self._pve_node_name(),
@ -1235,6 +1316,17 @@ class ProxmoxMenuMixin:
# vit pas ici ne traverse rien qu'on sache couper, et la case y
# promettrait un hors-ligne que personne ne tient.
"cache_offert": bool(self._pve_cache_ca(host)),
# Lu ICI, terminal encore à nous : la sonde passe par ssh, et une
# invite de mot de passe pendant que l'écran affiche le casserait.
"gpu_offert": gpu_possible,
# Ce qui manque, nommé : une case qui disparaît sans un mot se
# lit comme une régression, et l'opérateur n'a rien à corriger.
"gpu_manque": gpu_manque,
# De quoi poser ce qui manque sans quitter l'écran, et de quoi
# RELIRE l'hôte ensuite : sans la seconde, l'écran croirait sur
# parole qu'apt a réussi.
"installer_gpu": poser_gpu,
"sonder_gpu": self._pve_gpu_dispo,
}
def _pve_capacity(self):
@ -1320,6 +1412,10 @@ class ProxmoxMenuMixin:
# Le DNS de l'hôte : « --ipconfig0 » ne le porte pas, et une VM
# en adresse fixe se retrouvait sans résolveur.
"nameservers": spec.get("nameservers") or (),
# L'accélération 3D se décide à la CRÉATION : l'écran d'une VM
# Proxmox est un choix de « qm create », et le changer ensuite
# demande de l'éteindre.
"gpu3d": bool(spec.get("gpu3d")),
}
if spec.get("sshkey_path"):
detail["sshkey_path"] = spec["sshkey_path"]
@ -1712,6 +1808,36 @@ class ProxmoxMenuMixin:
print(f" ✓ {t('apt mirror pinned')} : {miroir}")
return True
def _pve_set_gpu_groups(self, cible, utilisateur, mod=None):
"""Met le compte de la VM dans les groupes du GPU, par ssh.
Le nœud de rendu appartient à « root:render » en 0660 : un compte qui
n'y est pas retombe en rendu logiciel alors même que la négociation
VIRGL entre l'hôte et l'invité a réussi, et rien ne le signale — le
matériel virtuel est bien accéléré, seul l'accès manque.
La voie libvirt pose ces groupes par le cloud-config ; « qm set » ne
sait écrire aucun fichier, d'où ce passage par ssh. Les groupes sont
CRÉÉS au besoin : « render » manque des images les plus anciennes, et
« usermod -aG » sur un groupe inconnu échoue.
Les appartenances ne valent qu'à la PROCHAINE session : celle qui
tourne garde les siennes, ce que l'installation qui suit ne subit pas,
chacune de ses commandes ouvrant sa propre session.
"""
groupes = list(getattr(mod, "GPU_GROUPS", ()) or ("render", "video"))
gestes = [f"sudo groupadd -f {g}" for g in groupes]
gestes.append(
f"sudo usermod -aG {','.join(groupes)}"
f" {shlex.quote(utilisateur)}"
)
code, _o = self._pve_ssh(cible, " && ".join(gestes), timeout=60)
if code:
print(f" ⚠ {t('GPU groups not set')} ({code})")
return False
print(f" ✓ {t('GPU groups set')} : {', '.join(groupes)}")
return True
def _pve_write_guide(self, cible, vm, spec, mod):
"""Pose le guide de connexion et l'identité git DANS la VM.
@ -1997,6 +2123,15 @@ class ProxmoxMenuMixin:
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
self._pve_set_cache_ca(vm["alias"], vm, ca_cache)
# Après la création, qui a posé l'écran accéléré : l'accès au
# nœud de rendu est une affaire de COMPTE, et il se donne
# dans l'invité.
if spec.get("gpu3d"):
self._pve_set_gpu_groups(
vm["alias"],
spec.get("user") or "erplibre",
mod_qemu,
)
joignables.append(vm)
install = spec.get("install")
# Rendu à l'appelant pour son sommaire : lui seul sait ce qui a été

View file

@ -6663,6 +6663,10 @@ TRANSLATIONS = {
"fr": "Pas encore de journal d'accès :",
"en": "No access log yet:",
},
"GPU groups set": {
"fr": "groupes du GPU posés",
"en": "GPU groups set",
},
"apt mirror pinned": {
"fr": "miroir apt fixé",
"en": "apt mirror pinned",
@ -6703,10 +6707,30 @@ TRANSLATIONS = {
"fr": "Pas de réponse ssh : l'invité reste tel qu'il est né.",
"en": "No ssh answer: guest left as created.",
},
"No 3D: the host lacks": {
"fr": "Pas de 3D : il manque à l'hôte",
"en": "No 3D: the host lacks",
},
"Install on the host": {
"fr": "Installer sur l'hôte",
"en": "Install on the host",
},
"3D is now available: the box is here.": {
"fr": "La 3D est maintenant possible : la case est là.",
"en": "3D is now available: the box is here.",
},
"Still missing on the host:": {
"fr": "Il manque encore à l'hôte :",
"en": "Still missing on the host:",
},
"apt mirror not pinned": {
"fr": "miroir apt non fixé",
"en": "apt mirror not pinned",
},
"GPU groups not set": {
"fr": "groupes du GPU non posés",
"en": "GPU groups not set",
},
" Entry 10 follows it live: a request that goes out to the": {
"fr": " L'entrée 10 le suit en direct : une requête qui sort sur",
"en": " Entry 10 follows it live: a request that goes out to the",
@ -6835,58 +6859,6 @@ TRANSLATIONS = {
"fr": "La lever par :",
"en": "Lift it with:",
},
"cache holds nothing for": {
"fr": "le cache ne détient rien pour",
"en": "cache holds nothing for",
},
"an offline VM will fail": {
"fr": "une VM hors ligne échouera",
"en": "an offline VM will fail",
},
"The cut hits every user of the cache:": {
"fr": "La coupure vaut pour tous les usagers du cache :",
"en": "The cut hits every user of the cache:",
},
"a deployment run from another terminal": {
"fr": "un déploiement mené depuis un autre terminal",
"en": "a deployment run from another terminal",
},
"goes offline too, without asking for it.": {
"fr": "sera hors ligne aussi, sans l'avoir demandé.",
"en": "goes offline too, without asking for it.",
},
"Cache upstream cut for this deployment.": {
"fr": "Amont du cache coupé pour ce déploiement.",
"en": "Cache upstream cut for this deployment.",
},
"Cache upstream restored.": {
"fr": "Amont du cache rebranché.",
"en": "Cache upstream restored.",
},
"Upstream not cut: nothing deployed.": {
"fr": "Amont non coupé : rien n'est déployé.",
"en": "Upstream not cut: nothing deployed.",
},
"The result would look offline without being so.": {
"fr": "Le résultat se lirait comme hors ligne sans l'être.",
"en": "The result would look offline without being so.",
},
"dnsmasq is missing on the host: names cannot": {
"fr": "dnsmasq manque sur l'hôte : les noms ne peuvent",
"en": "dnsmasq is missing on the host: names cannot",
},
"be cut. Install the dnsmasq package, then F5.": {
"fr": "pas être coupés. Installer le paquet dnsmasq, puis F5.",
"en": "be cut. Install the dnsmasq package, then F5.",
},
"download cache authority installed": {
"fr": "autorité du cache de téléchargement posée",
"en": "download cache authority installed",
},
"download cache authority not installed": {
"fr": "autorité du cache de téléchargement non posée",
"en": "download cache authority not installed",
},
"waiting for the guest agent install (apt lock)": {
"fr": "attente de la pose de l'agent invité (verrou apt)",
"en": "waiting for the guest agent install (apt lock)",
@ -7087,6 +7059,58 @@ TRANSLATIONS = {
"fr": "Les VM n'ont plus de sortie directe non plus : seul l'hôte leur répond.",
"en": "The VMs have no direct way out either: only the host answers them.",
},
"cache holds nothing for": {
"fr": "le cache ne détient rien pour",
"en": "cache holds nothing for",
},
"an offline VM will fail": {
"fr": "une VM hors ligne échouera",
"en": "an offline VM will fail",
},
"The cut hits every user of the cache:": {
"fr": "La coupure vaut pour tous les usagers du cache :",
"en": "The cut hits every user of the cache:",
},
"a deployment run from another terminal": {
"fr": "un déploiement mené depuis un autre terminal",
"en": "a deployment run from another terminal",
},
"goes offline too, without asking for it.": {
"fr": "sera hors ligne aussi, sans l'avoir demandé.",
"en": "goes offline too, without asking for it.",
},
"Cache upstream cut for this deployment.": {
"fr": "Amont du cache coupé pour ce déploiement.",
"en": "Cache upstream cut for this deployment.",
},
"Cache upstream restored.": {
"fr": "Amont du cache rebranché.",
"en": "Cache upstream restored.",
},
"Upstream not cut: nothing deployed.": {
"fr": "Amont non coupé : rien n'est déployé.",
"en": "Upstream not cut: nothing deployed.",
},
"The result would look offline without being so.": {
"fr": "Le résultat se lirait comme hors ligne sans l'être.",
"en": "The result would look offline without being so.",
},
"dnsmasq is missing on the host: names cannot": {
"fr": "dnsmasq manque sur l'hôte : les noms ne peuvent",
"en": "dnsmasq is missing on the host: names cannot",
},
"be cut. Install the dnsmasq package, then F5.": {
"fr": "pas être coupés. Installer le paquet dnsmasq, puis F5.",
"en": "be cut. Install the dnsmasq package, then F5.",
},
"download cache authority installed": {
"fr": "autorité du cache de téléchargement posée",
"en": "download cache authority installed",
},
"download cache authority not installed": {
"fr": "autorité du cache de téléchargement non posée",
"en": "download cache authority not installed",
},
"Timezone": {
"fr": "Fuseau horaire",
"en": "Timezone",

View file

@ -356,6 +356,27 @@ class TestLesCommandes(unittest.TestCase):
self.assertIn("qm resize 100 scsi0 12G", joint)
self.assertTrue(cmds[-1].endswith("qm start 100"))
def test_without_3d_the_screen_stays_the_serial_console(self):
"""Le défaut d'une machine de serveur : « qm terminal » en dépend, et
poser un périphérique vidéo d'office changerait son matériel."""
creation = pve.create_cmds(100, self._spec())[0]
self.assertIn("--vga serial0", creation)
self.assertIn("--serial0 socket", creation)
def test_3d_asks_for_an_accelerated_screen(self):
"""« virtio-gl » donne à l'invité un virtio-gpu que le VIRGL de
l'hôte accélère."""
creation = pve.create_cmds(100, self._spec(gpu3d=True))[0]
self.assertIn("--vga virtio-gl", creation)
self.assertNotIn("--vga serial0", creation)
def test_the_serial_port_survives_the_accelerated_screen(self):
"""Le port série reste posé : sans lui, « qm terminal » n'a plus rien
où s'attacher, et une VM qui ne démarre pas devient muette."""
self.assertIn(
"--serial0 socket", pve.create_cmds(100, self._spec(gpu3d=True))[0]
)
def test_the_agent_and_the_serial_console_are_asked_for(self):
"""Sans agent, aucune adresse ; sans serial0, « qm terminal » est
inutilisable et il ne reste que l'interface web."""

View file

@ -192,6 +192,9 @@ def contexte():
# L'hôte d'essai est une VM de notre pont : la case « Sans connexion
# internet » est donc offerte, comme sur un Proxmox imbriqué.
"cache_offert": True,
# L'hôte d'essai a un nœud de rendu et le VIRGL : la case 3D est
# donc offerte, comme sur un hôte Proxmox capable.
"gpu_offert": True,
"ipconfig": lambda pont, vmid: f"ip=10.10.10.{50 + vmid % 200}/24",
"build_command": lambda vm, spec: [f"qm create {vm['vmid']}"],
"branches": ["develop", "master"],
@ -888,6 +891,281 @@ class TestLeHorsLigneSurProxmox(unittest.TestCase):
self.assertTrue(vu["suivi_fige"])
class TestLaTroisDSurProxmox(unittest.TestCase):
"""L'accélération 3D, offerte sur Proxmox VE comme sur QEMU/KVM.
Deux moitiés, et l'une sans l'autre ne donne rien. L'ÉCRAN se pose à la
création (« --vga virtio-gl ») ; l'ACCÈS au nœud de rendu est une affaire
de groupes DANS l'invité, que « qm set » ne sait pas écrire. Sans les
groupes, toute application GL retombe en rendu logiciel alors que la
négociation VIRGL a réussi — et rien ne le signale.
"""
def _todo(self):
import sys
sys.argv = ["todo.py"]
from script.todo.todo import TODO
return TODO.__new__(TODO)
def test_les_groupes_sont_crees_avant_detre_donnes(self):
"""« usermod -aG » sur un groupe inconnu échoue, et « render » manque
des images les plus anciennes."""
todo = self._todo()
vu = {}
def faux_ssh(cible, cmd, timeout=120):
vu["cible"], vu["cmd"] = cible, cmd
return 0, ""
todo._pve_ssh = faux_ssh
import contextlib
import io
with contextlib.redirect_stdout(io.StringIO()):
self.assertTrue(todo._pve_set_gpu_groups("pve+vm-a", "erplibre"))
self.assertEqual(vu["cible"], "pve+vm-a")
self.assertLess(
vu["cmd"].index("groupadd -f render"),
vu["cmd"].index("usermod -aG"),
)
self.assertIn("groupadd -f video", vu["cmd"])
self.assertIn("usermod -aG render,video erplibre", vu["cmd"])
def test_un_echec_est_dit_et_non_tu(self):
todo = self._todo()
todo._pve_ssh = lambda *a, **k: (255, "")
import contextlib
import io
sortie = io.StringIO()
with contextlib.redirect_stdout(sortie):
self.assertFalse(todo._pve_set_gpu_groups("pve+vm-a", "erplibre"))
self.assertIn("255", sortie.getvalue())
def test_la_sonde_exige_le_noeud_ET_les_trois_bibliotheques(self):
"""Un hôte sans GPU n'expose aucun nœud de rendu ; sans VIRGL, GL ou
EGL, Proxmox refuse de démarrer la machine — « missing libraries for
'virtio-gl' detected! Please install 'libgl1' and 'libegl1' » —, et
il le refuse APRÈS avoir écrit le disque. Un hôte peut porter GL sans
EGL : les exiger ensemble est le seul contrôle qui vaille."""
todo = self._todo()
vus = []
def faux_show(remote, timeout=120, quiet=False):
vus.append(remote)
# Rien de manquant : la sonde ne dit que son jeton final.
return 0, "FIN\n"
todo._pve_show = faux_show
# Tout est là : la sonde ne dit que « FIN ».
self.assertEqual(todo._pve_gpu_dispo(), (True, ""))
for attendu in (
"/dev/dri/renderD*",
"libvirglrenderer.so.*",
"libGL.so.1",
"libEGL.so.1",
):
self.assertIn(attendu, vus[0])
def test_la_sonde_nomme_ce_qui_manque(self):
"""Le cas vécu : un hôte porte GL mais pas EGL, et Proxmox refuse de
démarrer la machine APRÈS avoir écrit son disque. La case ne doit pas
disparaître en silence — ce qui manque se nomme."""
todo = self._todo()
todo._pve_show = lambda *a, **k: (0, "libegl1\nFIN\n")
self.assertEqual(todo._pve_gpu_dispo(), (False, "libegl1"))
todo._pve_show = lambda *a, **k: (0, "noeud\nlibgl1\nlibegl1\nFIN\n")
self.assertEqual(
todo._pve_gpu_dispo(), (False, "noeud libgl1 libegl1")
)
def test_une_sonde_qui_naboutit_pas_naccuse_rien(self):
"""Sans le jeton final, une sortie vide voudrait dire « tout est
là » : c'est ssh qui a échoué, et on ne promet rien."""
todo = self._todo()
todo._pve_show = lambda *a, **k: (0, "")
self.assertEqual(todo._pve_gpu_dispo(), (False, ""))
todo._pve_show = lambda *a, **k: (255, "FIN")
self.assertEqual(todo._pve_gpu_dispo(), (False, ""))
def test_le_choix_atteint_la_commande_de_creation(self):
from pathlib import Path
racine = Path(__file__).resolve().parent.parent
src = (racine / "script" / "todo" / "proxmox_menu.py").read_text(
encoding="utf-8"
)
self.assertIn('"gpu3d": bool(spec.get("gpu3d"))', src)
# UNE sonde, deux clés : l'écran ne doit pas pouvoir offrir la case
# et nommer en même temps ce qui l'empêche.
self.assertIn("gpu_possible, gpu_manque = self._pve_gpu_dispo()", src)
self.assertIn('"gpu_offert": gpu_possible', src)
self.assertIn('"gpu_manque": gpu_manque', src)
def test_la_case_atteint_la_spec(self):
form = {
"host": {"target": "pve1"},
"storage": "local-lvm",
"bridge": "vmbr0",
"res_label": "x1",
"ssh_key": "",
"start": True,
"add_ssh_config": True,
"install": None,
"monitor": True,
"parallelism": 1,
"gpu3d": True,
}
self.assertTrue(build_spec([{"name": "a"}], [], form)["gpu3d"])
form["gpu3d"] = False
self.assertFalse(build_spec([{"name": "a"}], [], form)["gpu3d"])
def _ecran(self, gpu_offert, cocher=False, manque=""):
"""Monte l'écran avec — ou sans — la 3D possible sur l'hôte."""
from textual.widgets import Checkbox, Static
ctx = contexte()
ctx["gpu_offert"] = gpu_offert
ctx["gpu_manque"] = manque
vu = {}
async def scenario():
app = run_proxmox_form(ctx, run_app=False)
async with app.run_test(size=(200, 50)) as pilote:
await pilote.pause()
cases = app.query("#f_gpu3d")
vu["offerte"] = bool(cases)
if cocher and cases:
cases.first(Checkbox).value = True
await pilote.pause()
vu["valeur"] = app._form_values()["gpu3d"]
# Relevé DANS le contexte : « run_test » démonte les widgets
# en sortant, et le texte n'existerait plus après.
vu["explication"] = " ".join(
str(getattr(w, "_content", "") or w.render())
for w in app.query("#t_gpu_manque")
)
vu["geste"] = " ".join(
str(getattr(w, "_content", "") or w.render())
for w in app.query("#t_gpu_geste")
)
assert Static # l'import sert au typage de la requête
asyncio.run(scenario())
return vu
def test_sans_gpu_sur_lhote_aucune_case(self):
"""Une case qui promettrait une accélération que l'hôte ne peut pas
rendre vaut moins que pas de case."""
vu = self._ecran(False)
self.assertFalse(vu["offerte"])
self.assertFalse(vu["valeur"])
def test_ce_qui_manque_est_nomme_avec_son_paquet(self):
"""Le cas vécu : la case avait disparu après correction de la sonde,
sans que rien ne dise pourquoi. Elle nomme désormais la pièce ET la
commande qui la pose — sur l'HÔTE, pas dans la VM."""
vu = self._ecran(False, manque="libegl1")
self.assertFalse(vu["offerte"])
self.assertIn("libegl1", vu["explication"])
self.assertIn("apt install libegl1", vu["geste"])
def test_un_noeud_de_rendu_absent_ne_propose_aucun_paquet(self):
"""Le nœud vient du matériel ou d'un GPU transmis : « apt install
noeud » enverrait l'opérateur dans le mur."""
vu = self._ecran(False, manque="noeud")
self.assertIn("noeud", vu["explication"])
self.assertEqual(vu["geste"], "")
def test_rien_nest_dit_quand_la_sonde_na_pas_abouti(self):
"""Sonde muette : on ne promet rien, et on n'accuse rien non plus."""
vu = self._ecran(False)
self.assertEqual(vu["explication"], "")
self.assertEqual(vu["geste"], "")
def test_avec_un_gpu_la_case_est_la_et_decochee(self):
vu = self._ecran(True)
self.assertTrue(vu["offerte"])
self.assertFalse(vu["valeur"])
def test_cocher_porte_le_choix_jusqua_la_spec(self):
self.assertTrue(self._ecran(True, cocher=True)["valeur"])
def _bouton(self, manque="libegl1", apres=(True, ""), moyen=True):
"""Monte l'écran, presse « Installer sur l'hôte », relève la suite.
« suspend() » est remplacé : un écran monté sans terminal ne peut pas
le rendre, et ce n'est pas lui qu'on éprouve. Ce qu'on éprouve, c'est
que le paquet parte, que l'hôte soit RELU, et que l'écran suive.
"""
import contextlib
from textual.widgets import Button
ctx = contexte()
ctx["gpu_offert"] = False
ctx["gpu_manque"] = manque
vu = {"recu": None, "notes": []}
if moyen:
ctx["installer_gpu"] = (
lambda paquets: vu.update(recu=paquets) or True
)
ctx["sonder_gpu"] = lambda: apres
async def scenario():
app = run_proxmox_form(ctx, run_app=False)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
app.suspend = lambda: contextlib.nullcontext()
app.notify = lambda m, **k: vu["notes"].append(str(m))
boutons = app.query("#f_gpu_poser")
vu["bouton"] = bool(boutons)
if boutons:
boutons.first(Button).press()
await pilote.pause()
await pilote.pause()
vu["case"] = bool(app.query("#f_gpu3d"))
vu["lignes"] = bool(app.query("#t_gpu_manque"))
asyncio.run(scenario())
return vu
def test_le_bouton_pose_le_paquet_et_la_case_apparait(self):
"""Ce que l'opérateur demande : ne pas quitter l'écran pour une
commande que l'écran vient de lui montrer."""
vu = self._bouton()
self.assertTrue(vu["bouton"])
self.assertEqual(vu["recu"], "libegl1")
self.assertTrue(vu["case"], "la case n'est pas apparue")
self.assertFalse(vu["lignes"], "le message est resté sous la case")
def test_seuls_les_paquets_partent_a_linstallation(self):
"""Le nœud de rendu ne s'installe pas : l'envoyer à apt ferait
échouer la pose des paquets qui, eux, existent."""
self.assertEqual(
self._bouton(manque="noeud libegl1")["recu"], "libegl1"
)
def test_un_noeud_seul_ne_donne_aucun_bouton(self):
vu = self._bouton(manque="noeud")
self.assertFalse(vu["bouton"])
self.assertFalse(vu["case"])
def test_sans_moyen_de_poser_aucun_bouton(self):
"""Un bouton sans effet vaut moins qu'une commande à recopier."""
self.assertFalse(self._bouton(moyen=False)["bouton"])
def test_lhote_est_relu_et_la_case_ne_vient_pas_sur_parole(self):
"""Croire apt sur parole offrirait une case que Proxmox refuserait
ensuite — après avoir écrit le disque de la VM."""
vu = self._bouton(apres=(False, "libgl1"))
self.assertFalse(vu["case"])
self.assertTrue(vu["lignes"], "le message a disparu pour rien")
self.assertTrue(any("libgl1" in n for n in vu["notes"]), vu["notes"])
class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
"""Une VM Proxmox tire du miroir que le cache a rempli.