[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:
parent
01c5290e30
commit
977722b7cd
6 changed files with 613 additions and 54 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue