diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md
index 6a8f298..9b46999 100644
--- a/CHANGELOG.base.md
+++ b/CHANGELOG.base.md
@@ -41,6 +41,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
+- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
+- A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared
+- Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at
+- Development assistants installed INSIDE a VM at deployment: rtk, starship with its shell hook, and one agent — Claude Code or opencode. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship
+- A Git and Shell menu that installs what a checkout needs rather than printing a command to copy: the repository's git hooks, `merge.conflictStyle=zdiff3`, Starship, Claude Code, opencode, and the Claude Code plugins with an ERPLibre list. Three assistant commands are deployed from it — `/git_prepare_merge`, `/todo_plan_max`, `/todo_generate_code`. The missing tools of a safe shrink are installed across the four package families, where three separate pieces of code each knew a different subset. A binary posed in a HOME directory the shell's PATH does not always carry is found anyway, and the export line is written once
+- A deployment blocked by an orphan disk — the qcow2 an interrupted creation leaves behind, which `deploy_qemu` then refuses to overwrite — is offered its deletion, size and path shown, before the creation fails after having made you wait
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed
- The TODO menu shows the context an assistant is given, scattered as it is over six sources: instructions, rules, skills, deployed commands, git hooks and memory. Each deployed command is compared against its repository template, ignoring the git identity lines the deployment substitutes, so a stale copy shows up where a strict equality would declare them all stale. Nothing from `private/` is reported, not even a count: naming a file whose purpose is to hold what must not go out amounts to pointing at it
- Duplicating a database and neutralising it for good: duplication goes through Odoo's `exp_duplicate_database` rather than `CREATE DATABASE … TEMPLATE`, which copies the tables and nothing else. Only Odoo drops the source's open connections — one Odoo shell left open is enough for PostgreSQL to refuse — regenerates `database.uuid`, copies the filestore, and runs the `neutralize.sql` files of the installed modules. The repository's in-house modules obtained none of the four, and one of them opened a door: deleting every `ir.mail_server` makes Odoo fall back on the configuration file's `smtp_server`, where Odoo's own placeholder server exists precisely to plug that hole. Odoo's neutralisation begins at 16; from 12 to 15 the copy falls back on the repository's long-standing `update_prod_to_dev.sh`, which sets no `is_neutralized`, disables no cron and leaves the payment keys, but does remove the mail servers and lay down a development account. The route taken is ANNOUNCED at run time, a copy whose route is unknown being unjudgeable, and a missing script fails the copy rather than letting it come out raw while announcing itself neutralised
@@ -133,6 +139,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
+- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
+- Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie
+- La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées
+- Les assistants de développement installés DANS une VM au déploiement : rtk, starship avec son accroche au shell, et un agent — Claude Code ou opencode. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship
+- Un menu Git et Shell qui installe ce dont un clone a besoin au lieu d'afficher une commande à recopier : les hooks git du dépôt, « merge.conflictStyle=zdiff3 », Starship, Claude Code, opencode, et les plugins Claude Code avec une liste ERPLibre. Trois commandes d'assistant s'y déploient — « /git_prepare_merge », « /todo_plan_max », « /todo_generate_code ». Les outils manquants d'une réduction sûre s'installent sur les quatre familles de paquets, là où trois écritures séparées en connaissaient chacune un sous-ensemble différent. Un binaire posé dans un répertoire du HOME que le PATH du shell ne porte pas toujours est trouvé quand même, et la ligne d'export est écrite une seule fois
+- Un déploiement bloqué par un disque orphelin — le qcow2 qu'une création interrompue laisse et que « deploy_qemu » refuse ensuite d'écraser — se voit proposer son effacement, taille et chemin affichés, avant que la création n'échoue après avoir fait attendre
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué
- Le menu TODO montre le contexte fourni à un assistant, éparpillé sur six sources : instructions, règles, skills, commandes déployées, hooks git et mémoire. Chaque commande déployée est comparée à son gabarit du dépôt en ignorant les lignes d'identité git que le déploiement substitue, si bien qu'une copie périmée se voit là où une égalité stricte les déclarerait toutes périmées. Rien de `private/` n'est relevé, pas même un compte : nommer un fichier dont l'objet est de retenir ce qui ne doit pas sortir revient à le désigner
- Dupliquer une base et la neutraliser pour de bon : la duplication passe par `exp_duplicate_database` d'Odoo plutôt que par `CREATE DATABASE … TEMPLATE`, qui copie les tables et rien d'autre. Odoo seul coupe les connexions ouvertes sur la source — un shell Odoo laissé ouvert suffit à faire refuser PostgreSQL —, régénère `database.uuid`, copie le filestore et exécute les fichiers `neutralize.sql` des modules installés. Les modules maison du dépôt n'obtenaient aucun des quatre, et l'un d'eux ouvrait une porte : supprimer tous les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier de configuration, là où le serveur bouchon d'Odoo existe précisément pour boucher ce trou. La neutralisation d'Odoo commence à la 16 ; de 12 à 15 la copie retombe sur la technique de longue date du dépôt, `update_prod_to_dev.sh`, qui ne pose pas `is_neutralized`, ne désactive aucun cron et laisse les clés de paiement, mais supprime les serveurs de courriel et pose un compte de développement. Le chemin suivi est ANNONCÉ à l'exécution, une copie dont on ignore par quel chemin elle est passée ne se jugeant pas, et un script introuvable fait échouer la copie plutôt que de la laisser sortir brute en s'annonçant neutralisée
@@ -231,6 +243,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
+- The name of a VM built from a rolling release drops the version segment: `latest` distinguishes no VM from another. A named version that coexists with others in the catalogue stays
+- Every menu entry carries an icon, ten menus having stayed bare, and the spacing follows the RENDERED width read from Unicode rather than guessed — two spaces after a one-column emoji, one after a wide one. The QEMU Manage section, grown too long to scan, is split into Manage, VM access and Troubleshoot
- Staging names the files, never `git add -A`. The sweep stages everything untracked, and this repository keeps two directories untracked ON PURPOSE: `private/`, the only place allowed to hold customer data, and `tasks/`, where the convention sends the investigation precisely because it is not versioned. It also swallows whatever else is in flight in the checkout, under a subject that does not cover it; `git add -p` stages the hunks when one file carries two subjects
- A countdown prompt gives 15 seconds to decide, where five were not enough to READ the question: the countdown exists so a run can be left unattended, not to go fast, and too short it does the opposite — the answer comes by reflex, or a default no one read is taken. A restored database is named after the backup file, which already carries a telling name, rather than « test », under which successive migrations all landed on one name. The name is sanitised, since it ends up in a createdb, and capped at 41 characters: the driver appends « _neutralize_upgrade_18 » and PostgreSQL truncates at 63, which would put two tiers on the same name. A remote download keeps the name the server gave
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
@@ -239,6 +253,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
+- Le nom d'une VM bâtie sur une publication continue perd le segment de version : « latest » ne distingue aucune VM d'une autre. Une version nommée qui coexiste avec d'autres au catalogue y reste
+- Chaque entrée de menu porte une icône, dix menus étant restés nus, et l'espacement suit la largeur RENDUE lue dans Unicode plutôt que devinée — deux espaces derrière un emoji d'une colonne, une derrière un large. La section Gérer de QEMU, devenue trop longue à parcourir, est scindée en Gérer, Accès à la VM et Dépannage
- L'indexation nomme les fichiers, jamais `git add -A`. Le ratissage indexe tout ce qui n'est pas suivi, et le dépôt garde deux répertoires non suivis EXPRÈS : `private/`, seul endroit autorisé à porter une donnée de client, et `tasks/`, où la convention envoie l'enquête précisément parce qu'il n'est pas versionné. Il emporte aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le couvre pas ; `git add -p` indexe les hunks quand un fichier porte deux sujets
- Une invite à compte à rebours laisse 15 secondes pour décider, là où cinq ne suffisaient pas à LIRE la question : le compte à rebours n'existe pas pour aller vite mais pour qu'une exécution puisse être laissée sans surveillance, et trop court il fait l'inverse — la réponse vient par réflexe, ou un défaut que personne n'a lu s'applique. Une base restaurée porte le nom du fichier de sauvegarde, qui en porte déjà un parlant, plutôt que « test », sous lequel des migrations successives finissaient toutes sur un même nom. Le nom est assaini, puisqu'il finit dans un createdb, et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom. Un téléchargement distant garde celui que le serveur a donné
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
@@ -251,6 +267,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Corrigé
+- The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission
+- System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'`
+- Accepting to install the QEMU packages no longer reboots the host without asking: `--assume-yes` covered the package manager, and the command silently added `--reboot-if-needed`. One constant served both the disposable guest and the workstation
+- Three screens that fell over: the statistics screen, where `datetime.datetime` does not exist since the CLASS is imported and the error took all of TODO with it; a failed VM whose output showed four lines of epilogue instead of the tool's own message; and a duplicate i18n key silently overwriting a main-menu label, the last definition winning in a Python dict literal with neither error nor warning
+- A shrink measures the free space BEFORE offering the backup, which doubles the space used and defaulted to YES: on an almost full disk, an empty answer started a copy that stopped halfway and left a truncated `.bak`
- `odoo_bin.sh db --drop` failed with AccessDenied at every migration tier, and the clone then hit « database already exists »: db_restore.py reads the repository's config.conf, sees admin_passwd = admin and therefore sends no master password, while odoo_bin.sh passed no « -c », so Odoo read ~/.odoorc and its hashed one. ODOO_RC closes that seam in one place instead of twenty call sites, versions 12 to 18 reading it after « -c » and before ~/.odoorc, so an explicit choice still wins
- The account.root SQL view Odoo 17 creates is dropped before the load into 18, where the model carries _auto = False and _table_query = '0', so its name enters no query and the missing-table check skips it. The view is not merely dead but WRONG, built on the code column the 18 ORM no longer writes, and it is the sole pin holding the two legacy columns database_cleanup fails on — no DROP COLUMN, OpenUpgrade still reading them afterwards
- The migration repair created pricelists that should not exist, in two ways. It asked `env.user.has_group()`, which says yes as soon as the caller belongs to the group — and the migration adds it along the way — where the settings checkbox reads something else entirely: what `base.group_user` IMPLIES. Deciding on the caller built a pricelist in a database whose feature is off, and Odoo then warned on every opening of the settings that it would archive it. And a pricelist shared across companies, with an empty `company_id`, did not count as belonging to the company: the repair saw a company without a list and made an empty duplicate beside the existing one, on a database that had lost nothing. Both now read the group implication and the tool's own `pricelist_missing` detector, which already tells the two cases apart; the migration-residue check asked the same wrong question and was corrected with them
@@ -271,6 +292,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation
+- Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission
+- Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' »
+- Accepter d'installer les paquets QEMU ne redémarre plus l'hôte sans demander : « --assume-yes » couvrait le gestionnaire de paquets, et la commande y ajoutait « --reboot-if-needed » en silence. Une seule constante servait l'invité jetable et le poste de travail
+- Trois écrans qui tombaient : les statistiques, où « datetime.datetime » n'existe pas puisque c'est la CLASSE qui est importée et où l'erreur emportait TODO entier ; une VM échouée dont la sortie montrait quatre lignes d'épilogue au lieu du message de l'outil ; et une clé i18n dupliquée qui écrasait un libellé du menu principal, la dernière définition gagnant dans un littéral de dict Python sans erreur ni avertissement
+- Une réduction mesure la place libre AVANT de proposer la sauvegarde, qui double la place occupée et avait OUI pour défaut : sur un disque presque plein, une réponse vide lançait une copie qui s'arrête à mi-course et laisse un « .bak » tronqué
- « odoo_bin.sh db --drop » échouait par AccessDenied à chaque palier de migration, et le clone butait ensuite sur « database already exists » : db_restore.py lit le config.conf du dépôt, y voit admin_passwd = admin et n'envoie donc aucun mot de passe maître, quand odoo_bin.sh ne passait pas de « -c », si bien qu'Odoo lisait ~/.odoorc et son mot de passe haché. ODOO_RC ferme cette couture en un point plutôt qu'à vingt sites d'appel, les versions 12 à 18 le lisant après « -c » et avant ~/.odoorc, donc un choix explicite l'emporte toujours
- La vue SQL account.root que crée Odoo 17 est retirée avant le chargement en 18, où le modèle porte _auto = False et _table_query = '0', si bien que son nom n'entre plus dans aucune requête et que le contrôle des tables manquantes l'ignore. La vue n'est pas seulement morte mais FAUSSE, bâtie sur la colonne code que l'ORM 18 n'écrit plus, et elle est l'unique épingle des deux colonnes héritées où database_cleanup échoue — aucun DROP COLUMN, OpenUpgrade les lisant encore ensuite
- La réparation de migration créait des listes de prix qui n'avaient pas lieu d'être, de deux façons. Elle interrogeait `env.user.has_group()`, qui répond oui dès que l'exécutant est membre du groupe — et la migration l'y ajoute en cours de route —, là où la case des réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE. Décider sur l'exécutant créait une liste de prix dans une base dont la fonctionnalité est éteinte, et Odoo prévenait alors à chaque ouverture des réglages qu'il allait l'archiver. Et une liste de prix partagée entre sociétés, au `company_id` vide, ne comptait pas comme appartenant à la société : la réparation voyait une société sans liste et fabriquait un doublon vide à côté de l'existante, sur une base qui n'avait pourtant rien perdu. Les deux lisent désormais l'implication du groupe et le détecteur `pricelist_missing` du même outil, qui distingue déjà les deux cas ; le contrôle « restant de migration » posait la même mauvaise question et a été corrigé avec elles
diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md
index 015acdf..8b8aca6 100644
--- a/CHANGELOG.fr.md
+++ b/CHANGELOG.fr.md
@@ -15,6 +15,12 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
+- L'accélération 3D des VM QEMU, cochée à la création et réglable ensuite, même sur une VM SANS écran virtuel — « auto » ne l'accorde jamais là, s'abstenant plutôt que de poser un périphérique vidéo que personne n'a demandé, alors qu'un rendu hors écran ou un émulateur tournant dedans veut exactement cela. Un nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le domaine et la VM reste inutilisable jusqu'à ce que quelqu'un défasse le réglage, d'où un repli sur le rendu logiciel à la création et le retrait proposé sur une VM existante. Dans l'invité, le nœud de rendu appartient à « root:render » en 0660 et le compte n'y était pas : toute application GL retombait sur le rendu logiciel alors que la négociation VIRGL avait réussi, sans que rien ne le signale ; « render » et « video » sont désormais déclarés AVANT usage, un nom de groupe inconnu faisant que cloud-init ne crée aucun compte — ni mot de passe, ni clé SSH, une VM qui démarre injoignable
+- Un diagnostic QEMU, écrit dans un fichier unique à transmettre à quelqu'un qui n'a pas accès à la machine : vingt et une sondes en lecture — hôte, hyperviseur, GPU, outils présents, stockage — chacune bornée dans le temps, une commande qui pend ne devant pas retenir le rapport, et chaque section isolée, le fichier s'écrivant d'un bloc à la fin. Il dit l'état 3D de chaque VM d'après sa définition PERSISTANTE, où trois valeurs répondent ensemble et aucune seule : le type de vidéo, « accel3d », et le device figé par libvirt. Ce dernier, un attribut arrivé avec libvirt 12.5.0 pour tenir l'ABI de l'invité stable d'un démarrage à l'autre, L'EMPORTE sur « accel3d » : une VM démarrée une première fois sans 3D garde le device sans GL, et cocher la case ensuite écrit une intention que rien n'applique. Le rapport propose aussi les outils qui lui manquent, la commande complète affichée avant la question, et la liste des périphériques que QEMU peut ouvrir — libvirt y met le nœud de rendu quand le domaine le déclare, jamais les nœuds propres d'une carte propriétaire, que sa pile ouvre pourtant. Il porte le nom de l'hôte, ses chemins et ses adresses, et le dit avant qu'on l'envoie
+- La récupération de fichiers dans le disque d'une VM qui ne démarre plus, libguestfs montant son qcow2 sans elle. Toute commande porte « --ro », et c'est ce qui change la manœuvre : ouvrir en écriture le disque d'une machine allumée corrompt son système de fichiers. Les partitions sont listées, puis les répertoires à extraire, les commandes « copy-out » étant montrées plutôt que devinées
+- Les assistants de développement installés DANS une VM au déploiement : rtk, starship avec son accroche au shell, et un agent — Claude Code ou opencode. La case découvre le choix et l'identité git, pré-remplie avec celle de l'hôte, puisque c'est ce que la VM reçoit déjà et qu'un champ vide la ferait croire absente ; ce qui est saisi prime, champ par champ. Chaque pose est privée d'entrée standard et bornée dans le temps : « || true » couvre l'échec, pas l'ATTENTE, et un installateur amont qui pose une question resterait pendu sur un SSH sans terminal — d'où « -y » pour starship
+- Un menu Git et Shell qui installe ce dont un clone a besoin au lieu d'afficher une commande à recopier : les hooks git du dépôt, « merge.conflictStyle=zdiff3 », Starship, Claude Code, opencode, et les plugins Claude Code avec une liste ERPLibre. Trois commandes d'assistant s'y déploient — « /git_prepare_merge », « /todo_plan_max », « /todo_generate_code ». Les outils manquants d'une réduction sûre s'installent sur les quatre familles de paquets, là où trois écritures séparées en connaissaient chacune un sous-ensemble différent. Un binaire posé dans un répertoire du HOME que le PATH du shell ne porte pas toujours est trouvé quand même, et la ligne d'export est écrite une seule fois
+- Un déploiement bloqué par un disque orphelin — le qcow2 qu'une création interrompue laisse et que « deploy_qemu » refuse ensuite d'écraser — se voit proposer son effacement, taille et chemin affichés, avant que la création n'échoue après avoir fait attendre
- Un hook `pre-commit` liste les commentaires à relire dans les fichiers qu'on indexe, et ne bloque jamais : sur les sources du dépôt, 373 fichiers rendent 463 signaux, et un contrôle bloquant à cette échelle se fait désinstaller la semaine suivante. L'outil qui le sert, `script/analyse/check_comment_hygiene.py`, rapporte deux familles de sûreté inégale — la donnée identifiante, adresse, courriel ou chemin de compte, qui est une trouvaille ; et le récit, marqueur de témoignage, date absolue ou première personne, qui est un signal à RELIRE, l'outil ne pouvant savoir si la phrase énonce un fait durable. Il lit les commentaires et les docstrings, les lignes `#` comme les commentaires shell de fin de ligne, écarte le code tiers, et se replie sur un balayage ligne à ligne quand un source ne se parse pas, un rapport vide déclarant sinon propre un fichier qu'il n'a jamais lu. Les codes de sortie suivent la convention du dépôt : 0 rien à signaler, 1 des trouvailles, 2 l'outil a échoué
- Le menu TODO montre le contexte fourni à un assistant, éparpillé sur six sources : instructions, règles, skills, commandes déployées, hooks git et mémoire. Chaque commande déployée est comparée à son gabarit du dépôt en ignorant les lignes d'identité git que le déploiement substitue, si bien qu'une copie périmée se voit là où une égalité stricte les déclarerait toutes périmées. Rien de `private/` n'est relevé, pas même un compte : nommer un fichier dont l'objet est de retenir ce qui ne doit pas sortir revient à le désigner
- Dupliquer une base et la neutraliser pour de bon : la duplication passe par `exp_duplicate_database` d'Odoo plutôt que par `CREATE DATABASE … TEMPLATE`, qui copie les tables et rien d'autre. Odoo seul coupe les connexions ouvertes sur la source — un shell Odoo laissé ouvert suffit à faire refuser PostgreSQL —, régénère `database.uuid`, copie le filestore et exécute les fichiers `neutralize.sql` des modules installés. Les modules maison du dépôt n'obtenaient aucun des quatre, et l'un d'eux ouvrait une porte : supprimer tous les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier de configuration, là où le serveur bouchon d'Odoo existe précisément pour boucher ce trou. La neutralisation d'Odoo commence à la 16 ; de 12 à 15 la copie retombe sur la technique de longue date du dépôt, `update_prod_to_dev.sh`, qui ne pose pas `is_neutralized`, ne désactive aucun cron et laisse les clés de paiement, mais supprime les serveurs de courriel et pose un compte de développement. Le chemin suivi est ANNONCÉ à l'exécution, une copie dont on ignore par quel chemin elle est passée ne se jugeant pas, et un script introuvable fait échouer la copie plutôt que de la laisser sortir brute en s'annonçant neutralisée
@@ -109,6 +115,8 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
+- Le nom d'une VM bâtie sur une publication continue perd le segment de version : « latest » ne distingue aucune VM d'une autre. Une version nommée qui coexiste avec d'autres au catalogue y reste
+- Chaque entrée de menu porte une icône, dix menus étant restés nus, et l'espacement suit la largeur RENDUE lue dans Unicode plutôt que devinée — deux espaces derrière un emoji d'une colonne, une derrière un large. La section Gérer de QEMU, devenue trop longue à parcourir, est scindée en Gérer, Accès à la VM et Dépannage
- L'indexation nomme les fichiers, jamais `git add -A`. Le ratissage indexe tout ce qui n'est pas suivi, et le dépôt garde deux répertoires non suivis EXPRÈS : `private/`, seul endroit autorisé à porter une donnée de client, et `tasks/`, où la convention envoie l'enquête précisément parce qu'il n'est pas versionné. Il emporte aussi ce qui est en cours ailleurs dans le checkout, sous un sujet qui ne le couvre pas ; `git add -p` indexe les hunks quand un fichier porte deux sujets
- Une invite à compte à rebours laisse 15 secondes pour décider, là où cinq ne suffisaient pas à LIRE la question : le compte à rebours n'existe pas pour aller vite mais pour qu'une exécution puisse être laissée sans surveillance, et trop court il fait l'inverse — la réponse vient par réflexe, ou un défaut que personne n'a lu s'applique. Une base restaurée porte le nom du fichier de sauvegarde, qui en porte déjà un parlant, plutôt que « test », sous lequel des migrations successives finissaient toutes sur un même nom. Le nom est assaini, puisqu'il finit dans un createdb, et borné à 41 caractères : le pilote ajoute « _neutralize_upgrade_18 » et PostgreSQL tronque à 63, ce qui ferait finir deux paliers sur le même nom. Un téléchargement distant garde celui que le serveur a donné
- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement
@@ -117,6 +125,11 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Corrigé
+- Le menu QEMU passe par le groupe « libvirt » plutôt que par sudo, qui n'ajoutait aucun droit et réclamait un mot de passe à chaque entrée ; l'appartenance se tranche en ESSAYANT, jamais en lisant /etc/group. L'URI libvirt est nommée explicitement : sans « --connect », un virsh non root vise « qemu:///session », un hyperviseur SÉPARÉ où aucune VM du système n'existe, et « list --all » y rend une liste vide sans erreur — l'URI par défaut de root masquait l'omission
+- Les outils système lancés depuis le menu n'héritent plus du venv en tête de leur PATH. Un outil écrit en Python et amorcé par « env python3 » démarrait dans un interpréteur privé des modules de la distribution et sortait sur « No module named 'gi' »
+- Accepter d'installer les paquets QEMU ne redémarre plus l'hôte sans demander : « --assume-yes » couvrait le gestionnaire de paquets, et la commande y ajoutait « --reboot-if-needed » en silence. Une seule constante servait l'invité jetable et le poste de travail
+- Trois écrans qui tombaient : les statistiques, où « datetime.datetime » n'existe pas puisque c'est la CLASSE qui est importée et où l'erreur emportait TODO entier ; une VM échouée dont la sortie montrait quatre lignes d'épilogue au lieu du message de l'outil ; et une clé i18n dupliquée qui écrasait un libellé du menu principal, la dernière définition gagnant dans un littéral de dict Python sans erreur ni avertissement
+- Une réduction mesure la place libre AVANT de proposer la sauvegarde, qui double la place occupée et avait OUI pour défaut : sur un disque presque plein, une réponse vide lançait une copie qui s'arrête à mi-course et laisse un « .bak » tronqué
- « odoo_bin.sh db --drop » échouait par AccessDenied à chaque palier de migration, et le clone butait ensuite sur « database already exists » : db_restore.py lit le config.conf du dépôt, y voit admin_passwd = admin et n'envoie donc aucun mot de passe maître, quand odoo_bin.sh ne passait pas de « -c », si bien qu'Odoo lisait ~/.odoorc et son mot de passe haché. ODOO_RC ferme cette couture en un point plutôt qu'à vingt sites d'appel, les versions 12 à 18 le lisant après « -c » et avant ~/.odoorc, donc un choix explicite l'emporte toujours
- La vue SQL account.root que crée Odoo 17 est retirée avant le chargement en 18, où le modèle porte _auto = False et _table_query = '0', si bien que son nom n'entre plus dans aucune requête et que le contrôle des tables manquantes l'ignore. La vue n'est pas seulement morte mais FAUSSE, bâtie sur la colonne code que l'ORM 18 n'écrit plus, et elle est l'unique épingle des deux colonnes héritées où database_cleanup échoue — aucun DROP COLUMN, OpenUpgrade les lisant encore ensuite
- La réparation de migration créait des listes de prix qui n'avaient pas lieu d'être, de deux façons. Elle interrogeait `env.user.has_group()`, qui répond oui dès que l'exécutant est membre du groupe — et la migration l'y ajoute en cours de route —, là où la case des réglages lit tout autre chose : ce que `base.group_user` IMPLIQUE. Décider sur l'exécutant créait une liste de prix dans une base dont la fonctionnalité est éteinte, et Odoo prévenait alors à chaque ouverture des réglages qu'il allait l'archiver. Et une liste de prix partagée entre sociétés, au `company_id` vide, ne comptait pas comme appartenant à la société : la réparation voyait une société sans liste et fabriquait un doublon vide à côté de l'existante, sur une base qui n'avait pourtant rien perdu. Les deux lisent désormais l'implication du groupe et le détecteur `pricelist_missing` du même outil, qui distingue déjà les deux cas ; le contrôle « restant de migration » posait la même mauvaise question et a été corrigé avec elles
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 64f174d..e79ca7f 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,12 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Added
+- 3D acceleration for QEMU VMs, ticked at creation and settable afterwards, even on a VM with NO virtual screen — `auto` never grants one there, abstaining rather than adding a video device nobody asked for, while an off-screen render or an emulator inside the VM wants exactly that. A render node can exist while EGL refuses to start on it: QEMU then rejects the domain and the VM stays unusable until someone undoes the setting, so creation falls back to software rendering and an existing VM is offered the removal. Inside the guest the render node is `root:render` at 0660 and the account was not in it, so every GL application fell back to software rendering although VIRGL negotiation had succeeded, with nothing to say so; `render` and `video` are now declared BEFORE use, an unknown group name making cloud-init create no account at all — no password, no SSH key, a VM that boots unreachable
+- A QEMU diagnostic report, written to one file to hand to someone who has no access to the machine: twenty-one read-only probes — host, hypervisor, GPU, tools present, storage — each time-bounded, since a command that hangs must not hold the report, and each section isolated, the file being written in one block at the end. It states the 3D condition of every VM from its PERSISTENT definition, where three values answer together and none alone: video type, `accel3d`, and the device libvirt pinned. That last one, an attribute added in libvirt 12.5.0 to keep the guest ABI stable across restarts, OUTRANKS `accel3d`: a VM first started without 3D keeps the non-GL device, and ticking the box afterwards writes an intent nothing applies. The report also offers the tools missing from it, showing the full command before asking, and the device list QEMU may open — libvirt adds the render node when the domain declares it, never a proprietary card's own nodes, which that stack opens too. It names the host, its paths and its addresses, and says so before it is shared
+- Recovering files from the disk of a VM that no longer boots, libguestfs mounting its qcow2 without it. Every command carries `--ro`, and that is what changes the manoeuvre: opening the disk of a running machine for writing corrupts its filesystem. Partitions are listed, then the directories to copy out, the `copy-out` commands being shown rather than guessed at
+- Development assistants installed INSIDE a VM at deployment: rtk, starship with its shell hook, and one agent — Claude Code or opencode. The box reveals the choice and the git identity, prefilled from the host, since that is what the VM already receives and an empty field would suggest none; what is typed wins, field by field. Every install is denied stdin and time-bounded: `|| true` covers failure, not WAITING, and an upstream installer asking a question would hang on a terminal-less SSH — hence `-y` for starship
+- A Git and Shell menu that installs what a checkout needs rather than printing a command to copy: the repository's git hooks, `merge.conflictStyle=zdiff3`, Starship, Claude Code, opencode, and the Claude Code plugins with an ERPLibre list. Three assistant commands are deployed from it — `/git_prepare_merge`, `/todo_plan_max`, `/todo_generate_code`. The missing tools of a safe shrink are installed across the four package families, where three separate pieces of code each knew a different subset. A binary posed in a HOME directory the shell's PATH does not always carry is found anyway, and the export line is written once
+- A deployment blocked by an orphan disk — the qcow2 an interrupted creation leaves behind, which `deploy_qemu` then refuses to overwrite — is offered its deletion, size and path shown, before the creation fails after having made you wait
- A `pre-commit` hook lists the comments worth re-reading in the files being staged, and never blocks: over the repository's own sources, 373 files yield 463 signals, and a blocking check at that scale gets uninstalled the following week. The tool behind it, `script/analyse/check_comment_hygiene.py`, reports two families of unequal certainty — identifying data, an address, an e-mail or an account path, which is a finding; and narrative, a witness marker, an absolute date or the first person, which is a signal to RE-READ, since it cannot know whether the sentence states a durable fact. It reads comments and docstrings, `#` lines and shell trailing comments alike, skips vendored code, and falls back on a line scan when a source will not parse, an empty report otherwise declaring clean a file it never read. Exit codes follow the repository convention: 0 nothing to report, 1 findings, 2 the tool failed
- The TODO menu shows the context an assistant is given, scattered as it is over six sources: instructions, rules, skills, deployed commands, git hooks and memory. Each deployed command is compared against its repository template, ignoring the git identity lines the deployment substitutes, so a stale copy shows up where a strict equality would declare them all stale. Nothing from `private/` is reported, not even a count: naming a file whose purpose is to hold what must not go out amounts to pointing at it
- Duplicating a database and neutralising it for good: duplication goes through Odoo's `exp_duplicate_database` rather than `CREATE DATABASE … TEMPLATE`, which copies the tables and nothing else. Only Odoo drops the source's open connections — one Odoo shell left open is enough for PostgreSQL to refuse — regenerates `database.uuid`, copies the filestore, and runs the `neutralize.sql` files of the installed modules. The repository's in-house modules obtained none of the four, and one of them opened a door: deleting every `ir.mail_server` makes Odoo fall back on the configuration file's `smtp_server`, where Odoo's own placeholder server exists precisely to plug that hole. Odoo's neutralisation begins at 16; from 12 to 15 the copy falls back on the repository's long-standing `update_prod_to_dev.sh`, which sets no `is_neutralized`, disables no cron and leaves the payment keys, but does remove the mail servers and lay down a development account. The route taken is ANNOUNCED at run time, a copy whose route is unknown being unjudgeable, and a missing script fails the copy rather than letting it come out raw while announcing itself neutralised
@@ -107,6 +113,8 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Changed
+- The name of a VM built from a rolling release drops the version segment: `latest` distinguishes no VM from another. A named version that coexists with others in the catalogue stays
+- Every menu entry carries an icon, ten menus having stayed bare, and the spacing follows the RENDERED width read from Unicode rather than guessed — two spaces after a one-column emoji, one after a wide one. The QEMU Manage section, grown too long to scan, is split into Manage, VM access and Troubleshoot
- Staging names the files, never `git add -A`. The sweep stages everything untracked, and this repository keeps two directories untracked ON PURPOSE: `private/`, the only place allowed to hold customer data, and `tasks/`, where the convention sends the investigation precisely because it is not versioned. It also swallows whatever else is in flight in the checkout, under a subject that does not cover it; `git add -p` stages the hunks when one file carries two subjects
- A countdown prompt gives 15 seconds to decide, where five were not enough to READ the question: the countdown exists so a run can be left unattended, not to go fast, and too short it does the opposite — the answer comes by reflex, or a default no one read is taken. A restored database is named after the backup file, which already carries a telling name, rather than « test », under which successive migrations all landed on one name. The name is sanitised, since it ends up in a createdb, and capped at 41 characters: the driver appends « _neutralize_upgrade_18 » and PostgreSQL truncates at 63, which would put two tiers on the same name. A remote download keeps the name the server gave
- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes
@@ -115,6 +123,11 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Fixed
+- The QEMU menu goes through the `libvirt` group rather than sudo, which added no right and asked for a password at every entry; membership is settled by TRYING, never by reading /etc/group. The libvirt URI is named explicitly: without `--connect`, a non-root virsh targets `qemu:///session`, a SEPARATE hypervisor where no system VM exists, and `list --all` returns an empty list with no error — root's default URI had masked the omission
+- System tools launched from the menu no longer inherit the venv at the head of their PATH. A Python tool bootstrapped by `env python3` started in an interpreter without the distribution's modules and died on `No module named 'gi'`
+- Accepting to install the QEMU packages no longer reboots the host without asking: `--assume-yes` covered the package manager, and the command silently added `--reboot-if-needed`. One constant served both the disposable guest and the workstation
+- Three screens that fell over: the statistics screen, where `datetime.datetime` does not exist since the CLASS is imported and the error took all of TODO with it; a failed VM whose output showed four lines of epilogue instead of the tool's own message; and a duplicate i18n key silently overwriting a main-menu label, the last definition winning in a Python dict literal with neither error nor warning
+- A shrink measures the free space BEFORE offering the backup, which doubles the space used and defaulted to YES: on an almost full disk, an empty answer started a copy that stopped halfway and left a truncated `.bak`
- `odoo_bin.sh db --drop` failed with AccessDenied at every migration tier, and the clone then hit « database already exists »: db_restore.py reads the repository's config.conf, sees admin_passwd = admin and therefore sends no master password, while odoo_bin.sh passed no « -c », so Odoo read ~/.odoorc and its hashed one. ODOO_RC closes that seam in one place instead of twenty call sites, versions 12 to 18 reading it after « -c » and before ~/.odoorc, so an explicit choice still wins
- The account.root SQL view Odoo 17 creates is dropped before the load into 18, where the model carries _auto = False and _table_query = '0', so its name enters no query and the missing-table check skips it. The view is not merely dead but WRONG, built on the code column the 18 ORM no longer writes, and it is the sole pin holding the two legacy columns database_cleanup fails on — no DROP COLUMN, OpenUpgrade still reading them afterwards
- The migration repair created pricelists that should not exist, in two ways. It asked `env.user.has_group()`, which says yes as soon as the caller belongs to the group — and the migration adds it along the way — where the settings checkbox reads something else entirely: what `base.group_user` IMPLIES. Deciding on the caller built a pricelist in a database whose feature is off, and Odoo then warned on every opening of the settings that it would archive it. And a pricelist shared across companies, with an empty `company_id`, did not count as belonging to the company: the repair saw a company without a list and made an empty duplicate beside the existing one, on a database that had lost nothing. Both now read the group implication and the tool's own `pricelist_missing` detector, which already tells the two cases apart; the migration-residue check asked the same wrong question and was corrected with them
diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py
index 52a96a7..36a562e 100755
--- a/script/qemu/deploy_qemu.py
+++ b/script/qemu/deploy_qemu.py
@@ -327,14 +327,10 @@ def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]:
mode = (mode or "auto").lower()
if mode == "off":
return False, ""
- if not screen:
- # Sans écran virtuel, la 3D n'a rien à accélérer. Le dire seulement
- # quand elle a été demandée explicitement.
- if mode == "on":
- return (
- False,
- " GPU : pas d'écran virtuel sur cette VM, 3D ignorée.",
- )
+ if not screen and mode != "on":
+ # Sans écran virtuel, « auto » s'abstient : une VM serveur n'a pas
+ # demandé de périphérique vidéo, et lui en poser un d'office change
+ # son matériel sans qu'on l'ait voulu.
return False, ""
if not node:
if mode == "on":
@@ -348,9 +344,36 @@ def gpu_decision(mode: str, node: str, screen: bool) -> tuple[bool, str]:
False,
" GPU : aucun sur l'hôte, rendu logiciel (virgl absent).",
)
+ if not screen:
+ # 3D demandée sur une VM sans console : le virtio-gpu est POSÉ quand
+ # même, et « egl-headless » n'ouvre aucun port — l'invité reçoit un
+ # périphérique DRM accéléré sans écran à regarder. C'est ce qui sert
+ # au rendu hors écran et à un émulateur qui tourne dans la VM.
+ return (
+ True,
+ f" GPU : 3D activée par {node} sans écran virtuel"
+ " (virtio-gpu accéléré, aucun port ouvert).",
+ )
return True, f" GPU : 3D activée par {node} (virtio-gpu + egl-headless)."
+# Ce que QEMU écrit quand le nœud de rendu existe mais qu'EGL n'y démarre
+# pas. Le fichier /dev/dri/renderD* est alors bien là — un GPU virtuel sans
+# pile EGL, un pilote sans GBM, une carte que Mesa ne sait pas ouvrir : la
+# présence du nœud ne prouve donc PAS que la 3D fonctionne, et rien ne le dit
+# avant que QEMU n'essaie.
+EGL_ECHEC = (
+ "eglInitialize failed",
+ "render node init failed",
+ "EGL_NOT_INITIALIZED",
+)
+
+
+def egl_failed(output: str) -> bool:
+ """La sortie de virt-install accuse-t-elle un EGL inutilisable ?"""
+ return any(marque in (output or "") for marque in EGL_ECHEC)
+
+
def gpu_apply(
video: list, mode: str, node: str, screen: bool
) -> tuple[list, list, str]:
@@ -663,15 +686,38 @@ class Runner:
self.dry_run = dry_run
def run(
- self, cmd: list[str], *, privileged: bool = False, check: bool = True
- ) -> None:
+ self,
+ cmd: list[str],
+ *,
+ privileged: bool = False,
+ check: bool = True,
+ capture: bool = False,
+ ):
+ """Lance la commande. `capture` rend (code, sortie) au lieu de sortir.
+
+ Sans `capture`, un échec termine le programme : c'est le comportement
+ voulu partout où il n'y a rien à rattraper. Avec, l'appelant décide —
+ seul l'appel qui SAIT réessayer autrement doit le demander.
+ """
if privileged and self.use_sudo:
cmd = ["sudo", *cmd]
printable = " ".join(cmd)
if self.dry_run:
print(f" [dry-run] {printable}")
- return
+ return (0, "") if capture else None
print(f" $ {printable}")
+ if capture:
+ # La sortie est réaffichée telle quelle : le journal garde tout,
+ # et l'appelant peut lire ce que l'outil a dit.
+ res = subprocess.run(
+ cmd,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ text=True,
+ )
+ if res.stdout:
+ print(res.stdout, end="")
+ return res.returncode, res.stdout or ""
try:
subprocess.run(cmd, check=check)
except subprocess.CalledProcessError as exc:
@@ -1610,7 +1656,35 @@ def host_timezone() -> str:
return "UTC"
-def user_groups(distro: str) -> str:
+# Groupes qui donnent accès au GPU DANS L'INVITÉ. Le nœud de rendu y
+# appartient à « root:render » en 0660, et le compte créé par cloud-init n'en
+# fait pas partie : toute application GL retombe alors sur le rendu logiciel,
+# alors même que la négociation VIRGL entre l'hôte et l'invité a réussi. Rien
+# ne le signale — le matériel virtuel est bien accéléré, seul l'accès manque.
+#
+# En session graphique locale, logind pose une ACL sur le nœud pour
+# l'utilisateur du siège actif et la question ne se pose pas. En SSH ou en
+# tty — le cas d'une VM de ce parc — personne ne la pose.
+GPU_GROUPS = ("render", "video")
+
+
+def gpu_group_block() -> list[str]:
+ """Bloc « groups: » qui CRÉE les groupes GPU avant leur usage.
+
+ « useradd -G » échoue sur un nom de groupe inconnu, et cloud-init ne crée
+ alors pas l'utilisateur du tout : ni mot de passe ni clé SSH, la VM démarre
+ et reste inaccessible. « render » est récent et manque des images les plus
+ anciennes, donc l'ajouter sans précaution rejouerait cette panne.
+
+ Le déclarer ici le rend certain d'exister : cloud-init crée les groupes
+ AVANT les comptes, et passe sans erreur sur ceux qui existent déjà. Le
+ groupe est retrouvé par NOM par les règles udev, donc un GID choisi par
+ cloud-init plutôt que par la distribution ne change rien.
+ """
+ return ["groups:"] + [f" - {nom}" for nom in GPU_GROUPS]
+
+
+def user_groups(distro: str, gpu: bool = False) -> str:
"""Groupes secondaires du compte créé par cloud-init.
Le nom du groupe d'administration change d'une famille à l'autre, et un
@@ -1625,10 +1699,14 @@ def user_groups(distro: str) -> str:
garantit « wheel » sur une image Minimal-VM — dans le doute on s'abstient
plutôt que de risquer un compte non créé."""
if distro in ("ubuntu", "debian"):
- return "users, sudo"
- if distro == "opensuse":
- return "users"
- return "users, wheel"
+ noms = ["users", "sudo"]
+ elif distro == "opensuse":
+ noms = ["users"]
+ else:
+ noms = ["users", "wheel"]
+ if gpu:
+ noms += list(GPU_GROUPS)
+ return ", ".join(noms)
# --------------------------------------------------------------------------- #
@@ -2224,9 +2302,12 @@ def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
]
if args.no_git_identity:
return files
+ # Ce que le formulaire a saisi PRIME sur l'identité de l'hôte, champ par
+ # champ : remplir le seul courriel ne doit pas effacer le nom. Vide, on
+ # retombe sur l'hôte, qui reste le comportement par défaut.
gitconfig = build_gitconfig(
- _git_global("user.name", home),
- _git_global("user.email", home),
+ getattr(args, "git_name", "") or _git_global("user.name", home),
+ getattr(args, "git_email", "") or _git_global("user.email", home),
editor,
)
if gitconfig:
@@ -2242,6 +2323,13 @@ def build_cloud_config(
"""Construit le contenu #cloud-config (user-data)."""
lines: list[str] = ["#cloud-config", f"hostname: {args.hostname}"]
+ # « off » est le seul refus explicite ; « auto » laisse l'hôte décider
+ # s'il accélère, mais l'invité porte un virtio-gpu dans les deux cas et
+ # l'appartenance aux groupes ne coûte rien quand elle ne sert pas.
+ gpu = (getattr(args, "gpu", "auto") or "auto").lower() != "off"
+ if gpu:
+ lines += gpu_group_block()
+
user_block = [
"users:",
f" - name: {args.user}",
@@ -2254,7 +2342,7 @@ def build_cloud_config(
# même écart, elles n'ont pas de groupe « sudo » mais « wheel ».
# Le privilège lui-même vient de la ligne « sudo: » ci-dessus, pas du
# groupe : celui-ci n'est qu'une commodité.
- f" groups: {user_groups(args.distro)}",
+ f" groups: {user_groups(args.distro, gpu)}",
" shell: /bin/bash",
" lock_passwd: false" if pw_hash else " lock_passwd: true",
]
@@ -2686,6 +2774,13 @@ def build_preseed(
f"echo '{user} ALL=(ALL) NOPASSWD:ALL' > /target/etc/sudoers.d/{user}",
f"chmod 440 /target/etc/sudoers.d/{user}",
]
+ # Accès au GPU, comme le cloud-config le donne aux autres distributions :
+ # sans ces groupes, toute application GL de l'invité retombe sur le rendu
+ # logiciel. « groupadd -f » ne fait rien si le groupe existe et ne rend
+ # jamais d'erreur, là où « usermod -aG » sur un nom inconnu échoue.
+ if (getattr(args, "gpu", "auto") or "auto").lower() != "off":
+ post += [f"in-target groupadd -f {nom}" for nom in GPU_GROUPS]
+ post.append(f"in-target usermod -aG {','.join(GPU_GROUPS)} {user}")
if ssh_keys:
post.append(f"mkdir -p /target/home/{user}/.ssh")
for key in ssh_keys:
@@ -2984,6 +3079,7 @@ def virt_install(
# comme l'émulateur Android qui tourne dedans — et c'est le défaut le plus
# coûteux qu'on puisse laisser en place sans le dire.
gpu_node = args.gpu_node or host_gpu_node()
+ video_sans_3d = list(video)
video, gpu_args, gpu_msg = gpu_apply(
video, args.gpu, gpu_node, graphics != "none"
)
@@ -3045,8 +3141,6 @@ def virt_install(
osinfo,
"--network",
args.network,
- "--graphics",
- graphics,
"--console",
# Journal de console pour la voie installateur. Une console « pty »
# seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une
@@ -3064,6 +3158,13 @@ def virt_install(
"--channel",
"unix,target.type=virtio,target.name=org.qemu.guest_agent.0",
]
+ # « --graphics none » dit « aucun affichage » : le poser à côté d'un
+ # « egl-headless », qui EST un affichage, se contredit. Sur une VM sans
+ # console dont la 3D est demandée, egl-headless reste donc le seul.
+ graphics_omis = bool(gpu_args) and graphics == "none"
+ if not graphics_omis:
+ cmd += ["--graphics", graphics]
+ i_gpu = len(cmd)
cmd += video + gpu_args
if args.arch == "s390x":
# s390x (IBM Z) : machine s390-ccw-virtio, amorçage IPL/zipl depuis le
@@ -3128,7 +3229,49 @@ def virt_install(
f"XDG_CACHE_HOME={cache_dir}",
f"HOME={cache_dir}",
]
- runner.run(log_env + cmd, privileged=True)
+ if not gpu_args:
+ runner.run(log_env + cmd, privileged=True)
+ return
+ # La 3D est le SEUL argument dont l'échec se rattrape : le nœud de rendu
+ # existe, mais QEMU n'arrive pas à y démarrer EGL. Rien ne permet de le
+ # savoir avant d'essayer, donc on essaie, et on retire la 3D si c'est
+ # elle qui a fait tomber le domaine.
+ code, sortie = runner.run(log_env + cmd, privileged=True, capture=True)
+ if code == 0:
+ return
+ if not egl_failed(sortie):
+ sys.exit(
+ f"\nÉchec de la commande (code {code}) :\n"
+ f" {' '.join(log_env + cmd)}"
+ )
+ # Le repli vaut AUSSI pour « --gpu on ». Une VM qu'on n'a pas est pire
+ # qu'une VM sans 3D, et le repli n'est pas silencieux : il le dit, en
+ # nommant ce qui a été demandé et ce qui a été obtenu.
+ demande = (args.gpu or "auto").lower() == "on"
+ print(
+ f"\n ⚠ EGL ne démarre pas sur {gpu_node} :"
+ f" {'la 3D DEMANDÉE est retirée' if demande else 'la 3D est retirée'}"
+ "\n et la VM recréée en rendu logiciel."
+ " « --gpu off » évite cet essai."
+ )
+ # Rendre l'affichage écarté plus haut : sans lui, la VM repartirait sans
+ # « --graphics none », donc avec le défaut de virt-install, qui n'est pas
+ # ce qu'on avait demandé.
+ rendu = [] if not graphics_omis else ["--graphics", graphics]
+ cmd_sans_3d = (
+ cmd[:i_gpu]
+ + rendu
+ + video_sans_3d
+ + cmd[i_gpu + len(video) + len(gpu_args) :]
+ )
+ # Le domaine défini par l'essai raté doit partir : sans quoi virt-install
+ # refuse le nom, et la VM resterait celle qui ne démarre pas.
+ runner.run(
+ ["virsh", "--connect", LIBVIRT_URI, "undefine", args.name, "--nvram"],
+ privileged=True,
+ check=False,
+ )
+ runner.run(log_env + cmd_sans_3d, privileged=True)
def watch_and_restart(name: str, runner: Runner) -> None:
@@ -3463,6 +3606,16 @@ def build_parser() -> argparse.ArgumentParser:
"dans le guide pour la mettre à jour. Vide : le guide s'arrête à "
"« git pull » plutôt que d'annoncer une cible qui n'est pas la bonne.",
)
+ g_cloud.add_argument(
+ "--git-name",
+ default="",
+ help="Nom pour le ~/.gitconfig de la VM (défaut : celui de l'hôte).",
+ )
+ g_cloud.add_argument(
+ "--git-email",
+ default="",
+ help="Courriel pour le ~/.gitconfig de la VM (défaut : l'hôte).",
+ )
g_cloud.add_argument(
"--no-git-identity",
action="store_true",
diff --git a/script/todo/deploy_form_lib.py b/script/todo/deploy_form_lib.py
index 793c52a..638ef59 100644
--- a/script/todo/deploy_form_lib.py
+++ b/script/todo/deploy_form_lib.py
@@ -393,6 +393,14 @@ def build_spec(vms, domains, form):
# ERPLibre se suit aussi (cloud-init, puis relevé système). Absent de
# cette assemblée, le choix du formulaire n'atteignait jamais la spec.
"monitor": form.get("monitor", True),
+ # Au niveau du déploiement : la 3D est une propriété du matériel de la
+ # VM, pas de ce qu'on installe dedans.
+ "gpu3d": form.get("gpu3d", False),
+ # Au niveau du déploiement : l'agent choisi et l'identité git valent
+ # pour tout le parc, comme le fuseau ou le magasin d'applications.
+ "ai_agent": form.get("ai_agent", ""),
+ "git_name": form.get("git_name", ""),
+ "git_email": form.get("git_email", ""),
"add_ssh_config": form["add_ssh_config"],
"parallelism": form["parallelism"],
}
diff --git a/script/todo/dev_tools.py b/script/todo/dev_tools.py
new file mode 100644
index 0000000..8d12450
--- /dev/null
+++ b/script/todo/dev_tools.py
@@ -0,0 +1,56 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Installateurs amont des outils de développement.
+
+Les mêmes outils se posent à DEUX endroits : sur l'hôte par le menu Shell,
+et dans une VM par le déploiement QEMU. Une seule table les décrit, parce
+que deux copies d'une URL dérivent dès que l'amont en change une, et que la
+seconde copie est celle qu'on oublie.
+
+Chacun est un installateur amont plutôt qu'un paquet : aucun n'est présent
+dans les dépôts des quatre familles que supporte ERPLibre, et celui qui l'est
+y traîne d'une version.
+"""
+
+# Proxy CLI qui réduit la consommation de tokens des assistants.
+RTK_UPSTREAM = (
+ "curl -fsSL https://raw.githubusercontent.com/rtk-ai/rtk/"
+ "refs/heads/master/install.sh | sh"
+)
+
+# L'installateur amont pose un binaire statique. Il sert de recours parce
+# que le paquet manque d'une partie des dépôts des plateformes supportées.
+STARSHIP_URL = "https://starship.rs/install/install.sh"
+STARSHIP_UPSTREAM = f"curl -sS {STARSHIP_URL} | sh"
+
+# Sans terminal — une pose par SSH dans une VM — l'installateur demande une
+# confirmation que personne ne donnera. « -y » la donne d'avance.
+STARSHIP_UPSTREAM_YES = f"curl -sS {STARSHIP_URL} | sh -s -- -y"
+
+# Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de
+# fichier : starship compose le prompt et doit passer après tout ce qui y
+# touche.
+STARSHIP_LINE = {
+ "bash": 'eval "$(starship init bash)"',
+ "zsh": 'eval "$(starship init zsh)"',
+ "fish": "starship init fish | source",
+}
+
+# Les assistants posés par un installateur amont : le nom du binaire mène
+# à (commande, répertoire d'installation). Le répertoire sert à garantir
+# le PATH — un binaire posé hors des chemins du shell reste introuvable.
+AGENTS = {
+ "claude": (
+ "curl -fsSL https://claude.ai/install.sh | bash",
+ "~/.local/bin",
+ ),
+ "opencode": (
+ "curl -fsSL https://opencode.ai/install | bash",
+ "~/.opencode/bin",
+ ),
+}
+
+# L'agent posé quand rien n'est choisi. Le premier de la table ferait
+# dépendre le défaut de l'ordre d'écriture d'un dictionnaire.
+AGENT_DEFAUT = "claude"
diff --git a/script/todo/qemu_access.py b/script/todo/qemu_access.py
index e0782f9..08f21d4 100644
--- a/script/todo/qemu_access.py
+++ b/script/todo/qemu_access.py
@@ -11,6 +11,7 @@ import subprocess
import time
from script.todo import todo_install
+from script.todo.qemu_privilege import sudo_prefix
from script.todo.todo_i18n import t
@@ -730,7 +731,10 @@ class QemuAccessMixin:
port = self._qemu_vnc_port(domain, jump)
# Les commandes de réparation se lancent SUR l'hyperviseur : le préfixe
# évite de les copier sur la mauvaise machine, l'erreur naturelle ici.
+ # Sur l'hyperviseur DISTANT, on ne peut pas sonder ses droits d'ici :
+ # « sudo » y reste écrit. En local, le sondage tranche.
pre = f"ssh {jump} " if jump else ""
+ su = "sudo " if jump else sudo_prefix()
if not port and self._hypervisor_is_proxmox(jump):
self._pve_console_hint(jump, domain)
return
@@ -738,17 +742,17 @@ class QemuAccessMixin:
print(f"\n ⚠ {t('This VM exposes no VNC port.')}")
print(f" {t('Its display is likely spice with listen=none:')}")
print(
- f" {pre}sudo virsh dumpxml {domain} | grep -A2 '"
)
- print(f" {pre}sudo virsh start {domain}")
+ print(f" {pre}{su}virsh start {domain}")
print(f"\n {t('New VMs get this by default; see deploy_qemu.')}")
return
if jump:
diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py
index 00ef384..1d0fb1c 100644
--- a/script/todo/qemu_deploy.py
+++ b/script/todo/qemu_deploy.py
@@ -13,6 +13,7 @@ import subprocess
import time
from script.todo import todo_prefs
+from script.todo.qemu_privilege import sudo_prefix
from script.todo.todo_i18n import get_lang, t
@@ -28,6 +29,7 @@ class QemuDeployMixin:
python_provider="",
app_store="deb",
tools=(),
+ ai_agent="",
):
"""Script exécuté DANS la VM. `branch` à None n'installe QUE le bureau
— le choix graphique ne dépend pas d'ERPLibre, et une VM peut être
@@ -75,7 +77,7 @@ class QemuDeployMixin:
+ self._qemu_cloud_init_wait()
+ self._qemu_no_auto_upgrade(prod, app_store)
+ self._qemu_desktop_remote_cmd(desktop, app_store)
- + self._qemu_tools_remote_cmd(tools, prod)
+ + self._qemu_tools_remote_cmd(tools, prod, ai_agent=ai_agent)
+ note
)
if not final_cmd:
@@ -102,7 +104,9 @@ class QemuDeployMixin:
# apt pendant l'installation. En PROD on ne touche à rien : les
# correctifs de sécurité automatiques doivent rester actifs.
no_auto_upgrade = self._qemu_no_auto_upgrade(prod, app_store)
- tools_cmd = self._qemu_tools_remote_cmd(tools, prod, "before")
+ tools_cmd = self._qemu_tools_remote_cmd(
+ tools, prod, "before", ai_agent
+ )
# La compilation mobile vient APRÈS l'installation : elle a besoin du
# dépôt, du venv d'outils qui synchronise le manifeste, et de node que
# « make install_os » installe. Liée par « && » et NON gardée, pour que
@@ -276,6 +280,7 @@ class QemuDeployMixin:
vm_tools=(),
pve=None,
meta=None,
+ ai_agent="",
):
"""Lance l'install ERPLibre en parallèle DÉTACHÉE sur les VM et ouvre
le dashboard Textual. Quitter le dashboard n'arrête pas les installs.
@@ -313,6 +318,7 @@ class QemuDeployMixin:
"" if desk_map else desktop,
python_provider,
app_store,
+ ai_agent=ai_agent,
)
try:
mod = self._qemu_import_module()
@@ -368,6 +374,7 @@ class QemuDeployMixin:
python_provider,
app_store,
self._qemu_tools_for(vm_tools, a, vm_desktop, d),
+ ai_agent,
)
vms.append(entry)
else:
@@ -414,6 +421,7 @@ class QemuDeployMixin:
python_provider="",
app_store="deb",
vm_tools=(),
+ ai_agent="",
):
"""Clone ERPLibre (branche donnée) dans la VM puis exécute la commande
d'install du profil choisi (streamé). `ip` : IP déjà résolue ;
@@ -454,6 +462,7 @@ class QemuDeployMixin:
self._qemu_tools_for(
vm_tools, vm_arch or "amd64", desktop, vm_distro or ""
),
+ ai_agent,
)
ssh_opts = (
"-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null "
@@ -781,6 +790,39 @@ class QemuDeployMixin:
orphans.append((name, path))
return orphans
+ def _qemu_offer_orphan_removal(self, names):
+ """Propose d'effacer les qcow2 restés seuls. Rend False si on renonce.
+
+ Un disque sans VM définie vient d'une création interrompue : la VM
+ n'a jamais démarré, et le fichier ne porte donc rien. Il est tout de
+ même PROPOSÉ et non effacé d'office — le même nom peut désigner le
+ disque d'une VM retirée à la main, dont on voulait garder les données.
+
+ Sans cet effacement, deploy_qemu refuse d'écraser et la création
+ échoue, après avoir fait attendre.
+ """
+ orphans = self._qemu_orphan_disks(names)
+ if not orphans:
+ return True
+ items = []
+ for _name, path in orphans:
+ try:
+ items.append((os.path.getsize(path), path))
+ except OSError:
+ items.append((0, path))
+ self._cleanup_delete_files(
+ t("Orphan disks that would fail the deployment"),
+ items,
+ t("Delete them and continue? (y/N): "),
+ )
+ restants = self._qemu_orphan_disks(names)
+ if not restants:
+ return True
+ print(f"\n⚠ {t('Kept - the deployment of these VMs will FAIL:')}")
+ for name, _path in restants:
+ print(f" {name}")
+ return self._is_yes(input(t("Continue anyway? (y/N): ")))
+
def _qemu_confirm_collisions(self, existing, pending_names):
"""Signale les noms qui heurtent l'existant, et demande confirmation.
@@ -792,17 +834,13 @@ class QemuDeployMixin:
orphans = self._qemu_orphan_disks(pending_names)
if not existing and not orphans:
return True
- print(f"\n⚠ {t('Name collisions detected')} :")
- skipped = t("VM already defined - SKIPPED, nothing overwritten")
- for name in existing:
- print(f" {name:<28.28} {skipped}")
- for name, path in orphans:
- print(
- f" {name:<28.28} "
- f"{t('disk present without VM - deployment will FAIL')}"
- )
- print(f" {'':<28} {path}")
- print(f" {'':<28} {t('Remove it by hand, or rename the VM.')}")
+ if existing:
+ print(f"\n⚠ {t('Name collisions detected')} :")
+ skipped = t("VM already defined - SKIPPED, nothing overwritten")
+ for name in existing:
+ print(f" {name:<28.28} {skipped}")
+ if orphans:
+ return self._qemu_offer_orphan_removal(pending_names)
return self._is_yes(
input(f"{t('Continue despite these collisions? (y/N): ')}")
)
@@ -945,6 +983,9 @@ class QemuDeployMixin:
prod=False,
install_cmd="",
vm_tools=(),
+ gpu3d=False,
+ git_name="",
+ git_email="",
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@@ -980,6 +1021,17 @@ class QemuDeployMixin:
parts += ["--locale", locale]
if desktop:
parts.append("--desktop")
+ if gpu3d:
+ # « on » et non « auto » : auto s'abstient sur une VM sans écran,
+ # or c'est précisément ce que la case permet de demander.
+ parts += ["--gpu", "on"]
+ # L'identité git de la VM. Sans ces options, deploy_qemu recopie celle
+ # de l'HÔTE : le formulaire la montre et permet de la changer, il ne
+ # la remplace pas par du vide.
+ if git_name:
+ parts += ["--git-name", git_name]
+ if git_email:
+ parts += ["--git-email", git_email]
# Guide affiché à la connexion SSH de la VM : dans la langue du menu, et
# avec la section ERPLibre seulement là où ERPLibre sera installé — une
# VM déployée nue n'annonce pas un dépôt qui n'existe pas.
@@ -1051,6 +1103,9 @@ class QemuDeployMixin:
vm.get("install_cmd") or (install or {}).get("cmd") or ""
),
vm_tools=spec.get("vm_tools") or (),
+ gpu3d=bool(spec.get("gpu3d")),
+ git_name=spec.get("git_name") or "",
+ git_email=spec.get("git_email") or "",
)
def _qemu_arches_for(self, distro, arch):
@@ -1332,6 +1387,15 @@ class QemuDeployMixin:
if spec is None:
return
if spec: # None = annulé, {} = repli sur la CLI
+ # Le formulaire signale les disques orphelins mais ne peut pas
+ # les effacer : le faire demande root, et une invite de mot de
+ # passe dans une application plein écran n'a nulle part où
+ # s'afficher. La proposition vient donc ici, terminal rendu.
+ if not self._qemu_offer_orphan_removal(
+ [vm["name"] for vm in spec.get("vms") or []]
+ ):
+ print(t("Cancelled."))
+ return
self._qemu_run_spec(spec)
return
@@ -1658,6 +1722,35 @@ class QemuDeployMixin:
picked.append(key)
return tuple(picked)
+ def _qemu_ask_ai_tools(self, vm_tools):
+ """(agent, nom, courriel) — rien à poser si l'outil n'est pas coché.
+
+ Le nom et le courriel sont proposés avec l'identité de l'HÔTE, qui
+ est ce que la VM reçoit aujourd'hui : une réponse vide la garde. Sans
+ ce défaut affiché, un champ vide se lirait comme une identité absente
+ et inviterait à la ressaisir pour rien.
+ """
+ from script.todo import dev_tools
+
+ if "aidev" not in (vm_tools or ()):
+ return "", "", ""
+ noms = list(dev_tools.AGENTS)
+ print(f" {t('AI coding tools')} :")
+ for i, nom in enumerate(noms, 1):
+ marque = " ←" if nom == dev_tools.AGENT_DEFAUT else ""
+ print(f" [{i}] {nom}{marque}")
+ rep = input(" " + t("Choice: ")).strip()
+ agent = (
+ noms[int(rep) - 1]
+ if rep.isdigit() and 1 <= int(rep) <= len(noms)
+ else dev_tools.AGENT_DEFAUT
+ )
+ hote_nom = self._qemu_host_git("user.name")
+ hote_mail = self._qemu_host_git("user.email")
+ nom = input(f" {t('Name for git')} [{hote_nom}] : ").strip()
+ mail = input(f" {t('Email for git')} [{hote_mail}] : ").strip()
+ return agent, nom, mail
+
def _qemu_ask_python_provider(self, arches):
"""mise (CPython précompilé) ou pyenv (compilation).
@@ -1806,6 +1899,18 @@ class QemuDeployMixin:
input(f"{t('Watch the VMs start (no install)')} ? (O/n) : ")
)
+ # Posée même sans bureau : une VM sans console peut vouloir un
+ # virtio-gpu accéléré, et c'est ce que « auto » n'accorde jamais.
+ gpu3d = self._is_yes(
+ input(
+ t("3D acceleration (host GPU), even without a screen? (y/N): ")
+ )
+ )
+
+ # Ces trois réponses n'ont d'objet que si l'outil est coché : les
+ # poser toujours ferait trois questions de plus à qui n'en veut pas.
+ ai_agent, git_name, git_email = self._qemu_ask_ai_tools(vm_tools)
+
add_ssh_config = self._is_yes_default_yes(
input(t("Add each VM to ~/.ssh/config? (Y/n): "))
)
@@ -1868,6 +1973,10 @@ class QemuDeployMixin:
# Au niveau du déploiement : le suivi survit à une installation
# décochée (voir _qemu_run_spec).
"monitor": monitor,
+ "gpu3d": gpu3d,
+ "ai_agent": ai_agent,
+ "git_name": git_name,
+ "git_email": git_email,
"add_ssh_config": add_ssh_config,
"parallelism": parallelism,
}
@@ -1905,11 +2014,49 @@ class QemuDeployMixin:
f"\n[{done}/{len(jobs)}] {mark} [{jid}] {jname} "
f"(rc={rc}, {self._fmt_dur(secs)})"
)
- for line in [ln for ln in out.strip().splitlines() if ln][-4:]:
+ lignes = [ln for ln in out.strip().splitlines() if ln]
+ # Une VM qui réussit n'a rien à raconter ; une qui échoue a
+ # UNE ligne qui compte, et elle est écrite par l'outil, pas
+ # par nous. Quatre lignes ne suffisent pas à l'atteindre :
+ # l'épilogue « Échec de la commande » et sa ligne de commande
+ # les occupent, et le message de virt-install tombe juste
+ # au-dessus de la fenêtre.
+ for line in lignes[-4:] if rc == 0 else lignes[-30:]:
print(f" {line}")
+ if rc != 0:
+ chemin = self._qemu_save_failure_log(jname, out)
+ if chemin:
+ print(f" {t('Full output:')} {chemin}")
+ # La 3D est décidée DANS deploy_qemu.py, pas dans l'argv :
+ # sa présence se lit sur la commande que le journal a
+ # rapportée. Le menu n'expose pas « --gpu off », donc la
+ # seule issue depuis ici est de la nommer.
+ if "accel3d=on" in out or "egl-headless" in out:
+ print(f" {t('3D was on; retry without it:')}")
+ print(" ./script/qemu/deploy_qemu.py --gpu off …")
outcome.append((jname, rc, out, secs))
return outcome
+ @staticmethod
+ def _qemu_save_failure_log(name, out):
+ """Écrit la sortie complète d'une création ratée. Rend le chemin.
+
+ L'appelant jette `out` après la boucle : sans ce fichier, l'unique
+ trace d'un échec est ce qui a défilé à l'écran. Rend None si l'écriture
+ échoue — perdre le journal ne doit pas faire perdre le déploiement.
+ """
+ try:
+ from script.todo.qemu_install_monitor import session_dir
+
+ sur = "".join(
+ c if c.isalnum() or c in "-_." else "_" for c in name
+ )
+ chemin = session_dir() / f"{sur}-create.log"
+ chemin.write_text(out, encoding="utf-8", errors="replace")
+ return chemin
+ except Exception:
+ return None
+
def _qemu_deploy_jobs_tui(self, jobs, workers):
"""Même chose, en blocs repliables Textual. Renvoie None si textual
manque, pour que l'appelant retombe sur la sortie texte."""
@@ -1948,6 +2095,7 @@ class QemuDeployMixin:
desktop = next((d for d in desktop_map.values() if d), "")
python_provider = spec.get("python_provider") or ""
app_store = spec.get("app_store") or "deb"
+ ai_agent = spec.get("ai_agent") or ""
# Outils de développement : cochés une fois pour tout le parc, puis
# filtrés machine par machine (architecture, saveur de bureau).
vm_tools = tuple(spec.get("vm_tools") or ())
@@ -2049,6 +2197,7 @@ class QemuDeployMixin:
python_provider=python_provider,
app_store=app_store,
vm_tools=vm_tools,
+ ai_agent=ai_agent,
)
elif install:
print(
@@ -2067,6 +2216,7 @@ class QemuDeployMixin:
python_provider=python_provider,
app_store=app_store,
vm_tools=vm_tools,
+ ai_agent=ai_agent,
)
# Sommaire TOTAL (déploiement + résolution IP + ssh_config + install
@@ -2083,4 +2233,4 @@ class QemuDeployMixin:
print(f"{'═' * 60}")
print(f"\n✅ {t('ERPLibre infra deployment done.')}")
print(f" {t('Default login:')} erplibre / erplibre")
- print(f" {t('Manage with:')} sudo virsh list --all")
+ print(f" {t('Manage with:')} {sudo_prefix()}virsh list --all")
diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py
index 15fd899..72b4208 100644
--- a/script/todo/qemu_deploy_form.py
+++ b/script/todo/qemu_deploy_form.py
@@ -339,6 +339,42 @@ def run_deploy_form(ctx, run_app: bool = True):
value=defaults.get("monitor", True),
id="f_monitor",
)
+ # Indépendante du type de VM : une machine sans console
+ # peut vouloir un virtio-gpu accéléré — rendu hors écran,
+ # ou émulateur qui tourne dedans. Sans écran,
+ # « egl-headless » n'ouvre aucun port.
+ yield Checkbox(
+ t("3D acceleration (host GPU), even without a screen"),
+ value=defaults.get("gpu3d", False),
+ id="f_gpu3d",
+ )
+ # Révélés par la case « AI coding tools » du bloc des
+ # outils : sans elle, ni l'agent ni l'identité git n'ont
+ # d'objet, et trois widgets de plus encombrent un écran
+ # déjà dense. Le nom et le courriel sont pré-remplis avec
+ # l'identité de l'HÔTE — c'est ce que la VM reçoit
+ # aujourd'hui, et un champ vide la ferait croire absente.
+ yield Static(
+ f" {t('AI coding tools')}",
+ id="t_ai",
+ classes="grouptitle",
+ )
+ yield Select(
+ [("Claude Code", "claude"), ("opencode", "opencode")],
+ value=defaults.get("ai_agent") or "claude",
+ allow_blank=False,
+ id="f_ai_agent",
+ )
+ yield Input(
+ value=defaults.get("git_name", ""),
+ placeholder=t("Name for git"),
+ id="f_git_name",
+ )
+ yield Input(
+ value=defaults.get("git_email", ""),
+ placeholder=t("Email for git"),
+ id="f_git_email",
+ )
# Le parallélisme reste dans « Déploiement » : c'est le
# nombre de VM menées de front, pas une option
# d'installation.
@@ -367,10 +403,25 @@ def run_deploy_form(ctx, run_app: bool = True):
yield Static("", id="totals")
yield Footer()
+ # Les widgets que la case « AI coding tools » découvre.
+ _AI_WIDGETS = ("#t_ai", "#f_ai_agent", "#f_git_name", "#f_git_email")
+
+ def _sync_ai(self) -> None:
+ """Montre ou cache le bloc IA selon la case des outils.
+
+ Cacher plutôt que griser : un champ grisé occupe la place et se
+ lit comme un réglage qu'on aurait le droit de changer."""
+ case = self.query("#f_tool_aidev")
+ vu = bool(case) and bool(case.first(Checkbox).value)
+ for sel in self._AI_WIDGETS:
+ for widget in self.query(sel):
+ widget.display = vu
+
def on_mount(self) -> None:
self.title = t("Deploy ERPLibre VM(s)!")
self._reload_catalog(first_load=True)
self._sync_install_deps()
+ self._sync_ai()
# -- catalogue et recalcul ------------------------------------- #
def _entries(self):
@@ -871,6 +922,9 @@ def run_deploy_form(ctx, run_app: bool = True):
self._recompute() # le disque annoncé inclut le +5 G ERPLibre
elif event.checkbox.id == "f_par_all":
self.query_one("#f_par", Select).disabled = event.value
+ elif event.checkbox.id == "f_tool_aidev":
+ self._sync_ai()
+ self._recompute()
elif str(event.checkbox.id or "").startswith("f_tool_"):
# Un IDE de plus, c'est un disque plus grand : le plan doit le
# montrer AVANT de déployer, pas après une heure d'installation.
@@ -911,6 +965,12 @@ def run_deploy_form(ctx, run_app: bool = True):
# l'installation : décocher ERPLibre emportait la case avec
# elle, et le tableau de bord ne s'ouvrait plus du tout.
"monitor": self.query_one("#f_monitor", Checkbox).value,
+ "gpu3d": self.query_one("#f_gpu3d", Checkbox).value,
+ "ai_agent": self.query_one("#f_ai_agent", Select).value,
+ "git_name": self.query_one("#f_git_name", Input).value.strip(),
+ "git_email": self.query_one(
+ "#f_git_email", Input
+ ).value.strip(),
"res_label": (
t("custom")
if self.profile == "custom"
diff --git a/script/todo/qemu_hardware.py b/script/todo/qemu_hardware.py
index 700cfc4..3d5ca13 100644
--- a/script/todo/qemu_hardware.py
+++ b/script/todo/qemu_hardware.py
@@ -144,6 +144,9 @@ def hw_state(xml: str, autostart=None) -> dict:
"max_mem_mib": 0,
"video": "",
"accel3d": False,
+ # Device QEMU figé par libvirt (attribut « device » du ,
+ # depuis libvirt 12.5.0). Vide sur les versions antérieures.
+ "video_device": "",
"egl": False,
"render": "",
"screen": False,
@@ -182,6 +185,7 @@ def hw_state(xml: str, autostart=None) -> dict:
state["heads"] = int(model.get("heads") or 1)
except ValueError:
state["heads"] = 1
+ state["video_device"] = model.get("device") or ""
accel = model.find("acceleration")
state["accel3d"] = accel is not None and accel.get("accel3d") == "yes"
for graphics in root.findall("./devices/graphics"):
@@ -197,6 +201,27 @@ def hw_state(xml: str, autostart=None) -> dict:
return state
+def pin_defeats_3d(state) -> bool:
+ """L'ABI figée annule-t-elle la 3D pourtant demandée ?
+
+ Depuis libvirt 12.5.0, le vidéo porte un attribut « device »
+ qui GRAVE le device QEMU retenu — « virtio-vga », « virtio-vga-gl »,
+ et leurs équivalents sans VGA. Il existe pour tenir l'ABI de l'invité
+ stable d'un démarrage à l'autre, et il l'emporte sur « accel3d ».
+
+ Le mode de défaillance qu'il produit : une VM démarrée une première
+ fois sans 3D — parce que le module GL manquait, ou parce que l'option
+ n'était pas cochée — garde le device non accéléré. Cocher la 3D
+ ensuite écrit « accel3d=yes » et ne change RIEN à ce que QEMU reçoit.
+ La définition et la ligne de commande se contredisent alors sans que
+ rien ne le signale : seul le suffixe « -gl » du device dit la vérité.
+ """
+ fige = state.get("video_device") or ""
+ return (
+ bool(state.get("accel3d")) and bool(fige) and not fige.endswith("-gl")
+ )
+
+
def net_token(kind: str, source) -> str:
"""Identité du réseau d'une interface : « network:default », « bridge:br0 ».
@@ -486,7 +511,13 @@ def hw_summary(state: dict) -> str:
bits = [f"{state.get('vcpus') or '?'} vCPU", fmt_mib(state.get("mem_mib"))]
if state.get("accel3d") or state.get("render"):
node = state.get("render") or "?"
- bits.append(f"3D {node.rsplit('/', 1)[-1]}")
+ marque = f"3D {node.rsplit('/', 1)[-1]}"
+ # Une VM dont l'ABI est figée sur un device sans GL tourne SANS 3D,
+ # quoi que dise « accel3d ». Afficher « 3D » tout court y ferait
+ # croire l'inverse, et c'est la ligne sur laquelle on décide.
+ if pin_defeats_3d(state):
+ marque += f" ⚠ {t('frozen on')} {state['video_device']}"
+ bits.append(marque)
elif state.get("screen"):
bits.append(t("software rendering"))
if (state.get("heads") or 1) > 1:
diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py
index f4c419c..24e9156 100644
--- a/script/todo/qemu_install.py
+++ b/script/todo/qemu_install.py
@@ -4,7 +4,9 @@
"""Menu QEMU/KVM : ce qui s'installe DANS la VM.\n\nCe fichier ne cr\u00e9e aucune VM : il fabrique les commandes distantes qu'on y\nex\u00e9cutera. Profils ERPLibre et service Odoo, bureau GNOME et ses saveurs,\nmagasins d'applications, fuseaux, miroirs de paquets, et les outils de\nd\u00e9veloppement (PyCharm, Android Studio, extensions GNOME, compilation mobile,\nAVD, Forgejo).\n\nFronti\u00e8re claire : ici on \u00e9crit du shell destin\u00e9 \u00e0 l'invit\u00e9 ; dans\nqemu_deploy.py on d\u00e9cide QUELLES VM le recevront."""
import re
+import shlex
+from script.todo import dev_tools
from script.todo.todo_i18n import t
@@ -843,6 +845,26 @@ class QemuInstallMixin:
#
# Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
# l'émulateur lui-même.
+ # Ni bureau ni famille de paquets : les quatre outils sont des
+ # installateurs amont, aucun n'est dans les dépôts des distributions
+ # supportées. Une VM serveur les prend donc aussi bien qu'une VM
+ # graphique — c'est en SSH qu'on s'en sert.
+ #
+ # Disque : les binaires sont petits (rtk et starship sont statiques,
+ # l'agent est un bundle node) ; la marge couvre leurs caches.
+ "aidev": {
+ "label": "AI coding tools",
+ "hint": "rtk, starship, and one agent",
+ "disk_gb": 2,
+ "arches": (),
+ "desktops": (),
+ "needs_desktop": False,
+ "families": (),
+ # AVANT le clone : chaque outil s'y garde lui-même, et aucun ne
+ # doit faire échouer l'installation d'ERPLibre pour un curl qui
+ # ne répond pas.
+ "phase": "before",
+ },
"avd": {
"label": "Android emulator (Pixel)",
"hint": "AVD viewable over ssh -X",
@@ -909,9 +931,31 @@ class QemuInstallMixin:
"add_ssh_config": True,
"monitor": True,
"prod": False,
+ # L'identité git que la VM reçoit AUJOURD'HUI, celle de
+ # l'hôte : les champs la montrent plutôt que de s'ouvrir
+ # vides, ce qui la ferait croire absente. Les laisser vides
+ # garde ce comportement, les modifier le remplace.
+ "ai_agent": dev_tools.AGENT_DEFAUT,
+ "git_name": self._qemu_host_git("user.name"),
+ "git_email": self._qemu_host_git("user.email"),
},
}
+ def _qemu_host_git(self, cle):
+ """Valeur globale « git config » de l'hôte, ou ''.
+
+ Passe par deploy_qemu, seule autorité sur cette lecture : git accepte
+ DEUX emplacements pour sa configuration globale et le script sait
+ lequel interroger. Sans module importable on ne devine pas — un champ
+ vide reprend l'identité de l'hôte au déploiement, ce qui est déjà le
+ comportement par défaut.
+ """
+ try:
+ mod = self._qemu_import_module()
+ return mod._git_global(cle, mod.invoking_home()) or ""
+ except Exception:
+ return ""
+
@classmethod
def _qemu_vm_tool_choices(cls):
"""[(clé, libellé, indice)] pour le formulaire et l'invite en ligne."""
@@ -2004,7 +2048,57 @@ class QemuInstallMixin:
"""Émulateur seul."""
return self._qemu_after_remote_cmd(("avd",), prod)
- def _qemu_tools_remote_cmd(self, tools, prod=False, phase="before"):
+ def _qemu_aidev_remote_cmd(self, agent=""):
+ """rtk, starship et UN agent, posés dans la VM.
+
+ Chaque pose est bornée dans le temps ET privée d'entrée standard. Le
+ contrat de la phase « before » veut qu'un outil ne fasse échouer ni
+ les autres ni l'installation d'ERPLibre : « || true » couvre l'échec,
+ mais pas l'ATTENTE. Un installateur amont qui pose une question
+ resterait pendu sur un SSH sans terminal, et le déploiement avec lui ;
+ « /dev/null"
+ f" || echo {shlex.quote(ligne)} >> ~/.bashrc; "
+ )
+
+ return (
+ f'echo "== {t("AI coding tools")} =="; '
+ + pose(dev_tools.RTK_UPSTREAM, 300)
+ + pose(dev_tools.STARSHIP_UPSTREAM_YES, 300)
+ + une_fois(prompt, "starship init bash")
+ + pose(commande, 600)
+ + une_fois(path_line, repertoire)
+ )
+
+ def _qemu_tools_remote_cmd(
+ self, tools, prod=False, phase="before", ai_agent=""
+ ):
"""Bloc des outils cochés pour cette PHASE, du plus utile au plus lourd.
« before » : posé avant le clone. Chaque outil s'y garde lui-même —
@@ -2017,6 +2111,9 @@ class QemuInstallMixin:
# Un seul bloc pour les deux options : voir _qemu_after_remote_cmd.
return self._qemu_after_remote_cmd(tools, prod)
blocks = {
+ # En tête : quelques secondes de curl, contre des minutes pour un
+ # IDE. Ce qui échoue vite se voit tôt.
+ "aidev": lambda: self._qemu_aidev_remote_cmd(ai_agent),
"gnome_ext": self._qemu_gnome_ext_remote_cmd,
"pycharm": lambda: self._qemu_pycharm_remote_cmd(prod),
"android": self._qemu_android_studio_remote_cmd,
diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py
index 2261f10..332960c 100644
--- a/script/todo/qemu_install_monitor.py
+++ b/script/todo/qemu_install_monitor.py
@@ -22,6 +22,12 @@ import shutil
import socket
import subprocess
import time
+
+from script.todo.qemu_privilege import (
+ LIBVIRT_URI as URI,
+ sudo_prefix,
+ virsh_argv,
+)
from pathlib import Path
try:
@@ -1170,7 +1176,7 @@ def virsh_domstates() -> dict:
pour tout le parc (à interroger à intervalle LENT)."""
try:
res = subprocess.run(
- ["sudo", "virsh", "list", "--all"],
+ virsh_argv("list", "--all"),
capture_output=True,
text=True,
timeout=15,
@@ -1389,7 +1395,7 @@ def read_domstats() -> str:
processus à chaque tour."""
try:
res = subprocess.run(
- ["sudo", "virsh", "domstats", "--balloon", "--block"],
+ virsh_argv("domstats", "--balloon", "--block"),
capture_output=True,
text=True,
timeout=15,
@@ -1758,15 +1764,7 @@ def arm_balloon(names) -> None:
for name in names or ():
try:
subprocess.run(
- [
- "sudo",
- "virsh",
- "dommemstat",
- name,
- "--period",
- "5",
- "--live",
- ],
+ virsh_argv("dommemstat", name, "--period", "5", "--live"),
capture_output=True,
text=True,
timeout=10,
@@ -1974,15 +1972,18 @@ def delete_vm_cmd(name: str, with_disks: bool, uuid: str = "") -> str:
cmd = ""
if uuid:
cmd = (
- f"vu=$(sudo virsh domuuid {q} 2>/dev/null | tr -d '[:space:]'); "
+ f"vu=$({sudo_prefix()}virsh --connect {URI} domuuid {q}"
+ " 2>/dev/null"
+ " | tr -d '[:space:]'); "
f'if [ "$vu" != {shlex.quote(uuid)} ]; then '
f'echo "REFUS : {name} n\'est plus le même domaine"'
f' "($vu). Rien n\'a été effacé."; exit 1; fi; '
)
cmd += (
- f"sudo virsh destroy {q} 2>/dev/null; "
- f"sudo virsh undefine {q} --nvram 2>/dev/null "
- f"|| sudo virsh undefine {q}"
+ f"{sudo_prefix()}virsh --connect {URI} destroy {q} 2>/dev/null; "
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"undefine {q} --nvram 2>/dev/null "
+ f"|| {sudo_prefix()}virsh --connect {URI} undefine {q}"
)
if with_disks:
disk = shlex.quote(f"/var/lib/libvirt/images/{name}.qcow2")
@@ -2917,7 +2918,10 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
sortie = "Ctrl+O"
else:
- cmd = f"sudo virsh console {shlex.quote(vm['name'])}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"console {shlex.quote(vm['name'])}"
+ )
titre = f"virsh console {vm['name']}"
sortie = "Ctrl+]"
with self.suspend():
@@ -3104,7 +3108,7 @@ def run_monitor(manifest_path: str, run_app: bool = True):
)
else:
subprocess.run(
- ["sudo", "virsh", action, nom],
+ virsh_argv(action, nom),
capture_output=True,
text=True,
timeout=30,
diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py
index 53d95a7..e565838 100644
--- a/script/todo/qemu_manage.py
+++ b/script/todo/qemu_manage.py
@@ -13,6 +13,12 @@ import subprocess
import time
from script.todo import todo_install
+from script.todo.qemu_privilege import (
+ LIBVIRT_URI as URI,
+ sudo_prefix,
+ system_path,
+ virsh_argv,
+)
from script.todo.todo_i18n import t
@@ -103,6 +109,33 @@ def ssh_orphans(blocs, juge, prefixe="erplibre-"):
return gardes, orphelines
+# Programme de la sonde « video du qemu en cours » du diagnostic. Il vit
+# ici, et non dans un littéral au milieu de la table des sondes : un
+# programme Python cité dans une commande shell citée dans une chaîne
+# Python porte trois niveaux d'échappement, et le premier guillemet le
+# casse. `shlex.quote` s'occupe du seul niveau qui reste.
+_DIAG_VIDEO_PY = """
+import glob
+
+MOTS = ("vga", "virtio-gpu", "egl", "virgl", "rendernode")
+for chemin in sorted(glob.glob("/proc/[0-9]*/cmdline")):
+ try:
+ with open(chemin, "rb") as fh:
+ argv = fh.read().decode("utf-8", "replace").split(chr(0))
+ except OSError:
+ continue
+ # Le tri se fait sur argv[0] : sur la ligne entière, la sonde se
+ # verrait elle-même, son propre motif contenant « qemu-system ».
+ if not argv or "qemu-system" not in argv[0]:
+ continue
+ noms = [a[6:].split(",")[0] for a in argv if a.startswith("guest=")]
+ print(noms[0] if noms else chemin.split("/")[2])
+ for arg in argv:
+ if any(mot in arg.lower() for mot in MOTS):
+ print(" " + arg)
+"""
+
+
class QemuManageMixin:
"""Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici."""
@@ -127,7 +160,7 @@ class QemuManageMixin:
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_list_vms(self, ask_advanced=False):
- cmd = "sudo virsh list --all"
+ cmd = f"{sudo_prefix()}virsh --connect {URI} list --all"
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
if not ask_advanced:
@@ -211,9 +244,23 @@ class QemuManageMixin:
print(t("Cancelled."))
return
for real in resolved:
- cmd = f"sudo virsh {action} {shlex.quote(real)}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"{action} {shlex.quote(real)}"
+ )
print(f"\n{t('Will execute:')} {cmd}")
- self.execute.exec_command_live(cmd, source_erplibre=False)
+ if action != "start":
+ self.execute.exec_command_live(cmd, source_erplibre=False)
+ continue
+ # Un démarrage est le seul moment où la 3D écrite dans la
+ # définition se met à l'épreuve : QEMU refuse le domaine si EGL
+ # n'y démarre pas, et la VM reste inutilisable jusqu'à ce que
+ # quelqu'un défasse le réglage.
+ code, lignes = self.execute.exec_command_live(
+ cmd, source_erplibre=False, return_status_and_output=True
+ )
+ if code:
+ self._qemu_start_failed(real, cmd, "\n".join(lignes or []))
@staticmethod
def _qemu_dumpxml(name, inactive=True):
@@ -228,7 +275,7 @@ class QemuManageMixin:
EST OUVERT, c'est elle qui compte, un disque attaché à chaud n'existant
que là.
"""
- argv = ["sudo", "virsh", "dumpxml"]
+ argv = virsh_argv("dumpxml")
if inactive:
argv.append("--inactive")
argv.append(name)
@@ -249,7 +296,7 @@ class QemuManageMixin:
"""Démarrage automatique activé ? (absent du XML : virsh seul le sait)"""
try:
res = subprocess.run(
- ["sudo", "virsh", "dominfo", name],
+ virsh_argv("dominfo", name),
capture_output=True,
text=True,
timeout=15,
@@ -290,15 +337,11 @@ class QemuManageMixin:
un qui contourne la gestion du réseau par libvirt.
"""
tokens = []
- nets = self._qemu_cmd_lines(
- ["sudo", "virsh", "net-list", "--all", "--name"]
- )
+ nets = self._qemu_cmd_lines(virsh_argv("net-list", "--all", "--name"))
owned = set()
for net in nets:
tokens.append(f"network:{net}")
- for line in self._qemu_cmd_lines(
- ["sudo", "virsh", "net-info", net]
- ):
+ for line in self._qemu_cmd_lines(virsh_argv("net-info", net)):
if line.startswith("Bridge:"):
owned.add(line.split(":", 1)[1].strip())
for line in self._qemu_cmd_lines(
@@ -328,6 +371,501 @@ class QemuManageMixin:
return []
return [ln.strip() for ln in res.stdout.splitlines() if ln.strip()]
+ # Ce que la 3D d'une VM exige de l'hôte, en DEUX briques que le même
+ # réglage met en jeu et qu'on confond souvent :
+ #
+ # « --graphics egl-headless » ouvre le nœud de rendu et crée un contexte
+ # EGL par GBM — c'est Mesa qui répond, et « eglInitialize failed » vient
+ # de là. Sans lui, le domaine ne démarre pas du tout.
+ #
+ # « accel3d=on » confie ensuite les commandes 3D de l'invité à
+ # virglrenderer, qui les traduit vers l'OpenGL de l'hôte. Il n'entre en
+ # jeu qu'APRÈS qu'EGL a démarré : l'installer ne répare donc pas un EGL
+ # qui refuse.
+ #
+ # Les fichiers plutôt que les paquets : leurs noms changent d'une
+ # distribution à l'autre, leur emplacement beaucoup moins.
+ # Les deux devices 3D sont des modules SÉPARÉS, empaquetés à part sur
+ # les distributions qui découpent QEMU. Lequel sert dépend du type de
+ # vidéo : « virtio-vga-gl » pour l'écran principal compatible VGA,
+ # « virtio-gpu-gl » pour un affichage sans VGA. Ne chercher que le
+ # second laisse le rapport tout en vert alors que le premier manque.
+ # QEMU n'annonce pas un device dont le module est absent, libvirt le
+ # remplace alors par « virtio-vga » nu, et rien ne le signale : la VM
+ # démarre sans 3D, avec l'egl-headless demandé toujours en place.
+ _GPU_3D_PIECES = (
+ ("qemu ui-egl-headless", "qemu/ui-egl-headless.so"),
+ ("qemu virtio-vga-gl", "qemu/hw-display-virtio-vga-gl.so"),
+ ("qemu virtio-gpu-gl", "qemu/hw-display-virtio-gpu-gl.so"),
+ ("virglrenderer", "libvirglrenderer.so"),
+ ("mesa libgbm", "libgbm.so"),
+ ("mesa libEGL", "libEGL.so"),
+ )
+
+ @staticmethod
+ def _qemu_lib_present(motif):
+ """Chemin du fichier s'il existe dans les emplacements habituels des
+ bibliothèques et des modules QEMU, sinon la chaîne vide."""
+ racines = (
+ "/usr/lib",
+ "/usr/lib64",
+ "/usr/lib/x86_64-linux-gnu",
+ "/usr/local/lib",
+ )
+ for racine in racines:
+ trouves = sorted(glob.glob(os.path.join(racine, motif + "*")))
+ if trouves:
+ return trouves[0]
+ return ""
+
+ def _qemu_gpu_3d_report(self):
+ """Dit ce qui manque à l'hôte pour que la 3D d'une VM démarre.
+
+ Ne modifie rien. Un nœud de rendu présent ne prouve RIEN : c'est
+ l'initialisation d'EGL dessus qui décide, et elle ne se teste qu'en
+ essayant — d'où les deux commandes suggérées à la fin.
+ """
+ print(f"\n── {t('3D on this host')} ──")
+ node = self._qemu_host_gpu_node()
+ print(f" {t('Render node:')} {node or t('none')}")
+ for nom, motif in self._GPU_3D_PIECES:
+ chemin = self._qemu_lib_present(motif)
+ # Absent : dire ce qui a été CHERCHÉ. Une case vide laisse sans
+ # prise, le motif dit quoi installer et où regarder.
+ print(f" {'✅' if chemin else '❌'} {nom:<24} {chemin or motif}")
+ print(f"\n {t('Check EGL itself on that node:')}")
+ print(" eglinfo -B 2>&1 | head -20")
+ print(" lspci -nnk | grep -A3 -iE 'vga|3d|display'")
+ self._qemu_nvidia_acl_advice()
+
+ def _qemu_vm_3d_report(self):
+ """État 3D de chaque VM définie, lu dans sa définition PERSISTANTE.
+
+ Le rapport sur l'hôte dit ce que la machine peut faire ; celui-ci
+ dit ce que chaque VM recevra au prochain démarrage, ce qui n'est
+ pas la même question. Trois valeurs y répondent ensemble et aucune
+ seule : le type de vidéo, « accel3d », et le device figé.
+
+ Ce dernier (attribut « device », depuis libvirt 12.5.0) grave le
+ device QEMU retenu pour tenir l'ABI de l'invité stable d'un
+ démarrage à l'autre, et il l'emporte sur « accel3d ». Une VM
+ démarrée une première fois sans 3D garde donc un device sans GL,
+ que cocher la 3D ensuite ne change pas : la définition et la ligne
+ de commande se contredisent alors sans que rien ne le signale.
+ """
+ from script.todo import qemu_hardware as hw
+
+ print(f"\n── {t('3D per VM')} ──")
+ noms = self._qemu_list_domains()
+ if not noms:
+ print(f" {t('none')}")
+ return
+ figees = []
+ for nom in noms:
+ etat = hw.hw_state(self._qemu_dumpxml(nom))
+ if not etat.get("video"):
+ print(f" · {nom} — {t('no video device')}")
+ continue
+ champs = [
+ f"video={etat['video']}",
+ f"accel3d={'on' if etat.get('accel3d') else 'off'}",
+ f"device={etat.get('video_device') or t('not pinned')}",
+ ]
+ if etat.get("render"):
+ champs.append(f"rendernode={etat['render']}")
+ if hw.pin_defeats_3d(etat):
+ marque = "❌"
+ figees.append(nom)
+ elif etat.get("accel3d"):
+ marque = "✅"
+ else:
+ marque = "· "
+ print(f" {marque} {nom} — {', '.join(champs)}")
+ if nom in figees:
+ print(
+ f" ⚠ {t('non-GL device pinned: 3D will not start')}"
+ )
+ if figees:
+ # Le rapport ne modifie rien : la commande est ÉCRITE, jamais
+ # lancée. Elle passe par la définition persistante et non par
+ # virt-xml, dont le vocabulaire ne connaît pas partout cet
+ # attribut ; « define » l'accepte quelle que soit sa version.
+ print(f"\n {t('To unpin, VM stopped:')}")
+ print(" v=$(mktemp) ; vm=" + figees[0])
+ print(
+ " virsh --connect qemu:///system dumpxml --inactive"
+ " $vm > $v"
+ )
+ print(
+ " sed -i \"s/device='virtio-vga'/device='virtio-vga-gl'/\""
+ " $v"
+ )
+ print(" virsh --connect qemu:///system define $v")
+
+ # Les relevés du diagnostic : (titre, commande). Tous en LECTURE — un
+ # rapport qui modifie l'hôte n'est plus un rapport, et celui-ci est fait
+ # pour être envoyé à quelqu'un qui n'a pas accès à la machine.
+ #
+ # « head -1 » sur virt-xml n'est pas une curiosité : la ligne d'amorçage
+ # dit quel interpréteur le porte, et c'est ce qui distingue un module
+ # système absent d'un venv qui capture la commande.
+ _DIAG_PROBES = (
+ ("uname", "uname -a"),
+ ("os-release", "cat /etc/os-release 2>/dev/null | head -5"),
+ ("virtualisation", "systemd-detect-virt || true"),
+ ("kvm", "ls -l /dev/kvm 2>&1"),
+ ("dri", "ls -l /dev/dri/ 2>&1"),
+ ("gpu", "lspci -nnk 2>/dev/null | grep -A3 -iE 'vga|3d|display'"),
+ ("egl", "eglinfo -B 2>&1 | head -20"),
+ ("virsh", "virsh --connect qemu:///system version 2>&1"),
+ ("domaines", "virsh --connect qemu:///system list --all 2>&1"),
+ ("réseaux", "virsh --connect qemu:///system net-list --all 2>&1"),
+ (
+ "outils",
+ "command -v virsh virt-install virt-xml qemu-img"
+ " guestfish genisoimage cloud-localds 2>&1",
+ ),
+ ("virt-xml", "head -1 $(command -v virt-xml) 2>&1"),
+ (
+ "python",
+ "command -v python3; python3 -c 'import sys;"
+ " print(sys.executable)' 2>&1",
+ ),
+ ("modules qemu", "ls /usr/lib/qemu/ 2>/dev/null | head -30"),
+ # NVIDIA propriétaire : trois conditions que Mesa seul ne donne pas.
+ # « modeset » à N prive GBM du pilote, le greffon GBM de NVIDIA vit
+ # hors des chemins de Mesa, et QEMU doit pouvoir ouvrir /dev/nvidia*
+ # — que libvirt n'ajoute PAS d'office à la liste des périphériques
+ # autorisés, même quand il y ajoute le nœud de rendu.
+ # Ce paramètre est lisible par root SEUL : sans sudo, la sonde ne
+ # rapporte qu'un refus de permission, qui n'apprend rien. « sudo -n »
+ # ne demande jamais de mot de passe — il échoue plutôt que de bloquer
+ # un relevé, et le repli dit alors ce qui manque.
+ (
+ "nvidia modeset",
+ "sudo -n cat /sys/module/nvidia_drm/parameters/modeset 2>/dev/null"
+ " || echo '(lisible par root seul : sudo cat"
+ " /sys/module/nvidia_drm/parameters/modeset)'",
+ ),
+ ("nvidia devices", "ls -l /dev/nvidia* 2>&1"),
+ ("gbm backends", "ls -l /usr/lib/gbm/ 2>&1"),
+ # Une sortie vide est ambiguë — fichier absent, ou toutes les clés
+ # en commentaire ? Le dire, puisque « rien de réglé » signifie que
+ # les défauts de libvirt s'appliquent, et c'est une information.
+ (
+ "libvirt qemu.conf",
+ "grep -nE '^[^#]*(user|group|cgroup_device_acl|namespaces)'"
+ " /etc/libvirt/qemu.conf 2>&1 | head -20"
+ " || echo '(aucune clé active : défauts de libvirt)'",
+ ),
+ (
+ "utilisateur des VM",
+ "ps -o user=,comm= -C qemu-system-x86_64 2>&1 | sort -u",
+ ),
+ # Ce que la VM a REÇU, et non ce que sa définition demande : entre
+ # les deux, libvirt peut avoir retiré l'accélération sans le dire.
+ # « virtio-vga-gl » est le device 3D des QEMU récents ; l'ancienne
+ # forme « virtio-vga,virgl=on » ne s'écrit plus, si bien qu'un grep
+ # sur « virgl » ne rend rien sur un QEMU pourtant accéléré et fait
+ # conclure à tort qu'il manque un réglage. Le suffixe « -gl » est ce
+ # qui distingue le device 3D de celui du rendu logiciel, et c'est la
+ # seule pièce que la ligne de commande dise sans ambiguïté :
+ # « egl-headless » s'y trouve dans les deux cas.
+ (
+ "video du qemu en cours",
+ "python3 -c " + shlex.quote(_DIAG_VIDEO_PY) + " 2>&1",
+ ),
+ ("stockage", "df -h /var/lib/libvirt/images 2>&1"),
+ ("groupes", "id"),
+ )
+
+ # Ce qui manque au relevé quand un outil est absent. « eglinfo » est le
+ # seul qui éprouve EGL pour de vrai : sans lui, le rapport dit ce qui est
+ # installé, jamais si ça démarre — et c'est justement la question.
+ _DIAG_TOOLS = (
+ (
+ "eglinfo",
+ {
+ "apt": "mesa-utils",
+ "dnf": "mesa-demos",
+ "pacman": "mesa-utils",
+ "zypper": "Mesa-demo-egl",
+ },
+ ),
+ (
+ "lspci",
+ {
+ "apt": "pciutils",
+ "dnf": "pciutils",
+ "pacman": "pciutils",
+ "zypper": "pciutils",
+ },
+ ),
+ )
+
+ def _qemu_diag_offer_tools(self):
+ """Propose d'installer les outils dont le relevé manque. Rend un bool
+ disant si quelque chose a été installé.
+
+ Proposé et non imposé : un diagnostic qui pose des paquets sans
+ demander n'est plus un diagnostic.
+ """
+ from script.todo.qemu_privilege import install_cmd_for
+
+ manquants = [
+ (binaire, paquets)
+ for binaire, paquets in self._DIAG_TOOLS
+ if shutil.which(binaire) is None
+ ]
+ if not manquants:
+ return False
+ print(f"\n {t('These tools would complete the report:')}")
+ commandes = []
+ for binaire, paquets in manquants:
+ cmd, paquet = install_cmd_for(paquets)
+ if cmd:
+ print(f" {binaire:<10} {t('package')} {paquet}")
+ commandes.append(cmd)
+ else:
+ print(f" {binaire:<10} {t('unknown package manager')}")
+ if not commandes:
+ return False
+ # La commande est annoncée EN ENTIER avant la question, sudo compris :
+ # « les installer ? » ne dit ni ce qui sera lancé, ni avec quels
+ # droits, et c'est ce qu'on approuve.
+ print(f"\n {t('Will run, as root:')}")
+ for cmd in commandes:
+ print(f" {cmd}")
+ try:
+ reponse = input(t("Install them now? (y/N): "))
+ except EOFError:
+ # Sans terminal — un lancement scripté — la question n'a personne
+ # pour y répondre. Ne rien installer, et laisser le rapport, qui
+ # est déjà écrit, faire son travail.
+ return False
+ if not self._is_yes(reponse):
+ return False
+ for cmd in commandes:
+ print(f"\n{t('Will execute:')} {cmd}")
+ self.execute.exec_command_live(
+ cmd,
+ source_erplibre=False,
+ new_env={"PATH": system_path()},
+ )
+ return True
+
+ # Ce que libvirt autorise par défaut. La liste sert de BASE à la
+ # proposition : la compléter suppose de la reprendre en entier, car la
+ # clé remplace le défaut au lieu de s'y ajouter.
+ _ACL_BASE = (
+ "/dev/null",
+ "/dev/full",
+ "/dev/zero",
+ "/dev/random",
+ "/dev/urandom",
+ "/dev/ptmx",
+ "/dev/kvm",
+ )
+ _QEMU_CONF = "/etc/libvirt/qemu.conf"
+
+ @staticmethod
+ def _qemu_nvidia_nodes():
+ """Nœuds de la carte NVIDIA présents sur l'hôte, triés.
+
+ Le répertoire /dev/nvidia-caps est écarté : ce sont des capacités MIG,
+ que la pile EGL n'ouvre pas, et les lister ferait proposer plus large
+ que nécessaire.
+ """
+ return sorted(
+ n
+ for n in glob.glob("/dev/nvidia*")
+ if os.path.exists(n) and not os.path.isdir(n)
+ )
+
+ def _qemu_acl_active(self):
+ """Le contenu ACTIF de cgroup_device_acl, ou None s'il n'y en a pas.
+
+ Rend None aussi quand le fichier est illisible : ne pas pouvoir lire
+ n'est pas la même chose que savoir qu'il n'y a rien, et la proposition
+ le dit plutôt que de conclure.
+ """
+ try:
+ with open(self._QEMU_CONF, encoding="utf-8") as fh:
+ lignes = [ln for ln in fh if not ln.lstrip().startswith("#")]
+ except OSError:
+ return None
+ texte = "".join(lignes)
+ if "cgroup_device_acl" not in texte:
+ return ""
+ return texte[texte.index("cgroup_device_acl") :]
+
+ def _qemu_nvidia_acl_advice(self):
+ """Propose la liste de périphériques quand elle explique le blocage.
+
+ Pertinent seulement si l'hôte porte une carte NVIDIA propriétaire ET
+ que sa liste ne nomme pas ces nœuds : libvirt y ajoute le nœud de
+ rendu quand le domaine le déclare, jamais ceux de la carte, et la
+ pile propriétaire ouvre les deux. Rend un bool disant s'il a parlé.
+ """
+ nodes = self._qemu_nvidia_nodes()
+ if not nodes:
+ return False
+ actif = self._qemu_acl_active()
+ if actif and all(n in actif for n in nodes):
+ return False
+ render = self._qemu_host_gpu_node()
+ entrees = list(self._ACL_BASE) + ([render] if render else []) + nodes
+ print(f"\n── {t('Devices QEMU may open')} ──")
+ if actif is None:
+ print(f" {t('Unreadable, so this may already be set:')}")
+ print(f" sudo grep -n cgroup_device_acl {self._QEMU_CONF}")
+ elif actif:
+ print(f" ⚠ {t('A list exists: ADD to it, never replace it.')}")
+ print(f" {t('In')} {self._QEMU_CONF} :")
+ print(" cgroup_device_acl = [")
+ for i in range(0, len(entrees), 3):
+ bout = ", ".join(f'"{e}"' for e in entrees[i : i + 3])
+ print(f" {bout},")
+ print(" ]")
+ print(f"\n {t('Then restart the daemon and recreate the VM:')}")
+ print(" systemctl is-active libvirtd virtqemud")
+ print(" sudo systemctl restart libvirtd")
+ print(f" {t('then stop and start the VM (a guest reboot is not')}")
+ print(f" {t('enough: the list applies when QEMU is launched).')}")
+ return True
+
+ @staticmethod
+ def _diag_section(titre, rendu):
+ """Une section du rapport, isolée : elle échoue SEULE.
+
+ Les sondes shell sont déjà protégées une à une ; les sections
+ écrites en Python ne l'étaient pas. Or le rapport s'écrit d'un
+ bloc à la FIN : une section qui lève emporte avec elle tout ce qui
+ a été relevé avant, et l'utilisateur se retrouve sans fichier —
+ au moment précis où il en a besoin. L'échec devient donc une ligne
+ du rapport, ce qui est en soi une information.
+ """
+ import io
+ from contextlib import redirect_stdout
+
+ tampon = io.StringIO()
+ try:
+ with redirect_stdout(tampon):
+ rendu()
+ except Exception as exc:
+ tampon.write(f"\n({type(exc).__name__}: {exc})\n")
+ return f"\n===== {titre} =====\n" + tampon.getvalue()
+
+ def _qemu_diagnostics(self):
+ """Relevé complet de l'hôte, écrit dans un fichier à transmettre.
+
+ Chaque sonde est bornée dans le temps : une commande qui pend ne doit
+ pas retenir le rapport, et son absence est elle-même une information.
+ """
+ import io
+ from contextlib import redirect_stdout
+
+ dossier = os.path.expanduser("~/.erplibre")
+ os.makedirs(dossier, exist_ok=True)
+ horo = time.strftime("%Y%m%d-%H%M%S")
+ chemin = os.path.join(dossier, f"qemu-diagnostic-{horo}.log")
+ morceaux = [f"# ERPLibre — diagnostic QEMU — {horo}\n"]
+ print(f"\n🩺 {t('Collecting the diagnostics...')}")
+ for titre, cmd in self._DIAG_PROBES:
+ print(f" … {titre}")
+ morceaux.append(f"\n===== {titre} =====\n$ {cmd}\n")
+ try:
+ res = subprocess.run(
+ cmd,
+ shell=True,
+ capture_output=True,
+ text=True,
+ timeout=30,
+ env=self._qemu_c_env(),
+ )
+ morceaux.append((res.stdout or "") + (res.stderr or ""))
+ except subprocess.TimeoutExpired:
+ morceaux.append("(timeout)\n")
+ except OSError as exc:
+ morceaux.append(f"({exc})\n")
+ morceaux.append(self._diag_section("3D", self._qemu_gpu_3d_report))
+ morceaux.append(
+ self._diag_section("3D par VM", self._qemu_vm_3d_report)
+ )
+ try:
+ with open(chemin, "w", encoding="utf-8") as fh:
+ fh.write("".join(morceaux))
+ except OSError as exc:
+ print(f" ⚠ {t('Cannot write the report: ')}{exc}")
+ return
+ print(f"\n✅ {t('Report written to: ')}{chemin}")
+ # Le rapport porte le nom de la machine, des chemins de compte et des
+ # adresses : le dire, puisqu'il est fait pour être transmis.
+ print(f" {t('It names this host, its paths and its addresses.')}")
+ print(f" {t('Read it before sharing it.')}")
+ # Après l'écriture : le rapport existe même si l'on refuse, et une
+ # installation acceptée invite à le refaire, plus complet.
+ if self._qemu_diag_offer_tools():
+ print(f"\n {t('Run the diagnostics again for a fuller report.')}")
+
+ def _qemu_egl_failed(self, sortie):
+ """La sortie accuse-t-elle un EGL inutilisable ?
+
+ La signature vit dans deploy_qemu.py, qui doit tenir seul en tant que
+ script : la lire là plutôt que la recopier ici évite deux vérités.
+ """
+ try:
+ return bool(self._qemu_import_module().egl_failed(sortie))
+ except Exception:
+ return False
+
+ def _qemu_start_failed(self, name, cmd, sortie):
+ """Un démarrage refusé : proposer ce qui le débloque, s'il y a lieu.
+
+ Le seul échec qui se rattrape ici est celui de la 3D : le nœud de
+ rendu existe, mais EGL n'y démarre pas, et rien ne permettait de le
+ savoir avant l'essai. Retirer l'accélération rend une VM qui démarre.
+ """
+ if not self._qemu_egl_failed(sortie):
+ return
+ print(f"\n ⚠ {t('EGL does not start on this host GPU.')}")
+ print(
+ f" {t('The VM cannot boot while 3D stays in its definition.')}"
+ )
+ self._qemu_gpu_3d_report()
+ if not self._is_yes_default_yes(
+ input(t("Remove the 3D and start again? (Y/n): "))
+ ):
+ return
+ from script.todo import qemu_hardware as hw
+
+ etat = hw.hw_state(
+ self._qemu_dumpxml(name), self._qemu_autostart(name)
+ )
+ if not etat.get("name"):
+ print(f" ⚠ {t('Unreadable VM definition.')}")
+ return
+ # Seule la 3D change. build_want retombe sur l'état pour les vCPU, la
+ # RAM, les écrans, le CPU et le réseau — mais PAS pour « autostart »,
+ # qu'il lit en booléen sec : y passer None éteindrait le démarrage
+ # automatique d'une VM qu'on voulait seulement débloquer.
+ want = hw.build_want(etat, None, None, False, etat.get("autostart"))
+ plan = hw.hw_plan(etat, want)
+ for entree in plan:
+ if "cmd" not in entree:
+ continue
+ retrait = sudo_prefix() + " ".join(
+ shlex.quote(c) for c in entree["cmd"]
+ )
+ print(f"\n{t('Will execute:')} {retrait}")
+ self.execute.exec_command_live(
+ retrait,
+ source_erplibre=False,
+ new_env={"PATH": system_path()},
+ )
+ print(f"\n{t('Will execute:')} {cmd}")
+ self.execute.exec_command_live(cmd, source_erplibre=False)
+
def _qemu_adjust_hardware(self, names):
"""Règle vCPU, RAM, 3D et démarrage automatique de VM ÉTEINTES.
@@ -391,9 +929,18 @@ class QemuManageMixin:
print(t("Cancelled."))
return
for entry in cmds:
- cmd = "sudo " + " ".join(shlex.quote(c) for c in entry["cmd"])
+ cmd = sudo_prefix() + " ".join(
+ shlex.quote(c) for c in entry["cmd"]
+ )
print(f"\n{t('Will execute:')} {cmd}")
- self.execute.exec_command_live(cmd, source_erplibre=False)
+ # virt-xml est un script Python du système : sans PATH assaini, il
+ # s'amorce sur l'interpréteur du venv, où les modules de la
+ # distribution n'existent pas.
+ self.execute.exec_command_live(
+ cmd,
+ source_erplibre=False,
+ new_env={"PATH": system_path()},
+ )
def _qemu_hw_form(self, rows, node, nets=None):
"""Formulaire TUI d'ajustement. Renvoie l'intention par VM, {} pour
@@ -474,7 +1021,7 @@ class QemuManageMixin:
"""(vcpus, max_mem_kib) via « virsh dominfo », ou (0, 0)."""
try:
res = subprocess.run(
- ["sudo", "virsh", "dominfo", name],
+ virsh_argv("dominfo", name),
capture_output=True,
text=True,
timeout=15,
@@ -559,22 +1106,14 @@ class QemuManageMixin:
disparaît au prochain démarrage du domaine."""
try:
subprocess.run(
- [
- "sudo",
- "virsh",
- "dommemstat",
- name,
- "--period",
- "5",
- "--live",
- ],
+ virsh_argv("dommemstat", name, "--period", "5", "--live"),
capture_output=True,
text=True,
timeout=15,
env=QemuManageMixin._qemu_c_env(),
)
res = subprocess.run(
- ["sudo", "virsh", "dommemstat", name],
+ virsh_argv("dommemstat", name),
capture_output=True,
text=True,
timeout=15,
@@ -674,7 +1213,11 @@ class QemuManageMixin:
else:
targets = [name]
for tgt in targets:
- cmd = f"sudo virsh domifaddr {shlex.quote(tgt)} --source lease"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"domifaddr {shlex.quote(tgt)}"
+ " --source lease"
+ )
print(f"\n{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@@ -691,7 +1234,9 @@ class QemuManageMixin:
print(
f"👤 {t('Default login (if set at deploy): erplibre / erplibre')}"
)
- cmd = f"sudo virsh console {shlex.quote(name)}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} console {shlex.quote(name)}"
+ )
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@@ -995,7 +1540,7 @@ class QemuManageMixin:
"""État libvirt de la VM (« running », « shut off », …) ou ''."""
try:
res = subprocess.run(
- ["sudo", "virsh", "domstate", name],
+ virsh_argv("domstate", name),
capture_output=True,
text=True,
timeout=15,
@@ -1014,7 +1559,7 @@ class QemuManageMixin:
return name
try:
res = subprocess.run(
- ["sudo", "virsh", "domname", str(name)],
+ virsh_argv("domname", str(name)),
capture_output=True,
text=True,
timeout=15,
@@ -1035,7 +1580,11 @@ class QemuManageMixin:
return True
# --mode acpi,agent : envoie le SIGNAL d'extinction (bouton ACPI) puis
# tente l'agent invité si présent — plus fiable qu'un arrêt brutal.
- cmd = f"sudo virsh shutdown {shlex.quote(name)} --mode acpi,agent"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"shutdown {shlex.quote(name)}"
+ " --mode acpi,agent"
+ )
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
print(
@@ -1066,7 +1615,10 @@ class QemuManageMixin:
)
)
):
- cmd = f"sudo virsh destroy {shlex.quote(name)}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"destroy {shlex.quote(name)}"
+ )
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
time.sleep(2)
@@ -1079,7 +1631,7 @@ class QemuManageMixin:
ignore le seed cloud-init (…-seed.iso, en lecture seule)."""
try:
res = subprocess.run(
- ["sudo", "virsh", "domblklist", name, "--details"],
+ virsh_argv("domblklist", name, "--details"),
capture_output=True,
text=True,
timeout=15,
@@ -1142,7 +1694,8 @@ class QemuManageMixin:
print(f"\n{t('Current disk:')} {disk}")
# -U : lecture sûre même VM allumée (sinon « shared write lock »).
self.execute.exec_command_live(
- f"sudo qemu-img info -U {shlex.quote(disk)}", source_erplibre=False
+ f"{sudo_prefix()}qemu-img info -U {shlex.quote(disk)}",
+ source_erplibre=False,
)
cur_bytes = self._qemu_disk_virtual_bytes(disk)
cur_gb = cur_bytes / (1 << 30)
@@ -1259,11 +1812,15 @@ class QemuManageMixin:
# Agrandissement À CHAUD : le disque virtuel grossit, le FS invité
# devra être étendu ensuite.
cmd = (
- f"sudo virsh blockresize {shlex.quote(name)} "
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"blockresize {shlex.quote(name)} "
f"{shlex.quote(disk)} {new_gb:g}G"
)
else:
- cmd = f"sudo qemu-img resize {shlex.quote(disk)} {new_gb:g}G"
+ cmd = (
+ f"{sudo_prefix()}qemu-img resize"
+ f" {shlex.quote(disk)} {new_gb:g}G"
+ )
# 4) Agrandissement : exécuter la commande + proposer d'étendre le FS.
if cmd is not None:
@@ -1302,7 +1859,10 @@ class QemuManageMixin:
if self._is_yes(input(t("Start the VM now? (y/N): "))):
# `name` est déjà le nom canonique : « virsh start »
# échouerait car l'ID disparaît quand la VM est éteinte.
- cmd = f"sudo virsh start {shlex.quote(name)}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"start {shlex.quote(name)}"
+ )
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@@ -1795,13 +2355,9 @@ class QemuManageMixin:
def agent(payload):
try:
res = subprocess.run(
- [
- "sudo",
- "virsh",
- "qemu-agent-command",
- name,
- json.dumps(payload),
- ],
+ virsh_argv(
+ "qemu-agent-command", name, json.dumps(payload)
+ ),
capture_output=True,
text=True,
timeout=30,
@@ -1862,7 +2418,9 @@ class QemuManageMixin:
)
if not self._is_yes(input(t("Open the serial console now? (y/N): "))):
return
- cmd = f"sudo virsh console {shlex.quote(name)}"
+ cmd = (
+ f"{sudo_prefix()}virsh --connect {URI} console {shlex.quote(name)}"
+ )
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@@ -1870,7 +2428,7 @@ class QemuManageMixin:
"""Noms des VM libvirt définies (via virsh)."""
try:
res = subprocess.run(
- ["sudo", "virsh", "list", "--all", "--name"],
+ virsh_argv("list", "--all", "--name"),
capture_output=True,
text=True,
timeout=15,
@@ -1923,9 +2481,11 @@ class QemuManageMixin:
# Éteindre si en cours, puis retirer la définition (+ nvram si
# UEFI ; repli sans l'option pour les vieilles versions de virsh).
cmd = (
- f"sudo virsh destroy {q} 2>/dev/null; "
- f"sudo virsh undefine {q} --nvram 2>/dev/null "
- f"|| sudo virsh undefine {q}"
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"destroy {q} 2>/dev/null; "
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"undefine {q} --nvram 2>/dev/null "
+ f"|| {sudo_prefix()}virsh --connect {URI} undefine {q}"
)
if del_disks and fichiers:
cmd += "; sudo rm -f " + " ".join(
@@ -1996,7 +2556,7 @@ class QemuManageMixin:
for name in self._qemu_list_domains():
try:
res = subprocess.run(
- ["sudo", "virsh", "domiflist", name],
+ virsh_argv("domiflist", name),
capture_output=True,
text=True,
timeout=15,
@@ -2115,7 +2675,7 @@ class QemuManageMixin:
for name in self._qemu_list_domains():
try:
res = subprocess.run(
- ["sudo", "virsh", "domblklist", name, "--details"],
+ virsh_argv("domblklist", name, "--details"),
capture_output=True,
text=True,
timeout=15,
@@ -2148,9 +2708,11 @@ class QemuManageMixin:
for name in ghosts:
q = shlex.quote(name)
cmd = (
- f"sudo virsh destroy {q} 2>/dev/null; "
- f"sudo virsh undefine {q} --nvram 2>/dev/null "
- f"|| sudo virsh undefine {q}"
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"destroy {q} 2>/dev/null; "
+ f"{sudo_prefix()}virsh --connect {URI} "
+ f"undefine {q} --nvram 2>/dev/null "
+ f"|| {sudo_prefix()}virsh --connect {URI} undefine {q}"
)
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
@@ -2341,7 +2903,7 @@ class QemuManageMixin:
"""Vrai si une VM libvirt de ce nom est déjà définie."""
try:
res = subprocess.run(
- ["sudo", "virsh", "dominfo", name],
+ virsh_argv("dominfo", name),
capture_output=True,
text=True,
timeout=15,
@@ -2385,7 +2947,7 @@ class QemuManageMixin:
for source in ("lease", "agent", "arp"):
try:
res = subprocess.run(
- ["sudo", "virsh", "domifaddr", name, "--source", source],
+ virsh_argv("domifaddr", name, "--source", source),
capture_output=True,
text=True,
timeout=15,
@@ -2417,7 +2979,7 @@ class QemuManageMixin:
for source in ("lease", "agent", "arp"):
try:
res = subprocess.run(
- ["sudo", "virsh", "domifaddr", name, "--source", source],
+ virsh_argv("domifaddr", name, "--source", source),
capture_output=True,
text=True,
timeout=15,
@@ -2594,7 +3156,7 @@ class QemuManageMixin:
"""Architecture d'une VM (jeton amd64/arm64/s390x) via virsh dumpxml."""
try:
res = subprocess.run(
- ["sudo", "virsh", "dumpxml", name],
+ virsh_argv("dumpxml", name),
capture_output=True,
text=True,
timeout=15,
diff --git a/script/todo/qemu_menu.py b/script/todo/qemu_menu.py
index a09e04f..9e653ed 100644
--- a/script/todo/qemu_menu.py
+++ b/script/todo/qemu_menu.py
@@ -239,6 +239,38 @@ class QemuMenuMixin:
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
+ def _qemu_warn_libvirt_access(self):
+ """Dit, AVANT d'installer, par quelle voie les commandes passeront.
+
+ Deux voies mènent à qemu:///system : le groupe libvirt, qui ne demande
+ rien, et sudo, qui demande un mot de passe à chaque commande. Le suivi
+ d'installation, lui, tourne détaché et sans terminal : il ne peut
+ répondre à aucune invite. Le savoir avant l'installation vaut mieux que
+ de le découvrir devant la première invite de mot de passe.
+
+ Ne dit rien quand l'accès est déjà là — un avertissement qui se répète
+ sans objet finit par ne plus se lire.
+ """
+ from script.todo import qemu_privilege as qp
+
+ if qp.libvirt_reachable(force=True):
+ return
+ declare, actif = qp.group_state()
+ if actif:
+ return
+ print(f"\n⚠ {t('Recommended before installing:')}")
+ if declare:
+ # Le groupe est acquis mais la session est plus ancienne que lui :
+ # aucun usermod à refaire, seulement une session à rouvrir.
+ print(f" {t('You are in the libvirt group, but this session')}")
+ print(f" {t('predates it. Log out and back in, or run:')}")
+ print(" newgrp libvirt")
+ else:
+ print(f" {t('Grant libvirt access, or every VM command will')}")
+ print(f" {t('ask for a sudo password:')}")
+ print(" sudo usermod -aG libvirt $USER")
+ print(f" {t('then log out and back in.')}")
+
def _qemu_ensure_tools(self):
"""virsh absent : proposer l'installation plutôt que de laisser
chaque commande échouer sur « sudo: virsh: command not found ».
@@ -249,6 +281,7 @@ class QemuMenuMixin:
return True
print(f"\n⚠ {t('virsh is missing: libvirt is not installed here.')}")
print(f" {t('Every VM command will fail until it is.')}")
+ self._qemu_warn_libvirt_access()
if not self._is_yes_default_yes(
input(t("Install the QEMU/libvirt tools now? (Y/n): "))
):
@@ -292,19 +325,16 @@ class QemuMenuMixin:
"Reopen install monitoring (last run / history)"
)
},
+ # Douze entrées sous un seul titre ne se lisent plus : on cherche
+ # à la ligne près. Trois intentions les séparent — vivre avec ses
+ # VM, y entrer, réparer quand ça va mal.
{"section": t("Manage")},
{"prompt_description": t("List VMs (virsh list --all)")},
{"prompt_description": t("Show a VM IP address")},
{"prompt_description": t("Open the console on a VM")},
{"prompt_description": t("Resize a VM disk")},
{"prompt_description": t("Delete VM(s)")},
- {"prompt_description": t("Clean up QEMU (orphan files)")},
- {
- "prompt_description": t(
- "Test a VM (open Odoo in a CLI browser)"
- )
- },
- {"prompt_description": t("Statistics (installs, durations, VMs)")},
+ {"section": t("VM access")},
{
"prompt_description": t(
"SSH configuration (~/.ssh/config, ProxyJump)"
@@ -320,6 +350,20 @@ class QemuMenuMixin:
"Android emulator (start, tunnel, scrcpy)"
)
},
+ {"section": t("Troubleshoot")},
+ {"prompt_description": t("Clean up QEMU (orphan files)")},
+ {
+ "prompt_description": t(
+ "Recover files from a VM disk (libguestfs)"
+ )
+ },
+ {
+ "prompt_description": t(
+ "Test a VM (open Odoo in a CLI browser)"
+ )
+ },
+ {"prompt_description": t("Diagnostics (report to share)")},
+ {"prompt_description": t("Statistics (installs, durations, VMs)")},
{"section": t("Catalog")},
{"prompt_description": t("List available images and specs")},
]
@@ -352,18 +396,22 @@ class QemuMenuMixin:
elif status == "9":
self._qemu_delete_vm()
elif status == "10":
- self._qemu_cleanup()
- elif status == "11":
- self._qemu_test_vm()
- elif status == "12":
- self._qemu_stats()
- elif status == "13":
self._qemu_ssh_config_menu()
- elif status == "14":
+ elif status == "11":
self._qemu_tunnel_menu()
- elif status == "15":
+ elif status == "12":
self._qemu_emulator_menu()
+ elif status == "13":
+ self._qemu_cleanup()
+ elif status == "14":
+ self._qemu_recover_files()
+ elif status == "15":
+ self._qemu_test_vm()
elif status == "16":
+ self._qemu_diagnostics()
+ elif status == "17":
+ self._qemu_stats()
+ elif status == "18":
self._qemu_list_images()
else:
cmd_no_found = True
@@ -473,8 +521,11 @@ class QemuMenuMixin:
def _qemu_stamp(ts):
"""Horodatage court « 2026-08-01 »."""
try:
- return datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
- except (OSError, OverflowError, ValueError):
+ return datetime.fromtimestamp(ts).strftime("%Y-%m-%d")
+ except (OSError, OverflowError, TypeError, ValueError):
+ # TypeError : un horodatage absent vaut None dans un résumé sans
+ # aucune installation, et une statistique ne doit pas faire
+ # tomber le menu.
return "?"
def _qemu_stats_vms(self, mon):
diff --git a/script/todo/qemu_privilege.py b/script/todo/qemu_privilege.py
new file mode 100644
index 0000000..a3b31a5
--- /dev/null
+++ b/script/todo/qemu_privilege.py
@@ -0,0 +1,197 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Faut-il « sudo » pour parler à libvirt ? Une seule réponse pour tout TODO.
+
+Appartenir au groupe libvirt suffit à joindre qemu:///system : préfixer alors
+les commandes de « sudo » ne donne aucun droit de plus et réclame un mot de
+passe pour rien. La question se tranche en ESSAYANT, jamais en lisant
+/etc/group : les groupes d'un processus sont figés à l'ouverture de session,
+donc un utilisateur fraîchement ajouté y figure sans que le shell courant en
+dispose. L'essai dit ce que le shell peut FAIRE, la table dit ce qui a été
+DÉCLARÉ — et c'est l'écart entre les deux qui explique « je suis pourtant
+dans le groupe ».
+
+Le même fichier porte de quoi ATTEINDRE ces outils : le PATH à assainir pour
+qu'un outil de la distribution ne s'amorce pas sur le venv, et la commande
+qui installe un paquet manquant selon le gestionnaire de l'hôte.
+"""
+
+import grp
+import os
+import shutil
+import subprocess
+
+# Réponse du sondage, gardée pour la session : chaque commande du menu la
+# demande, et lancer un virsh par entrée de menu se verrait.
+_CACHE: bool | None = None
+
+PROBE = ["virsh", "--connect", "qemu:///system", "list", "--name"]
+
+
+def reset_cache() -> None:
+ """Oublie le sondage. À appeler après un changement de droits."""
+ global _CACHE
+ _CACHE = None
+
+
+def libvirt_reachable(force: bool = False) -> bool:
+ """qemu:///system répond-il SANS sudo ?
+
+ Rend faux quand virsh est absent : il n'y a alors rien à joindre, et le
+ dire évite de conclure « il faut sudo » sur une machine sans libvirt.
+ """
+ global _CACHE
+ if _CACHE is not None and not force:
+ return _CACHE
+ if shutil.which("virsh") is None:
+ _CACHE = False
+ return _CACHE
+ try:
+ probe = subprocess.run(
+ PROBE, capture_output=True, text=True, timeout=15
+ )
+ _CACHE = probe.returncode == 0
+ except (OSError, subprocess.SubprocessError):
+ _CACHE = False
+ return _CACHE
+
+
+def needs_sudo() -> bool:
+ """Faut-il préfixer les commandes libvirt de « sudo » ?
+
+ Root n'en a jamais besoin. Sans virsh, il n'y a rien à préfixer : rendre
+ faux laisse la commande échouer sur « command not found » plutôt que sur
+ une invite de mot de passe qui ne mène nulle part.
+ """
+ if os.geteuid() == 0:
+ return False
+ if shutil.which("virsh") is None:
+ return False
+ return not libvirt_reachable()
+
+
+def sudo_prefix() -> str:
+ """« sudo » et son espace, ou la chaîne vide. À coller devant virsh."""
+ return "sudo " if needs_sudo() else ""
+
+
+# L'URI ne se laisse JAMAIS implicite. Pour un utilisateur non root, libvirt
+# choisit « qemu:///session », un hyperviseur séparé où AUCUNE des VM du
+# système n'existe : « virsh list --all » y rend une liste vide, sans erreur
+# et sans avertissement. Appartenir au groupe libvirt donne le DROIT d'accéder
+# à qemu:///system, mais ne change pas l'URI par défaut. Tant que les
+# commandes passaient par sudo, l'URI de root masquait l'omission.
+LIBVIRT_URI = "qemu:///system"
+
+
+def virsh_argv(*args: str) -> list:
+ """Argv d'un virsh local : sudo si besoin, URI toujours."""
+ prefixe = ["sudo"] if needs_sudo() else []
+ return prefixe + ["virsh", "--connect", LIBVIRT_URI, *args]
+
+
+def virsh_cmd(args: str = "") -> str:
+ """Même chose, en chaîne pour un shell. « args » est déjà échappé."""
+ base = f"{sudo_prefix()}virsh --connect {LIBVIRT_URI}"
+ return f"{base} {args}" if args else base
+
+
+def system_path(path: str = "") -> str:
+ """PATH débarrassé des répertoires de venv du projet.
+
+ TODO tourne DANS son venv, dont le « bin » est en TÊTE du PATH : chaque
+ commande lancée par le menu le voit en premier, et ce répertoire contient
+ un « python3 ». Un outil système écrit en Python et amorcé par
+ « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où les
+ modules fournis par la distribution — PyGObject, entre autres — n'existent
+ pas, et l'import échoue sur un module que le système possède pourtant.
+
+ Sous sudo le piège ne se voyait pas : sudo réinitialise le PATH par son
+ « secure_path ». Le retirer là où il n'était pas nécessaire l'a mis au
+ jour, et l'assainissement doit donc être explicite.
+ """
+ path = path or os.environ.get("PATH", "")
+ venv = os.environ.get("VIRTUAL_ENV", "")
+ gardees = []
+ for entree in path.split(os.pathsep):
+ if not entree:
+ continue
+ # Un répertoire du venv courant, ou de n'importe quel « .venv* » du
+ # dépôt : les deux mènent au même interpréteur de trop.
+ if venv and os.path.normpath(entree).startswith(
+ os.path.normpath(venv) + os.sep
+ ):
+ continue
+ if any(
+ part.startswith(".venv")
+ for part in os.path.normpath(entree).split(os.sep)
+ ):
+ continue
+ gardees.append(entree)
+ return os.pathsep.join(gardees)
+
+
+def system_env(env: dict | None = None) -> dict:
+ """Environnement où un outil système trouve l'interpréteur système."""
+ base = dict(env or os.environ)
+ base["PATH"] = system_path(base.get("PATH", ""))
+ return base
+
+
+# (gestionnaire, binaire à détecter, commande d'installation). L'ordre est
+# celui de la détection : le premier binaire présent gagne.
+PKG_INSTALL = (
+ ("apt", "apt-get", "sudo apt-get install -y"),
+ ("dnf", "dnf", "sudo dnf install -y"),
+ ("pacman", "pacman", "sudo pacman -S --needed --noconfirm"),
+ ("zypper", "zypper", "sudo zypper --non-interactive install"),
+)
+
+
+def install_cmd_for(par_gestionnaire: dict) -> tuple:
+ """(commande, paquet) pour cet hôte, ou (None, None).
+
+ `par_gestionnaire` donne le nom du paquet POUR CHAQUE gestionnaire : ces
+ noms diffèrent d'une distribution à l'autre, et celui de Debian ne marche
+ presque jamais ailleurs. Rend None sur un hôte dont le gestionnaire n'est
+ pas connu — proposer une commande qui échouera vaut moins que de dire
+ qu'on ne sait pas.
+ """
+ for cle, binaire, install in PKG_INSTALL:
+ if shutil.which(binaire) and par_gestionnaire.get(cle):
+ paquet = par_gestionnaire[cle]
+ return f"{install} {paquet}", paquet
+ return None, None
+
+
+def group_state(user: str = "") -> tuple[bool, bool]:
+ """(déclaré, actif) pour le groupe libvirt.
+
+ « déclaré » : le nom figure dans le groupe, d'après la base système.
+ « actif » : le processus courant PORTE le groupe. Les deux diffèrent tant
+ que la session n'a pas été rouverte, et c'est le cas qui déroute le plus.
+ Les deux valent faux quand le groupe n'existe pas — libvirt pas encore
+ installé.
+ """
+ user = user or _current_user()
+ try:
+ entry = grp.getgrnam("libvirt")
+ except KeyError:
+ return False, False
+ declared = user in entry.gr_mem
+ try:
+ declared = declared or grp.getgrgid(os.getgid()).gr_name == "libvirt"
+ except (KeyError, OSError):
+ pass
+ return declared, entry.gr_gid in os.getgroups()
+
+
+def _current_user() -> str:
+ """Le nom de l'utilisateur courant, sans lever si l'environnement ment."""
+ try:
+ import getpass
+
+ return getpass.getuser()
+ except Exception:
+ return os.environ.get("USER") or ""
diff --git a/script/todo/qemu_recover.py b/script/todo/qemu_recover.py
new file mode 100644
index 0000000..611435f
--- /dev/null
+++ b/script/todo/qemu_recover.py
@@ -0,0 +1,318 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Menu QEMU/KVM : récupérer des fichiers dans le disque d'une VM.
+
+Une VM qui ne démarre plus garde ses fichiers : le qcow2 est là, et libguestfs
+sait le monter SANS la machine. Le chemin complet : poser les outils selon la
+distribution de l'hôte, lister les systèmes de fichiers du disque, en choisir
+un, parcourir un répertoire, puis extraire vers l'hôte.
+
+Frontière claire : ici on LIT un disque, on n'y écrit jamais. Toute commande
+porte « --ro » — c'est ce qui rend l'opération sûre même sur une VM allumée,
+et c'est aussi pourquoi rien de ce fichier ne peut abîmer une machine.
+"""
+
+import os
+import shlex
+import shutil
+import subprocess
+
+from script.todo.qemu_privilege import (
+ install_cmd_for,
+ system_env,
+ system_path,
+)
+from script.todo.todo_i18n import t
+
+# Le paquet qui apporte guestfish, par gestionnaire de paquets. Il ne vit pas
+# dans la table du déploiement : ces outils ne servent QU'À la récupération,
+# et les poser sur chaque hôte qui déploie une VM serait du poids pour rien.
+#
+# Les noms diffèrent d'une distribution à l'autre et le nom Debian ne marche
+# nulle part ailleurs : « libguestfs-tools » sur apt, « guestfs-tools » sur
+# dnf et zypper depuis que le paquet a été scindé, « libguestfs » sur pacman.
+GUESTFS_PACKAGES = {
+ "apt": "libguestfs-tools",
+ "dnf": "guestfs-tools",
+ "pacman": "libguestfs",
+ "zypper": "guestfs-tools",
+}
+
+# guestfish suffit à tout faire ; les deux autres ne font que présenter mieux
+# ce qu'il montre déjà. Les distinguer évite d'exiger un paquet complet là où
+# l'essentiel est présent.
+GUESTFS_BIN_ESSENTIEL = "guestfish"
+GUESTFS_BIN_CONFORT = ("virt-filesystems", "virt-df")
+
+
+def guestfs_install_cmd():
+ """(commande d'installation, paquet) pour cet hôte, ou (None, None).
+
+ macOS y rend None comme tout hôte au gestionnaire inconnu, et c'est juste :
+ libguestfs n'y tourne pas.
+ """
+ return install_cmd_for(GUESTFS_PACKAGES)
+
+
+class QemuRecoverMixin:
+ """Menu QEMU/KVM : récupérer des fichiers dans le disque d'une VM.
+
+ Une VM qui ne démarre plus garde ses fichiers : le qcow2 est là, et
+ libguestfs sait le monter SANS la machine. Le chemin complet : poser les
+ outils selon la distribution de l'hôte, lister les systèmes de fichiers du
+ disque, en choisir un, parcourir un répertoire, puis extraire vers l'hôte.
+
+ Frontière claire : ici on LIT un disque, on n'y écrit jamais.
+ """
+
+ def _qemu_guestfish_cmd(self, disk, *commandes):
+ """Commande guestfish en LECTURE SEULE sur ce disque.
+
+ « --ro » n'est pas une précaution parmi d'autres : sans lui, ouvrir le
+ disque d'une VM allumée corrompt son système de fichiers. Avec lui, la
+ lecture est sûre à tout moment — au pire elle voit un instantané
+ incohérent, ce que le menu annonce.
+ """
+ parties = " : ".join(commandes)
+ return (
+ f"guestfish --ro -a {shlex.quote(str(disk))}"
+ f"{' ' + parties if parties else ''}"
+ )
+
+ def _qemu_guestfish_lines(self, disk, *commandes, timeout=180):
+ """Lignes rendues par guestfish, ou [] s'il échoue.
+
+ L'appliance libguestfs démarre un noyau : compter en secondes, pas en
+ dixièmes. Un timeout généreux vaut mieux qu'un échec sur une machine
+ chargée.
+ """
+ cmd = self._qemu_guestfish_cmd(disk, *commandes)
+ try:
+ res = subprocess.run(
+ cmd,
+ shell=True,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ env=system_env(),
+ )
+ except (OSError, subprocess.SubprocessError):
+ return []
+ if res.returncode != 0:
+ if res.stderr:
+ print(f" ⚠ {res.stderr.strip()[:300]}")
+ return []
+ return [ln for ln in (res.stdout or "").splitlines() if ln.strip()]
+
+ def _qemu_recover_ensure_tools(self):
+ """guestfish est-il là ? Sinon, proposer de l'installer. Rend un bool.
+
+ L'installation est PROPOSÉE et la commande montrée avant : elle pose
+ un paquet de plusieurs centaines de mégaoctets, avec une image noyau.
+ """
+ manque_essentiel = shutil.which(GUESTFS_BIN_ESSENTIEL) is None
+ confort = [b for b in GUESTFS_BIN_CONFORT if shutil.which(b) is None]
+ if not manque_essentiel and not confort:
+ return True
+ install, paquet = guestfs_install_cmd()
+ if manque_essentiel:
+ print(f"\n⚠ {t('guestfish is missing: libguestfs is not here.')}")
+ else:
+ print(f"\n⚠ {t('Some libguestfs helpers are missing:')}")
+ print(f" {' '.join(confort)}")
+ if not install:
+ print(f" {t('Unknown package manager: install libguestfs.')}")
+ return not manque_essentiel
+ print(f" {t('Will execute:')} {install}")
+ if not self._is_yes_default_yes(
+ input(t("Install libguestfs now? (Y/n): "))
+ ):
+ return not manque_essentiel
+ self.execute.exec_command_live(
+ install, source_erplibre=False, new_env={"PATH": system_path()}
+ )
+ return shutil.which(GUESTFS_BIN_ESSENTIEL) is not None
+
+ def _qemu_recover_ready(self, name):
+ """La VM est-elle dans un état où la lecture a du sens ? Rend un bool.
+
+ Une VM ALLUMÉE écrit pendant qu'on lit : la copie voit un instantané
+ qui peut être incohérent — un fichier à moitié écrit, un journal non
+ rejoué. Ce n'est pas dangereux en « --ro », mais il faut le dire, et
+ proposer l'arrêt propre qui rend la lecture fidèle.
+ """
+ etat = self._qemu_domstate(name)
+ if etat != "running":
+ return True
+ print(f"\n⚠ {t('This VM is running.')}")
+ print(f" {t('Reading a live disk sees a possibly torn state:')}")
+ print(f" {t('a half-written file, an unreplayed journal.')}")
+ choix = self._qemu_pick(
+ t("What do you want to do?"),
+ ["read", "shutdown", "cancel"],
+ "shutdown",
+ [
+ t("Read anyway (read-only, no risk for the VM)"),
+ t("Shut the VM down cleanly, then read"),
+ t("Cancel"),
+ ],
+ )
+ if choix == "cancel":
+ return False
+ if choix == "read":
+ return True
+ return self._qemu_shutdown_wait(name)
+
+ def _qemu_recover_pick_filesystem(self, disk):
+ """Système de fichiers choisi dans le disque, ou "" si aucun.
+
+ « list-filesystems » les rend tous, y compris ceux qu'on ne peut pas
+ monter — swap, partitions vides. Les montrer quand même : leur absence
+ de la liste inquiéterait plus qu'elle n'aiderait.
+ """
+ cmd = self._qemu_guestfish_cmd(disk, "run", "list-filesystems")
+ print(f"\n{t('Will execute:')} {cmd}")
+ lignes = self._qemu_guestfish_lines(disk, "run", "list-filesystems")
+ if not lignes:
+ print(f" {t('No filesystem found on this disk.')}")
+ return ""
+ parts, labels = [], []
+ for ligne in lignes:
+ # « /dev/sda3: ext4 » — le nom, puis son type.
+ dev, _, typ = ligne.partition(":")
+ dev, typ = dev.strip(), typ.strip()
+ if not dev:
+ continue
+ parts.append(dev)
+ labels.append(f"{dev} {typ}")
+ if not parts:
+ return ""
+ # Le plus grand système de fichiers non-swap est presque toujours la
+ # racine : le proposer par défaut épargne un choix à qui ne connaît
+ # pas le partitionnement de sa VM.
+ defaut = next(
+ (p for p, lab in zip(parts, labels) if "swap" not in lab),
+ parts[0],
+ )
+ return self._qemu_pick(t("Filesystem to mount"), parts, defaut, labels)
+
+ def _qemu_recover_browse(self, disk, part, chemin):
+ """Liste un répertoire du système de fichiers monté.
+
+ Rend les entrées trouvées."""
+ cmd = self._qemu_guestfish_cmd(
+ disk, "run", f"mount {part} /", f"ls {shlex.quote(chemin)}"
+ )
+ print(f"\n{t('Will execute:')} {cmd}")
+ entrees = self._qemu_guestfish_lines(
+ disk, "run", f"mount {part} /", f"ls {shlex.quote(chemin)}"
+ )
+ if not entrees:
+ print(f" {t('Empty or unreadable directory.')}")
+ return []
+ for entree in entrees:
+ print(f" {entree}")
+ print(f" {t('Total:')} {len(entrees)}")
+ return entrees
+
+ def _qemu_recover_copy_out(self, disk, part, source, dest):
+ """Extrait un chemin de la VM vers l'hôte. Rend un bool.
+
+ « copy-out » écrit DANS le répertoire de destination : il faut donc
+ qu'il existe, sinon guestfish s'arrête sur une erreur qui ne dit pas
+ laquelle des deux extrémités manque.
+ """
+ try:
+ os.makedirs(dest, exist_ok=True)
+ except OSError as exc:
+ print(f" ⚠ {t('Cannot create the destination: ')}{exc}")
+ return False
+ cmd = self._qemu_guestfish_cmd(
+ disk,
+ "run",
+ f"mount {part} /",
+ f"copy-out {shlex.quote(source)} {shlex.quote(dest)}",
+ )
+ print(f"\n{t('Will execute:')} {cmd}")
+ code = self.execute.exec_command_live(
+ cmd, source_erplibre=False, new_env={"PATH": system_path()}
+ )
+ if code:
+ print(f" ⚠ {t('Extraction failed.')}")
+ return False
+ cible = os.path.join(dest, os.path.basename(source.rstrip("/")))
+ print(f"\n✅ {t('Extracted to: ')}{cible}")
+ return True
+
+ def _qemu_recover_diagnostics(self, disk):
+ """Commandes de diagnostic sur le disque, sans rien monter à la main.
+
+ Chacune répond à une question précise qu'on se pose quand la lecture
+ ne donne pas ce qu'on attend : les partitions existent-elles, restent-
+ elles de la place, le système est-il reconnu, l'appliance
+ démarre-t-elle.
+ """
+ q = shlex.quote(str(disk))
+ sondes = [
+ (
+ t("Partitions and sizes"),
+ f"virt-filesystems -a {q} --long -h --all",
+ ),
+ (t("Free space per filesystem"), f"virt-df -a {q} -h"),
+ (
+ t("Detected operating system"),
+ self._qemu_guestfish_cmd(disk, "run", "inspect-os"),
+ ),
+ (
+ t("Does the libguestfs appliance boot?"),
+ "libguestfs-test-tool",
+ ),
+ ]
+ for titre, cmd in sondes:
+ print(f"\n── {titre} ──")
+ print(f"{t('Will execute:')} {cmd}")
+ self.execute.exec_command_live(
+ cmd, source_erplibre=False, new_env={"PATH": system_path()}
+ )
+
+ def _qemu_recover_files(self):
+ """Récupère des fichiers dans le disque d'une VM, sans la démarrer."""
+ print(f"\n💾 {t('Recover files from a VM disk (libguestfs)')}")
+ if not self._qemu_recover_ensure_tools():
+ return
+ self._qemu_list_vms()
+ print()
+ name = input(t("VM name or ID: ")).strip()
+ if not name:
+ print(t("VM name is required!"))
+ return
+ name = self._qemu_domname(name)
+ disk = self._qemu_main_disk(name)
+ if not disk:
+ print(f" ⚠ {t('No disk found for this VM.')}")
+ return
+ print(f" {t('Disk:')} {disk}")
+ if not self._qemu_recover_ready(name):
+ print(t("Cancelled."))
+ return
+ if self._is_yes(input(t("Run the diagnostics first? (y/N): "))):
+ self._qemu_recover_diagnostics(disk)
+ part = self._qemu_recover_pick_filesystem(disk)
+ if not part:
+ return
+ chemin = input(t("Directory to list [/]: ")).strip() or "/"
+ while True:
+ self._qemu_recover_browse(disk, part, chemin)
+ suite = input(
+ t("Another directory, or Enter to extract: ")
+ ).strip()
+ if not suite:
+ break
+ chemin = suite
+ source = input(f"{t('Path to extract')} [{chemin}] : ").strip()
+ source = source or chemin
+ defaut_dest = f"/tmp/{name}-backup"
+ dest = input(f"{t('Destination on the host')} [{defaut_dest}] : ")
+ dest = dest.strip() or defaut_dest
+ self._qemu_recover_copy_out(disk, part, source, dest)
diff --git a/script/todo/todo.py b/script/todo/todo.py
index 512e509..e579ad1 100755
--- a/script/todo/todo.py
+++ b/script/todo/todo.py
@@ -26,8 +26,9 @@ sys.path.append(new_path)
from script.config import config_file
from script.execute import execute
-from script.todo import todo_install, todo_prefs
+from script.todo import dev_tools, todo_install, todo_prefs
from script.todo.database_manager import DatabaseManager
+from script.todo.kdbx_manager import KdbxManager
from script.todo.longtest_menu import LongTestMenuMixin
from script.todo.proxmox_menu import ProxmoxMenuMixin
from script.todo.qemu_access import QemuAccessMixin
@@ -35,7 +36,7 @@ from script.todo.qemu_deploy import QemuDeployMixin
from script.todo.qemu_install import QemuInstallMixin
from script.todo.qemu_manage import QemuManageMixin
from script.todo.qemu_menu import QemuMenuMixin
-from script.todo.kdbx_manager import KdbxManager
+from script.todo.qemu_recover import QemuRecoverMixin
from script.todo.todo_i18n import get_lang, lang_is_configured, set_lang, t
from script.todo.version_manager import get_odoo_version
@@ -95,6 +96,7 @@ class TODO(
QemuDeployMixin,
QemuInstallMixin,
QemuManageMixin,
+ QemuRecoverMixin,
QemuAccessMixin,
ProxmoxMenuMixin,
LongTestMenuMixin,
@@ -151,7 +153,7 @@ class TODO(
[4] {t("Fork - Open TODO in a new tab")}
[5] {t("Navigation telemetry (TUI)")}
[6] {t("Configuration")}
-[0] {t("Quit")}
+[0] 🚪 {t("Quit")}
"""
while True:
try:
@@ -2709,32 +2711,12 @@ class TODO(
"fish": "~/.config/fish/config.fish",
}
- # Ce que chaque shell écrit pour lancer starship. La ligne va en FIN de
- # fichier : starship compose le prompt et doit passer après tout ce qui y
- # touche.
- _STARSHIP_LINE = {
- "bash": 'eval "$(starship init bash)"',
- "zsh": 'eval "$(starship init zsh)"',
- "fish": "starship init fish | source",
- }
-
- # L'installateur amont pose un binaire statique. Il sert de recours parce
- # que le paquet manque d'une partie des dépôts des plateformes supportées.
- _STARSHIP_UPSTREAM = "curl -sS https://starship.rs/install/install.sh | sh"
-
- # Les assistants posés par un installateur amont : le nom du binaire mène
- # à (commande, répertoire d'installation). Le répertoire sert à garantir
- # le PATH — un binaire posé hors des chemins du shell reste introuvable.
- _UPSTREAM_TOOLS = {
- "claude": (
- "curl -fsSL https://claude.ai/install.sh | bash",
- "~/.local/bin",
- ),
- "opencode": (
- "curl -fsSL https://opencode.ai/install | bash",
- "~/.opencode/bin",
- ),
- }
+ # Ces quatre tables vivent dans dev_tools : le déploiement QEMU pose les
+ # mêmes outils DANS une VM, et deux copies d'une URL amont dérivent dès
+ # que l'une change. Les noms de classe restent, ils sont l'interface.
+ _STARSHIP_LINE = dev_tools.STARSHIP_LINE
+ _STARSHIP_UPSTREAM = dev_tools.STARSHIP_UPSTREAM
+ _UPSTREAM_TOOLS = dev_tools.AGENTS
@staticmethod
def _shell_name():
@@ -3390,7 +3372,7 @@ class TODO(
"Show a plugin detail and its token cost"
)
},
- {"section": t("Install")},
+ {"section": t("Install plugins")},
{"prompt_description": t("Install the ERPLibre preferred list")},
{"prompt_description": t("Install a plugin by name")},
{"prompt_description": t("Add a marketplace")},
diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py
index 8325d41..4339082 100644
--- a/script/todo/todo_i18n.py
+++ b/script/todo/todo_i18n.py
@@ -97,8 +97,8 @@ TRANSLATIONS = {
"en": "🔒 Security - Dependency security audit",
},
"RTK - CLI proxy to reduce LLM token consumption": {
- "fr": "RTK - Proxy CLI pour réduire la consommation de tokens LLM",
- "en": "RTK - CLI proxy to reduce LLM token consumption",
+ "fr": "🪙 RTK - Proxy CLI pour réduire la consommation de tokens LLM",
+ "en": "🪙 RTK - CLI proxy to reduce LLM token consumption",
},
"Language - Change language / Changer la langue": {
"fr": "🌍 Langue - Changer la langue / Change language",
@@ -424,20 +424,20 @@ TRANSLATIONS = {
"en": "Do you want a new instance?",
},
"SSH port-forwarding": {
- "fr": "SSH port-forwarding",
- "en": "SSH port-forwarding",
+ "fr": "🚇 SSH port-forwarding",
+ "en": "🚇 SSH port-forwarding",
},
"Network performance request per second": {
- "fr": "Performance réseau en requêtes par seconde",
- "en": "Network performance request per second",
+ "fr": "📶 Performance réseau en requêtes par seconde",
+ "en": "📶 Network performance request per second",
},
"Setup queue job for parallelism": {
- "fr": "Configurer la file d'attente pour l'exécution parallèle",
- "en": "Setup queue job for parallelism",
+ "fr": "⚡ Configurer la file d'attente pour l'exécution parallèle",
+ "en": "⚡ Setup queue job for parallelism",
},
"Choose your database": {
- "fr": "Choisir sa base de données",
- "en": "Choose your database",
+ "fr": "🗃 Choisir sa base de données",
+ "en": "🗃 Choose your database",
},
"Development update": {
"fr": "Mise à jour du développement",
@@ -457,8 +457,8 @@ TRANSLATIONS = {
},
# Database drop / erase
"Erase a database": {
- "fr": "Effacer une base de données",
- "en": "Erase a database",
+ "fr": "🗑 Effacer une base de données",
+ "en": "🗑 Erase a database",
},
"Erase a database — irreversible operation!": {
"fr": "Effacer une base de données — opération irréversible !",
@@ -595,16 +595,16 @@ TRANSLATIONS = {
},
# todo.json translatable prompt_descriptions
"Test - Minimal base instance": {
- "fr": "Test - Instance de base minimale",
- "en": "Test - Minimal base instance",
+ "fr": "🧪 Test - Instance de base minimale",
+ "en": "🧪 Test - Minimal base instance",
},
"Open RobotLibre 🤖 minimal": {
- "fr": "Ouvrir RobotLibre 🤖 minimal",
- "en": "Open RobotLibre 🤖 minimal",
+ "fr": "▶ Ouvrir RobotLibre 🤖 minimal",
+ "en": "▶ Open RobotLibre 🤖 minimal",
},
"Open RobotLibre 🤖 with search enabled": {
- "fr": "Ouvrir RobotLibre 🤖 en activant la recherche",
- "en": "Open RobotLibre 🤖 with search enabled",
+ "fr": "▶ Ouvrir RobotLibre 🤖 en activant la recherche",
+ "en": "▶ Open RobotLibre 🤖 with search enabled",
},
"Open ERPLibre with TODO 🤖": {
"fr": "Ouvrir ERPLibre avec TODO 🤖",
@@ -652,60 +652,63 @@ TRANSLATIONS = {
"en": "🐛 Debug",
},
"Migration module coverage": {
- "fr": "Couverture de migration des modules",
- "en": "Migration module coverage",
+ "fr": "📈 Couverture de migration des modules",
+ "en": "📈 Migration module coverage",
},
"What change between version": {
- "fr": "Quels changements entre les versions",
- "en": "What change between version",
+ "fr": "🆕 Quels changements entre les versions",
+ "en": "🆕 What change between version",
},
"OCA guidelines": {
- "fr": "Directives OCA",
- "en": "OCA guidelines",
+ "fr": "📖 Directives OCA",
+ "en": "📖 OCA guidelines",
},
"OCA migration Odoo 19 milestone": {
- "fr": "Migration OCA Odoo 19 - Jalons",
- "en": "OCA migration Odoo 19 milestone",
+ "fr": "🎯 Migration OCA Odoo 19 - Jalons",
+ "en": "🎯 OCA migration Odoo 19 milestone",
},
"Download database to create backup (.zip)": {
- "fr": "Télécharger une base de données pour créer une sauvegarde (.zip)",
- "en": "Download database to create backup (.zip)",
+ "fr": (
+ "⬇ Télécharger une base de données pour créer une"
+ " sauvegarde (.zip)"
+ ),
+ "en": "⬇ Download database to create backup (.zip)",
},
"Restore from backup (.zip)": {
- "fr": "Restaurer a partir d'une sauvegarde (.zip)",
- "en": "Restore from backup (.zip)",
+ "fr": "♻ Restaurer a partir d'une sauvegarde (.zip)",
+ "en": "♻ Restore from backup (.zip)",
},
"Create backup (.zip)": {
- "fr": "Créer une sauvegarde (.zip)",
- "en": "Create backup (.zip)",
+ "fr": "💾 Créer une sauvegarde (.zip)",
+ "en": "💾 Create backup (.zip)",
},
"Kill Odoo process from actual port": {
- "fr": "Terminer le processus Odoo du port actuel",
- "en": "Kill Odoo process from actual port",
+ "fr": "🛑 Terminer le processus Odoo du port actuel",
+ "en": "🛑 Kill Odoo process from actual port",
},
"Kill git daemon server process": {
- "fr": "Terminer le processus du serveur git daemon",
- "en": "Kill git daemon server process",
+ "fr": "🛑 Terminer le processus du serveur git daemon",
+ "en": "🛑 Kill git daemon server process",
},
"Git daemon process killed.": {
"fr": "Processus git daemon terminé.",
"en": "Git daemon process killed.",
},
"Generate all configuration": {
- "fr": "Générer toute la configuration",
- "en": "Generate all configuration",
+ "fr": "🧱 Générer toute la configuration",
+ "en": "🧱 Generate all configuration",
},
"Generate from pre-configuration": {
- "fr": "Générer a partir de la pre-configuration",
- "en": "Generate from pre-configuration",
+ "fr": "📋 Générer a partir de la pre-configuration",
+ "en": "📋 Generate from pre-configuration",
},
"Generate from backup file": {
- "fr": "Générer a partir d'un fichier de sauvegarde",
- "en": "Generate from backup file",
+ "fr": "💾 Générer a partir d'un fichier de sauvegarde",
+ "en": "💾 Generate from backup file",
},
"Generate from database": {
- "fr": "Générer a partir de la base de données",
- "en": "Generate from database",
+ "fr": "🗄 Générer a partir de la base de données",
+ "en": "🗄 Generate from database",
},
"base": {
"fr": "base",
@@ -737,24 +740,24 @@ TRANSLATIONS = {
"en": "Test an Odoo module on a temporary database!",
},
"Test a module": {
- "fr": "Tester un module",
- "en": "Test a module",
+ "fr": "🧪 Tester un module",
+ "en": "🧪 Test a module",
},
"Test a module with code coverage": {
- "fr": "Tester un module avec couverture de code",
- "en": "Test a module with code coverage",
+ "fr": "📊 Tester un module avec couverture de code",
+ "en": "📊 Test a module with code coverage",
},
"ERPLibre unit tests": {
- "fr": "Tests unitaires ERPLibre",
- "en": "ERPLibre unit tests",
+ "fr": "⚗ Tests unitaires ERPLibre",
+ "en": "⚗ ERPLibre unit tests",
},
"Mail unit tests": {
- "fr": "Tests unitaires courriel",
- "en": "Mail unit tests",
+ "fr": "📧 Tests unitaires courriel",
+ "en": "📧 Mail unit tests",
},
"Analyse unit tests": {
- "fr": "Tests unitaires analyse",
- "en": "Analyse unit tests",
+ "fr": "🔍 Tests unitaires analyse",
+ "en": "🔍 Analyse unit tests",
},
"Running unit tests": {
"fr": "Exécution des tests unitaires",
@@ -1022,8 +1025,8 @@ TRANSLATIONS = {
},
# GPT code - Claude automation
"Add an automation with Claude in todo.py": {
- "fr": "Ajouter une automatisation avec Claude dans todo.py",
- "en": "Add an automation with Claude in todo.py",
+ "fr": "🤖 Ajouter une automatisation avec Claude dans todo.py",
+ "en": "🤖 Add an automation with Claude in todo.py",
},
"Description of the command to add: ": {
"fr": "Description de la commande à ajouter : ",
@@ -1169,29 +1172,31 @@ TRANSLATIONS = {
"en": "AI assistant tools for development!",
},
"Configure Claude Code configurations": {
- "fr": "Configurer les configurations Claude Code",
- "en": "Configure Claude Code configurations",
+ "fr": "🛠 Configurer les configurations Claude Code",
+ "en": "🛠 Configure Claude Code configurations",
},
"Commit - OCA/Odoo commit command": {
- "fr": "Commit - Commande de commit OCA/Odoo",
- "en": "Commit - OCA/Odoo commit command",
+ "fr": "💬 Commit - Commande de commit OCA/Odoo",
+ "en": "💬 Commit - OCA/Odoo commit command",
},
"Git prepare merge - Git merge preparation command": {
- "fr": "Git prepare merge - Commande de préparation merge git",
- "en": "Git prepare merge - Git merge preparation command",
+ "fr": "🤝 Git prepare merge - Commande de préparation merge git",
+ "en": "🤝 Git prepare merge - Git merge preparation command",
},
"Todo Add Command + Plan Max - Plan and add a todo.py command": {
"fr": (
- "Todo Add Command + Plan Max - Planifier et ajouter une"
+ "🧭 Todo Add Command + Plan Max - Planifier et ajouter une"
" commande au menu todo.py"
),
- "en": "Todo Add Command + Plan Max - Plan and add a todo.py command",
+ "en": (
+ "🧭 Todo Add Command + Plan Max - Plan and add a todo.py command"
+ ),
},
"Todo Generate Code - Code by the OCA rules at high effort": {
"fr": (
- "Todo Generate Code - Coder selon les règles OCA, effort élevé"
+ "🏭 Todo Generate Code - Coder selon les règles OCA, effort élevé"
),
- "en": "Todo Generate Code - Code by the OCA rules at high effort",
+ "en": "🏭 Todo Generate Code - Code by the OCA rules at high effort",
},
"Enter your full name: ": {
"fr": "Entrez votre nom complet : ",
@@ -1207,8 +1212,8 @@ TRANSLATIONS = {
},
# Claude Code plugins
"Claude Code plugins - marketplaces and ERPLibre list": {
- "fr": "Plugins Claude Code - marketplaces et liste ERPLibre",
- "en": "Claude Code plugins - marketplaces and ERPLibre list",
+ "fr": "🧩 Plugins Claude Code - marketplaces et liste ERPLibre",
+ "en": "🧩 Claude Code plugins - marketplaces and ERPLibre list",
},
"Manage Claude Code plugins and marketplaces!": {
"fr": "Gérer les plugins et les marketplaces de Claude Code!",
@@ -1234,9 +1239,9 @@ TRANSLATIONS = {
"fr": "Afficher le détail d'un plugin et son coût en tokens",
"en": "Show a plugin detail and its token cost",
},
- "Install": {
- "fr": "Installer",
- "en": "Install",
+ "Install plugins": {
+ "fr": "Installer des plugins",
+ "en": "Install plugins",
},
"Install the ERPLibre preferred list": {
"fr": "Installer la liste préférée d'ERPLibre",
@@ -1323,8 +1328,8 @@ TRANSLATIONS = {
"en": "A restart of Claude Code applies the change.",
},
"Show installed custom commands": {
- "fr": "Afficher les commandes personnalisées installées",
- "en": "Show installed custom commands",
+ "fr": "📇 Afficher les commandes personnalisées installées",
+ "en": "📇 Show installed custom commands",
},
"No custom commands found in ~/.claude/commands/": {
"fr": "Aucune commande personnalisée trouvée dans ~/.claude/commands/",
@@ -1626,8 +1631,8 @@ TRANSLATIONS = {
"en": "📊 Navigation telemetry (TUI)",
},
"Configuration": {
- "fr": "⚙ Configuration",
- "en": "⚙ Configuration",
+ "fr": "🔩 Configuration",
+ "en": "🔩 Configuration",
},
"Interface": {
"fr": "Interface",
@@ -4961,6 +4966,316 @@ TRANSLATIONS = {
"fr": "Logs :",
"en": "Logs:",
},
+ "3D was on; retry without it:": {
+ "fr": "La 3D était active ; réessayer sans elle :",
+ "en": "3D was on; retry without it:",
+ },
+ "Recommended before installing:": {
+ "fr": "Recommandé avant d'installer :",
+ "en": "Recommended before installing:",
+ },
+ "Grant libvirt access, or every VM command will": {
+ "fr": "Donner l'accès à libvirt, sinon chaque commande VM",
+ "en": "Grant libvirt access, or every VM command will",
+ },
+ "ask for a sudo password:": {
+ "fr": "demandera un mot de passe sudo :",
+ "en": "ask for a sudo password:",
+ },
+ "then log out and back in.": {
+ "fr": "puis se déconnecter et se reconnecter.",
+ "en": "then log out and back in.",
+ },
+ "Orphan disks that would fail the deployment": {
+ "fr": "Disques orphelins qui feraient échouer le déploiement",
+ "en": "Orphan disks that would fail the deployment",
+ },
+ "Delete them and continue? (y/N): ": {
+ "fr": "Les effacer et continuer ? (o/N) : ",
+ "en": "Delete them and continue? (y/N, default: no): ",
+ },
+ "Kept - the deployment of these VMs will FAIL:": {
+ "fr": "Conservés — le déploiement de ces VM ÉCHOUERA :",
+ "en": "Kept - the deployment of these VMs will FAIL:",
+ },
+ "3D acceleration (host GPU), even without a screen": {
+ "fr": "Accélération 3D (GPU de l'hôte), même sans écran",
+ "en": "3D acceleration (host GPU), even without a screen",
+ },
+ "3D acceleration (host GPU), even without a screen? (y/N): ": {
+ "fr": "Accélération 3D (GPU de l'hôte), même sans écran ? (o/N) : ",
+ "en": (
+ "3D acceleration (host GPU), even without a screen?"
+ " (y/N, default: no): "
+ ),
+ },
+ # Récupération de fichiers dans le disque d'une VM (libguestfs)
+ # Outils d'assistance posés dans une VM au déploiement.
+ "AI coding tools": {
+ "fr": "Outils d'assistance IA",
+ "en": "AI coding tools",
+ },
+ "rtk, starship, and one agent": {
+ "fr": "rtk, starship, et un agent",
+ "en": "rtk, starship, and one agent",
+ },
+ "Name for git": {
+ "fr": "Nom pour git",
+ "en": "Name for git",
+ },
+ "Email for git": {
+ "fr": "Courriel pour git",
+ "en": "Email for git",
+ },
+ "3D per VM": {
+ "fr": "3D par VM",
+ "en": "3D per VM",
+ },
+ "no video device": {
+ "fr": "aucun périphérique vidéo",
+ "en": "no video device",
+ },
+ "not pinned": {
+ "fr": "non figé",
+ "en": "not pinned",
+ },
+ "non-GL device pinned: 3D will not start": {
+ "fr": "device sans GL figé : la 3D ne démarrera pas",
+ "en": "non-GL device pinned: 3D will not start",
+ },
+ "To unpin, VM stopped:": {
+ "fr": "Pour défiger, la VM arrêtée :",
+ "en": "To unpin, VM stopped:",
+ },
+ # Une VM dont libvirt a figé le device vidéo sur une variante sans GL
+ # tourne sans 3D, même avec « accel3d=yes » dans sa définition.
+ "frozen on": {
+ "fr": "figée sur",
+ "en": "frozen on",
+ },
+ "Recover files from a VM disk (libguestfs)": {
+ "fr": "💾 Récupérer des fichiers dans le disque d'une VM (libguestfs)",
+ "en": "💾 Recover files from a VM disk (libguestfs)",
+ },
+ "guestfish is missing: libguestfs is not here.": {
+ "fr": "guestfish est absent : libguestfs n'est pas installé ici.",
+ "en": "guestfish is missing: libguestfs is not here.",
+ },
+ "Some libguestfs helpers are missing:": {
+ "fr": "Des utilitaires libguestfs manquent :",
+ "en": "Some libguestfs helpers are missing:",
+ },
+ "Unknown package manager: install libguestfs.": {
+ "fr": "Gestionnaire de paquets inconnu : installez libguestfs.",
+ "en": "Unknown package manager: install libguestfs.",
+ },
+ "Install libguestfs now? (Y/n): ": {
+ "fr": "Installer libguestfs maintenant ? (O/n) : ",
+ "en": "Install libguestfs now? (Y/n, default: yes): ",
+ },
+ "This VM is running.": {
+ "fr": "Cette VM est allumée.",
+ "en": "This VM is running.",
+ },
+ "Reading a live disk sees a possibly torn state:": {
+ "fr": "Lire un disque vivant montre un état peut-être incohérent :",
+ "en": "Reading a live disk sees a possibly torn state:",
+ },
+ "a half-written file, an unreplayed journal.": {
+ "fr": "un fichier à moitié écrit, un journal non rejoué.",
+ "en": "a half-written file, an unreplayed journal.",
+ },
+ "Read anyway (read-only, no risk for the VM)": {
+ "fr": "Lire quand même (lecture seule, sans risque pour la VM)",
+ "en": "Read anyway (read-only, no risk for the VM)",
+ },
+ "Shut the VM down cleanly, then read": {
+ "fr": "Éteindre proprement la VM, puis lire",
+ "en": "Shut the VM down cleanly, then read",
+ },
+ "No filesystem found on this disk.": {
+ "fr": "Aucun système de fichiers trouvé sur ce disque.",
+ "en": "No filesystem found on this disk.",
+ },
+ "Filesystem to mount": {
+ "fr": "Système de fichiers à monter",
+ "en": "Filesystem to mount",
+ },
+ "Empty or unreadable directory.": {
+ "fr": "Répertoire vide ou illisible.",
+ "en": "Empty or unreadable directory.",
+ },
+ "Cannot create the destination: ": {
+ "fr": "Impossible de créer la destination : ",
+ "en": "Cannot create the destination: ",
+ },
+ "Extraction failed.": {
+ "fr": "L'extraction a échoué.",
+ "en": "Extraction failed.",
+ },
+ "Extracted to: ": {
+ "fr": "Extrait vers : ",
+ "en": "Extracted to: ",
+ },
+ "Partitions and sizes": {
+ "fr": "Partitions et tailles",
+ "en": "Partitions and sizes",
+ },
+ "Free space per filesystem": {
+ "fr": "Espace libre par système de fichiers",
+ "en": "Free space per filesystem",
+ },
+ "Detected operating system": {
+ "fr": "Système d'exploitation détecté",
+ "en": "Detected operating system",
+ },
+ "Does the libguestfs appliance boot?": {
+ "fr": "L'appliance libguestfs démarre-t-elle ?",
+ "en": "Does the libguestfs appliance boot?",
+ },
+ "No disk found for this VM.": {
+ "fr": "Aucun disque trouvé pour cette VM.",
+ "en": "No disk found for this VM.",
+ },
+ "Disk:": {
+ "fr": "Disque :",
+ "en": "Disk:",
+ },
+ "Run the diagnostics first? (y/N): ": {
+ "fr": "Lancer d'abord les diagnostics ? (o/N) : ",
+ "en": "Run the diagnostics first? (y/N, default: no): ",
+ },
+ "Directory to list [/]: ": {
+ "fr": "Répertoire à lister [/] : ",
+ "en": "Directory to list [/]: ",
+ },
+ "Another directory, or Enter to extract: ": {
+ "fr": "Autre répertoire, ou Entrée pour extraire : ",
+ "en": "Another directory, or Enter to extract: ",
+ },
+ "Path to extract": {
+ "fr": "Chemin à extraire",
+ "en": "Path to extract",
+ },
+ "Destination on the host": {
+ "fr": "Destination sur l'hôte",
+ "en": "Destination on the host",
+ },
+ "EGL does not start on this host GPU.": {
+ "fr": "EGL ne démarre pas sur le GPU de cet hôte.",
+ "en": "EGL does not start on this host GPU.",
+ },
+ "The VM cannot boot while 3D stays in its definition.": {
+ "fr": (
+ "La VM ne démarrera pas tant que la 3D reste dans sa"
+ " définition."
+ ),
+ "en": "The VM cannot boot while 3D stays in its definition.",
+ },
+ "Remove the 3D and start again? (Y/n): ": {
+ "fr": "Retirer la 3D et redémarrer ? (O/n) : ",
+ "en": "Remove the 3D and start again? (Y/n, default: yes): ",
+ },
+ "3D on this host": {
+ "fr": "La 3D sur cet hôte",
+ "en": "3D on this host",
+ },
+ "Render node:": {
+ "fr": "Nœud de rendu :",
+ "en": "Render node:",
+ },
+ "Check EGL itself on that node:": {
+ "fr": "Vérifier EGL lui-même sur ce nœud :",
+ "en": "Check EGL itself on that node:",
+ },
+ "VM access": {
+ "fr": "🔌 Accès aux VM",
+ "en": "🔌 VM access",
+ },
+ "Troubleshoot": {
+ "fr": "🩺 Dépanner",
+ "en": "🩺 Troubleshoot",
+ },
+ "Diagnostics (report to share)": {
+ "fr": "🩺 Diagnostic (rapport à transmettre)",
+ "en": "🩺 Diagnostics (report to share)",
+ },
+ "Collecting the diagnostics...": {
+ "fr": "Relevé du diagnostic en cours…",
+ "en": "Collecting the diagnostics...",
+ },
+ "Cannot write the report: ": {
+ "fr": "Impossible d'écrire le rapport : ",
+ "en": "Cannot write the report: ",
+ },
+ "Report written to: ": {
+ "fr": "Rapport écrit dans : ",
+ "en": "Report written to: ",
+ },
+ "It names this host, its paths and its addresses.": {
+ "fr": "Il nomme cette machine, ses chemins et ses adresses.",
+ "en": "It names this host, its paths and its addresses.",
+ },
+ "Read it before sharing it.": {
+ "fr": "Relisez-le avant de le transmettre.",
+ "en": "Read it before sharing it.",
+ },
+ "These tools would complete the report:": {
+ "fr": "Ces outils compléteraient le rapport :",
+ "en": "These tools would complete the report:",
+ },
+ "unknown package manager": {
+ "fr": "gestionnaire de paquets inconnu",
+ "en": "unknown package manager",
+ },
+ "Install them now? (y/N): ": {
+ "fr": "Les installer maintenant ? (o/N) : ",
+ "en": "Install them now? (y/N, default: no): ",
+ },
+ "Run the diagnostics again for a fuller report.": {
+ "fr": "Relancez le diagnostic pour un rapport plus complet.",
+ "en": "Run the diagnostics again for a fuller report.",
+ },
+ "Devices QEMU may open": {
+ "fr": "Périphériques que QEMU peut ouvrir",
+ "en": "Devices QEMU may open",
+ },
+ "Unreadable, so this may already be set:": {
+ "fr": "Illisible, donc peut-être déjà réglé :",
+ "en": "Unreadable, so this may already be set:",
+ },
+ "A list exists: ADD to it, never replace it.": {
+ "fr": "Une liste existe : y AJOUTER, ne jamais la remplacer.",
+ "en": "A list exists: ADD to it, never replace it.",
+ },
+ "In": {
+ "fr": "Dans",
+ "en": "In",
+ },
+ "Then restart the daemon and recreate the VM:": {
+ "fr": "Puis redémarrer le démon et recréer la VM :",
+ "en": "Then restart the daemon and recreate the VM:",
+ },
+ "then stop and start the VM (a guest reboot is not": {
+ "fr": "puis éteindre et rallumer la VM (un redémarrage de",
+ "en": "then stop and start the VM (a guest reboot is not",
+ },
+ "enough: the list applies when QEMU is launched).": {
+ "fr": "l'invité ne suffit pas : la liste s'applique au lancement).",
+ "en": "enough: the list applies when QEMU is launched).",
+ },
+ "package": {
+ "fr": "paquet",
+ "en": "package",
+ },
+ "Will run, as root:": {
+ "fr": "Va exécuter, en root :",
+ "en": "Will run, as root:",
+ },
+ "Full output:": {
+ "fr": "Sortie complète :",
+ "en": "Full output:",
+ },
"Read the logs:": {
"fr": "Lire les logs :",
"en": "Read the logs:",
@@ -7144,8 +7459,8 @@ TRANSLATIONS = {
},
# Contexte fourni à Claude
"Show the context given to Claude": {
- "fr": "Afficher le contexte fourni à Claude",
- "en": "Show the context given to Claude",
+ "fr": "🧠 Afficher le contexte fourni à Claude",
+ "en": "🧠 Show the context given to Claude",
},
"Context given to Claude": {
"fr": "Contexte fourni à Claude",
@@ -10347,8 +10662,8 @@ TRANSLATIONS = {
},
"Android emulator": {"fr": "Émulateur Android", "en": "Android emulator"},
"Android emulator (start, tunnel, scrcpy)": {
- "fr": "Émulateur Android (démarrer, tunnel, scrcpy)",
- "en": "Android emulator (start, tunnel, scrcpy)",
+ "fr": "📱 Émulateur Android (démarrer, tunnel, scrcpy)",
+ "en": "📱 Android emulator (start, tunnel, scrcpy)",
},
"An emulator is already running on this VM.": {
"fr": "Un émulateur tourne déjà sur cette VM.",
@@ -10938,8 +11253,8 @@ TRANSLATIONS = {
"en": "Copy",
},
"Duplicate a database": {
- "fr": "Dupliquer une base de données",
- "en": "Duplicate a database",
+ "fr": "📑 Dupliquer une base de données",
+ "en": "📑 Duplicate a database",
},
"Name of the copy (default=": {
"fr": "Nom de la copie (défaut=",
diff --git a/test/test_qemu_ai_tools.py b/test/test_qemu_ai_tools.py
new file mode 100644
index 0000000..6a25355
--- /dev/null
+++ b/test/test_qemu_ai_tools.py
@@ -0,0 +1,258 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Les outils d'assistance posés dans une VM au déploiement.
+
+rtk, starship et UN agent — Claude Code ou opencode. Aucun n'est dans les
+dépôts des distributions supportées : ce sont quatre installateurs amont,
+donc quatre curl vers l'extérieur, lancés sur un SSH sans terminal.
+
+Ce que ces tests gardent :
+
+- une seule autorité pour les URL amont : l'hôte et la VM posent les mêmes
+ outils, et deux copies dérivent dès que l'amont en change une ;
+- aucune pose ne peut PENDRE : « || true » couvre l'échec, pas l'attente
+ d'une réponse que personne ne donnera ;
+- les lignes ajoutées au ~/.bashrc le sont UNE fois, sinon chaque
+ redéploiement d'une même VM le rallonge ;
+- l'identité git saisie prime sur celle de l'hôte, champ par champ.
+"""
+
+import importlib.util
+import shlex
+import subprocess
+import sys
+import unittest
+from pathlib import Path
+
+sys.argv = ["todo.py"]
+from script.todo import dev_tools # noqa: E402
+from script.todo.deploy_form_lib import build_spec # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+RACINE = Path(__file__).resolve().parents[1]
+
+
+def _deploy_qemu():
+ chemin = RACINE / "script/qemu/deploy_qemu.py"
+ spec = importlib.util.spec_from_file_location("deploy_qemu", chemin)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+DQ = _deploy_qemu()
+
+
+class UneSeuleAutorite(unittest.TestCase):
+ """Les mêmes outils se posent sur l'hôte et dans une VM."""
+
+ def test_the_host_reads_the_shared_table(self):
+ """Deux copies d'une URL dérivent dès que l'amont en change une, et
+ la seconde est celle qu'on oublie."""
+ self.assertIs(TODO._UPSTREAM_TOOLS, dev_tools.AGENTS)
+ self.assertIs(TODO._STARSHIP_LINE, dev_tools.STARSHIP_LINE)
+ self.assertEqual(TODO._STARSHIP_UPSTREAM, dev_tools.STARSHIP_UPSTREAM)
+
+ def test_both_agents_are_offered(self):
+ self.assertEqual(["claude", "opencode"], sorted(dev_tools.AGENTS))
+
+ def test_the_default_agent_is_one_of_them(self):
+ """Le premier de la table ferait dépendre le défaut de l'ordre
+ d'écriture d'un dictionnaire."""
+ self.assertIn(dev_tools.AGENT_DEFAUT, dev_tools.AGENTS)
+
+
+class LeCatalogue(unittest.TestCase):
+ def test_the_tool_exists_and_needs_no_desktop(self):
+ """On s'en sert en SSH : une VM serveur le prend aussi."""
+ spec = TODO._QEMU_VM_TOOLS["aidev"]
+ self.assertFalse(spec["needs_desktop"])
+ self.assertEqual((), spec["arches"])
+ self.assertEqual((), spec["families"])
+
+ def test_it_is_posed_before_the_clone(self):
+ """« before » est la phase où chaque outil se garde lui-même. En
+ « after », un curl sans réponse rendrait la VM rouge."""
+ self.assertEqual("before", TODO._QEMU_VM_TOOLS["aidev"]["phase"])
+
+
+class LaCommandeDistante(unittest.TestCase):
+ def _cmd(self, agent=""):
+ return TODO.__new__(TODO)._qemu_aidev_remote_cmd(agent)
+
+ def test_it_poses_the_three_tools(self):
+ cmd = self._cmd("claude")
+ self.assertIn("rtk", cmd)
+ self.assertIn("starship", cmd)
+ self.assertIn("claude.ai", cmd)
+
+ def test_the_chosen_agent_is_the_one_posed(self):
+ """Un seul agent, celui qu'on a demandé : poser les deux
+ installerait un outil que personne n'a coché."""
+ claude = self._cmd("claude")
+ opencode = self._cmd("opencode")
+ self.assertIn("claude.ai", claude)
+ self.assertNotIn("opencode.ai", claude)
+ self.assertIn("opencode.ai", opencode)
+ self.assertNotIn("claude.ai", opencode)
+
+ def test_an_unknown_agent_falls_back(self):
+ """Une valeur inattendue ne doit pas faire tomber un déploiement."""
+ self.assertIn("claude.ai", self._cmd("gemini"))
+ self.assertIn("claude.ai", self._cmd(""))
+
+ def test_no_pose_can_hang(self):
+ """« || true » couvre l'ÉCHEC, pas l'ATTENTE. Un installateur qui
+ pose une question resterait pendu sur un SSH sans terminal, et le
+ déploiement avec lui."""
+ cmd = self._cmd("claude")
+ self.assertEqual(3, cmd.count("> ~/.bashrc" in morceau:
+ self.assertIn("||", morceau)
+
+ def test_the_path_uses_home_not_a_tilde(self):
+ """Entre guillemets, le tilde n'est pas étendu : le PATH porterait
+ un répertoire nommé « ~ », qui n'existe pas."""
+ cmd = self._cmd("opencode")
+ self.assertIn('export PATH="$HOME/.opencode/bin:$PATH"', cmd)
+ self.assertNotIn('PATH="~/', cmd)
+
+ def test_it_is_valid_shell(self):
+ """Une commande mal citée casse tout le bloc des outils, pas
+ seulement le sien."""
+ for agent in ("claude", "opencode", ""):
+ with self.subTest(agent=agent):
+ fini = subprocess.run(
+ ["bash", "-n"],
+ input=self._cmd(agent),
+ text=True,
+ capture_output=True,
+ )
+ self.assertEqual(0, fini.returncode, fini.stderr)
+
+ def test_nothing_is_posed_without_the_box(self):
+ todo = TODO.__new__(TODO)
+ cmd = todo._qemu_erplibre_remote_cmd("develop", tools=())
+ self.assertNotIn("starship", cmd)
+
+ def test_the_agent_reaches_the_remote_command(self):
+ """L'épreuve du bout en bout : le choix doit traverser toute la
+ chaîne, sans quoi il reste un réglage sans effet."""
+ todo = TODO.__new__(TODO)
+ cmd = todo._qemu_erplibre_remote_cmd(
+ "develop", tools=("aidev",), ai_agent="opencode"
+ )
+ self.assertIn("opencode.ai", cmd)
+
+
+class LIdentiteGit(unittest.TestCase):
+ """Le formulaire montre l'identité de l'hôte et permet de la changer."""
+
+ def _args(self, **kw):
+ argv = ["--distro", "ubuntu", "--hostname", "vm"]
+ for cle, valeur in kw.items():
+ argv += [f"--{cle.replace('_', '-')}", valeur]
+ return DQ.build_parser().parse_args(argv)
+
+ def _identite(self, cc):
+ lu = {}
+ for ligne in cc.split("\n"):
+ for champ in ("name", "email"):
+ if ligne.strip().startswith(f"{champ} = "):
+ lu[champ] = ligne.split(" = ", 1)[1]
+ return lu
+
+ def test_what_was_typed_wins(self):
+ cc = DQ.build_cloud_config(
+ self._args(
+ git_name="Une Personne", git_email="qui@exemple.invalid"
+ ),
+ None,
+ [],
+ )
+ self.assertEqual(
+ {"name": "Une Personne", "email": "qui@exemple.invalid"},
+ self._identite(cc),
+ )
+
+ def test_each_field_stands_alone(self):
+ """Remplir le seul courriel ne doit pas effacer le nom : les deux
+ retombent sur l'hôte SÉPARÉMENT."""
+ cc = DQ.build_cloud_config(
+ self._args(git_email="qui@exemple.invalid"), None, []
+ )
+ lu = self._identite(cc)
+ self.assertEqual("qui@exemple.invalid", lu.get("email"))
+ self.assertNotEqual("", lu.get("name", ""))
+
+ def test_the_option_reaches_the_command(self):
+ """Le réglage doit atteindre deploy_qemu, sinon il n'existe pas."""
+ todo = TODO.__new__(TODO)
+ cmd = todo._qemu_build_deploy_parts(
+ "ubuntu",
+ "24.04",
+ "amd64",
+ "vm",
+ 2048,
+ 2,
+ "20G",
+ "",
+ "",
+ True,
+ git_name="Une Personne",
+ git_email="qui@exemple.invalid",
+ )
+ self.assertIn("--git-name", cmd)
+ self.assertIn("Une Personne", cmd)
+ self.assertIn("--git-email", cmd)
+
+ def test_an_empty_identity_says_nothing(self):
+ """Vide, deploy_qemu recopie l'hôte : ajouter l'option avec une
+ valeur vide écraserait cette identité par du rien."""
+ todo = TODO.__new__(TODO)
+ cmd = todo._qemu_build_deploy_parts(
+ "ubuntu", "24.04", "amd64", "vm", 2048, 2, "20G", "", "", True
+ )
+ self.assertNotIn("--git-name", cmd)
+ self.assertNotIn("--git-email", cmd)
+
+
+class LaSpec(unittest.TestCase):
+ def test_the_three_settings_reach_the_spec(self):
+ """Absents de l'assemblée, les réglages du formulaire n'atteignent
+ jamais le déploiement — la case resterait décorative."""
+ spec = build_spec(
+ [],
+ [],
+ {
+ "res_label": "x1",
+ "ssh_key": "",
+ "install": None,
+ "add_ssh_config": False,
+ "parallelism": 1,
+ "ai_agent": "opencode",
+ "git_name": "Une Personne",
+ "git_email": "qui@exemple.invalid",
+ },
+ )
+ self.assertEqual("opencode", spec["ai_agent"])
+ self.assertEqual("Une Personne", spec["git_name"])
+ self.assertEqual("qui@exemple.invalid", spec["git_email"])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_deploy_failure_output.py b/test/test_qemu_deploy_failure_output.py
new file mode 100644
index 0000000..07df6fc
--- /dev/null
+++ b/test/test_qemu_deploy_failure_output.py
@@ -0,0 +1,126 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Une création de VM ratée doit montrer le message de l'outil.
+
+La sortie de virt-install est capturée en mémoire puis jetée après la boucle :
+si elle ne s'affiche pas à ce moment-là, elle n'existe plus nulle part. Quatre
+lignes ne suffisent pas — l'épilogue « Échec de la commande » et sa ligne de
+commande les occupent entièrement, et le message de l'outil tombe juste
+au-dessus de la fenêtre.
+
+Ce que ces tests gardent :
+
+- une VM qui réussit reste discrète, une qui échoue montre assez pour être
+ diagnostiquée ;
+- la sortie complète d'un échec atterrit dans un fichier, seule trace qui
+ survit à l'écran ;
+- un journal qu'on ne peut pas écrire ne fait pas échouer le déploiement.
+"""
+
+import io
+import sys
+import unittest
+from contextlib import redirect_stdout
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+# Forme réelle d'un échec : le message de l'outil, puis l'épilogue qui occupe
+# à lui seul les quatre dernières lignes.
+SORTIE_ECHEC = "\n".join(
+ [f" bruit {i}" for i in range(20)]
+ + [
+ "ERROR Le message de virt-install qui explique tout",
+ " virsh --connect qemu:///system start la-vm",
+ " sinon, recommencer l'installation.",
+ "Échec de la commande (code 1) :",
+ " env XDG_CACHE_HOME=/var/tmp/x virt-install --connect …",
+ ]
+)
+
+
+class FailureOutput(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def _lancer(self, rc, sortie):
+ """Rend ce que le déploiement affiche pour un job au code `rc`."""
+ jobs = [(1, "la-vm", ["/bin/true"])]
+
+ class Res:
+ returncode = rc
+ stdout = sortie
+ stderr = ""
+
+ with mock.patch(
+ "script.todo.qemu_deploy.subprocess.run", return_value=Res()
+ ), mock.patch.object(
+ self.todo, "_fmt_dur", return_value="1s"
+ ), mock.patch.object(
+ self.todo, "_qemu_save_failure_log", return_value="/tmp/x.log"
+ ):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ self.todo._qemu_deploy_jobs_cli(jobs, 1)
+ return buf.getvalue()
+
+ def test_a_failure_shows_the_tool_message(self):
+ """La régression même : avec quatre lignes, cette ligne manquait."""
+ rendu = self._lancer(1, SORTIE_ECHEC)
+ self.assertIn("Le message de virt-install qui explique tout", rendu)
+
+ def test_a_failure_names_the_full_log(self):
+ rendu = self._lancer(1, SORTIE_ECHEC)
+ self.assertIn("/tmp/x.log", rendu)
+
+ def test_a_success_stays_terse(self):
+ """Sans quoi trente lignes par VM réussie noieraient un lot de dix."""
+ rendu = self._lancer(0, SORTIE_ECHEC)
+ self.assertNotIn("bruit 0", rendu)
+ corps = [
+ ln
+ for ln in rendu.splitlines()
+ if ln.startswith(" ") and ln.strip()
+ ]
+ self.assertLessEqual(len(corps), 4, corps)
+
+ def test_a_3d_failure_names_the_way_out(self):
+ """« --gpu off » n'est pas dans le menu : si le rapport ne le nomme
+ pas, un hôte incompatible avec la 3D n'a aucune issue depuis TODO."""
+ rendu = self._lancer(1, SORTIE_ECHEC + "\n --video accel3d=on")
+ self.assertIn("--gpu off", rendu)
+
+ def test_a_failure_without_3d_stays_silent_about_it(self):
+ rendu = self._lancer(1, SORTIE_ECHEC)
+ self.assertNotIn("--gpu off", rendu)
+
+ def test_an_unwritable_log_does_not_break_the_deploy(self):
+ """Perdre le journal ne doit pas faire perdre le déploiement."""
+ with mock.patch(
+ "script.todo.qemu_install_monitor.session_dir",
+ side_effect=OSError("disque plein"),
+ ):
+ chemin = self.todo._qemu_save_failure_log("la-vm", "peu importe")
+ self.assertIsNone(chemin)
+
+ def test_the_log_name_survives_a_hostile_vm_name(self):
+ """Le nom de VM vient de l'utilisateur : il ne doit pas composer un
+ chemin hors du répertoire de session."""
+ import tempfile
+ from pathlib import Path
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with mock.patch(
+ "script.todo.qemu_install_monitor.session_dir",
+ return_value=Path(tmp),
+ ):
+ chemin = self.todo._qemu_save_failure_log("../../evil vm", "x")
+ self.assertIsNotNone(chemin)
+ self.assertEqual(chemin.parent, Path(tmp))
+ self.assertNotIn("/", chemin.name.replace("-create.log", ""))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py
index 75cba88..2d9ba76 100644
--- a/test/test_qemu_desktop_tools.py
+++ b/test/test_qemu_desktop_tools.py
@@ -288,9 +288,10 @@ class TestMobileBuild(unittest.TestCase):
def test_it_runs_on_a_server_vm(self):
"""Elle compile, elle n'affiche rien : un bureau serait du gaspillage.
- L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X."""
+ L'émulateur non plus n'en a pas besoin — il s'affiche par ssh -X, et
+ les outils d'assistance encore moins : on s'en sert en SSH."""
got = self.todo._qemu_tools_for(self.all, "amd64", "", "ubuntu")
- self.assertEqual(["mobile", "forgejo", "avd"], got)
+ self.assertEqual(["mobile", "forgejo", "aidev", "avd"], got)
# Forgejo est là pour la même raison que la compilation : un
# service ne demande pas d'écran.
diff --git a/test/test_qemu_egl_fallback.py b/test/test_qemu_egl_fallback.py
new file mode 100644
index 0000000..349fda0
--- /dev/null
+++ b/test/test_qemu_egl_fallback.py
@@ -0,0 +1,210 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Un nœud de rendu présent ne prouve pas que la 3D marche.
+
+QEMU refuse de démarrer le domaine quand EGL ne s'initialise pas sur le nœud,
+et il le dit seulement à ce moment-là : « egl: eglInitialize failed:
+EGL_NOT_INITIALIZED », puis « egl: render node init failed ». La détection ne
+voit qu'un fichier dans /dev/dri, et aucun test préalable ne distingue un GPU
+utilisable d'un nœud qui existe sans pile EGL.
+
+Ce que ces tests gardent :
+
+- une VM graphique naît quand même, en rendu logiciel, plutôt que d'échouer ;
+- le domaine de l'essai raté est retiré avant la seconde tentative, sinon le
+ nom est pris et la VM reste celle qui ne démarre pas ;
+- « --gpu on » est une exigence : elle n'est pas trahie en silence ;
+- un échec qui n'est PAS celui d'EGL n'est pas rattrapé.
+"""
+
+import importlib.util
+import io
+import sys
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from types import SimpleNamespace
+from unittest import mock
+
+sys.argv = ["todo.py"]
+RACINE = Path(__file__).resolve().parents[1]
+
+
+def _deploy_qemu():
+ path = RACINE / "script/qemu/deploy_qemu.py"
+ spec = importlib.util.spec_from_file_location("deploy_qemu", path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+DQ = _deploy_qemu()
+
+SORTIE_EGL = (
+ "ERROR erreur interne : le processus s'est arrêté pendant la"
+ " connexion au moniteur: qemu-system-x86_64: egl: eglInitialize failed:"
+ " EGL_NOT_INITIALIZED\n"
+ "qemu-system-x86_64: egl: render node init failed\n"
+)
+SORTIE_AUTRE = "ERROR Unknown OS name 'ubuntu99.04'\n"
+
+
+class Signature(unittest.TestCase):
+ def test_it_recognises_the_egl_failure(self):
+ self.assertTrue(DQ.egl_failed(SORTIE_EGL))
+
+ def test_it_does_not_recognise_another_failure(self):
+ self.assertFalse(DQ.egl_failed(SORTIE_AUTRE))
+ self.assertFalse(DQ.egl_failed(""))
+
+
+class Repli(unittest.TestCase):
+ """virt_install avec un GPU détecté, dont EGL ne démarre pas."""
+
+ def _args(self, gpu="auto"):
+ return SimpleNamespace(
+ name="vm-a",
+ memory=4096,
+ vcpus=2,
+ arch="amd64",
+ network="network=default,model=virtio",
+ graphics="none",
+ desktop=True,
+ gpu=gpu,
+ gpu_node="/dev/dri/renderD128",
+ attach_console=False,
+ bios=False,
+ )
+
+ def _lancer(self, gpu="auto", sortie=SORTIE_EGL, code=1):
+ """Rend (commandes lancées, SystemExit ou None)."""
+ lances = []
+
+ class FauxRunner:
+ dry_run = False
+ use_sudo = False
+
+ def run(self, cmd, *, privileged=False, check=True, capture=False):
+ lances.append(list(cmd))
+ if capture:
+ return (code, sortie)
+ return None
+
+ with mock.patch.object(
+ DQ, "host_arch", return_value="amd64"
+ ), mock.patch.object(
+ DQ, "kvm_available", return_value=True
+ ), mock.patch.object(
+ DQ, "os"
+ ) as faux_os:
+ faux_os.getuid.return_value = 1000
+ faux_os.makedirs.return_value = None
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ try:
+ DQ.virt_install(
+ self._args(gpu),
+ Path("/tmp/d.qcow2"),
+ Path("/tmp/s.iso"),
+ "ubuntu26.04",
+ FauxRunner(),
+ )
+ sortie_exc = None
+ except SystemExit as exc:
+ sortie_exc = exc
+ return lances, sortie_exc, buf.getvalue()
+
+ def test_the_first_attempt_carries_the_3d(self):
+ lances, _, _ = self._lancer()
+ self.assertIn("model.acceleration.accel3d=on", " ".join(lances[0]))
+
+ def test_a_second_attempt_runs_without_the_3d(self):
+ lances, exc, rendu = self._lancer()
+ self.assertIsNone(exc, rendu)
+ dernier = " ".join(lances[-1])
+ self.assertNotIn("accel3d", dernier)
+ self.assertNotIn("egl-headless", dernier)
+ # L'écran doit revenir : une VM graphique sans --video n'en a plus.
+ self.assertIn("--video virtio", dernier)
+
+ def test_the_failed_domain_is_undefined_first(self):
+ """Sans ce retrait, virt-install refuse le nom déjà pris et la VM
+ reste celle qui ne démarre pas."""
+ lances, _, _ = self._lancer()
+ milieu = " ".join(lances[1])
+ self.assertIn("undefine", milieu)
+ self.assertIn("vm-a", milieu)
+ self.assertLess(1, len(lances) - 1)
+
+ def test_gpu_on_also_falls_back_but_says_so(self):
+ """Une VM qu'on n'a pas est pire qu'une VM sans 3D. Le repli vaut donc
+ aussi pour une 3D demandée — à condition de nommer ce qui manque, sans
+ quoi l'utilisateur croirait avoir obtenu ce qu'il a coché."""
+ lances, exc, rendu = self._lancer(gpu="on")
+ self.assertIsNone(exc, rendu)
+ self.assertIn("DEMANDÉE", rendu)
+ self.assertNotIn("accel3d", " ".join(lances[-1]))
+
+ def test_another_failure_is_not_retried(self):
+ lances, exc, _ = self._lancer(sortie=SORTIE_AUTRE)
+ self.assertIsInstance(exc, SystemExit)
+ self.assertEqual(1, len(lances))
+
+ def _sans_ecran(self, resultat):
+ """virt_install sur une VM sans console dont la 3D est demandée.
+
+ `resultat` est ce que rend le runner en mode capture. Rend les
+ commandes lancées."""
+ args = self._args(gpu="on")
+ args.desktop = False
+ lances = []
+
+ class FauxRunner:
+ dry_run = False
+ use_sudo = False
+
+ def run(self, cmd, *, privileged=False, check=True, capture=False):
+ lances.append(list(cmd))
+ return resultat if capture else None
+
+ with mock.patch.object(
+ DQ, "host_arch", return_value="amd64"
+ ), mock.patch.object(
+ DQ, "kvm_available", return_value=True
+ ), mock.patch.object(
+ DQ, "os"
+ ) as faux_os:
+ faux_os.getuid.return_value = 1000
+ with redirect_stdout(io.StringIO()):
+ DQ.virt_install(
+ args,
+ Path("/tmp/d.qcow2"),
+ Path("/tmp/s.iso"),
+ "ubuntu26.04",
+ FauxRunner(),
+ )
+ return lances
+
+ def test_a_screenless_3d_vm_keeps_egl_as_its_only_display(self):
+ """« --graphics none » dit « aucun affichage » : le poser à côté d'un
+ egl-headless, qui EST un affichage, se contredit."""
+ rendu = " ".join(self._sans_ecran((0, ""))[0])
+ self.assertIn("egl-headless", rendu)
+ self.assertNotIn("--graphics none", rendu)
+
+ def test_dropping_the_3d_gives_the_display_back(self):
+ """Le repli doit rendre le « --graphics none » écarté pour la 3D,
+ sinon la VM repart sur le défaut de virt-install."""
+ dernier = " ".join(self._sans_ecran((1, SORTIE_EGL))[-1])
+ self.assertIn("--graphics none", dernier)
+ self.assertNotIn("egl-headless", dernier)
+
+ def test_a_success_never_retries(self):
+ lances, exc, _ = self._lancer(sortie="", code=0)
+ self.assertIsNone(exc)
+ self.assertEqual(1, len(lances))
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_gpu.py b/test/test_qemu_gpu.py
index 071fddc..f20ba4a 100644
--- a/test/test_qemu_gpu.py
+++ b/test/test_qemu_gpu.py
@@ -446,7 +446,31 @@ class TestMenuGlue(unittest.TestCase):
joined = " ".join(todo.launched)
self.assertIn("--vcpus 4", joined)
self.assertIn("accel3d=on", joined)
- self.assertTrue(all(c.startswith("sudo ") for c in todo.launched))
+
+ def test_the_privilege_follows_the_probe(self):
+ """Appartenir au groupe libvirt suffit à joindre qemu:///system :
+ préfixer alors de « sudo » ne donne aucun droit de plus et réclame un
+ mot de passe pour rien. Les deux cas sont tenus ici, sans quoi la
+ réponse du sondage pourrait être ignorée sans que rien ne rougisse."""
+ for joignable, attendu in ((True, ""), (False, "sudo ")):
+ with self.subTest(joignable=joignable):
+ todo = self._todo({"vm-a": "shut off"})
+ todo._qemu_hw_form = lambda rows, node, nets=None: {
+ "vm-a": {"vcpus": 4, "ram": 8192, "gpu": True}
+ }
+ with mock.patch(
+ "script.todo.qemu_privilege.libvirt_reachable",
+ return_value=joignable,
+ ), mock.patch(
+ "script.todo.qemu_privilege.os.geteuid", return_value=1000
+ ), mock.patch(
+ "script.todo.qemu_privilege.shutil.which",
+ return_value="/usr/bin/virsh",
+ ):
+ self._run(todo, ["vm-a"], ["o"])
+ self.assertTrue(todo.launched)
+ for c in todo.launched:
+ self.assertEqual(c.startswith("sudo "), bool(attendu), c)
def test_nothing_to_change_launches_nothing(self):
todo = self._todo({"vm-a": "shut off"})
diff --git a/test/test_qemu_gpu_option.py b/test/test_qemu_gpu_option.py
new file mode 100644
index 0000000..6c9dcc9
--- /dev/null
+++ b/test/test_qemu_gpu_option.py
@@ -0,0 +1,283 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""La 3D se coche à la création, même sur une VM sans écran.
+
+« auto » s'abstient sans écran virtuel : une VM serveur n'a pas demandé de
+périphérique vidéo, et lui en poser un d'office changerait son matériel. Mais
+une VM sans console peut vouloir un virtio-gpu accéléré — rendu hors écran,
+émulateur qui tourne dedans. La case le demande explicitement.
+
+Ce que ces tests gardent :
+
+- la case atteint vraiment la commande, à travers la spec ;
+- sans elle, rien ne change pour une VM serveur ;
+- « --graphics none » et « egl-headless » ne coexistent jamais : le premier
+ dit « aucun affichage », le second EST un affichage.
+"""
+
+import importlib.util
+import sys
+import unittest
+from pathlib import Path
+
+sys.argv = ["todo.py"]
+from script.todo.deploy_form_lib import build_spec # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+RACINE = Path(__file__).resolve().parents[1]
+NODE = "/dev/dri/renderD128"
+
+
+def _deploy_qemu():
+ path = RACINE / "script/qemu/deploy_qemu.py"
+ spec = importlib.util.spec_from_file_location("deploy_qemu", path)
+ mod = importlib.util.module_from_spec(spec)
+ spec.loader.exec_module(mod)
+ return mod
+
+
+DQ = _deploy_qemu()
+
+
+class LaDecision(unittest.TestCase):
+ def test_auto_still_abstains_without_a_screen(self):
+ """Le défaut ne doit pas changer : une VM serveur reste sans vidéo."""
+ on, msg = DQ.gpu_decision("auto", NODE, False)
+ self.assertFalse(on)
+ self.assertEqual("", msg)
+
+ def test_on_now_enables_3d_without_a_screen(self):
+ on, msg = DQ.gpu_decision("on", NODE, False)
+ self.assertTrue(on)
+ self.assertIn("sans écran", msg)
+
+ def test_off_wins_over_everything(self):
+ self.assertEqual((False, ""), DQ.gpu_decision("off", NODE, False))
+
+ def test_without_a_host_node_it_still_refuses(self):
+ on, msg = DQ.gpu_decision("on", "", False)
+ self.assertFalse(on)
+ self.assertTrue(msg)
+
+
+class LaCaseAtteintLaCommande(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def _parts(self, gpu3d):
+ vm = {
+ "name": "vm-a",
+ "distro": "ubuntu",
+ "version": "24.04",
+ "arch": "amd64",
+ "ram": 4096,
+ "vcpus": 2,
+ "disk": "20G",
+ }
+ spec = {
+ "vms": [vm],
+ "gpu3d": gpu3d,
+ "install": None,
+ "ssh_key": "",
+ "vm_tools": (),
+ }
+ return self.todo._qemu_deploy_parts_for(vm, spec, dry_run=True)
+
+ def test_the_box_adds_the_flag(self):
+ self.assertIn("--gpu", self._parts(True))
+ parts = self._parts(True)
+ self.assertEqual("on", parts[parts.index("--gpu") + 1])
+
+ def test_without_the_box_nothing_is_added(self):
+ self.assertNotIn("--gpu", self._parts(False))
+
+
+class LaSpecLaTransporte(unittest.TestCase):
+ def _spec(self, form_extra):
+ form = {
+ "res_label": "x1",
+ "ssh_key": "",
+ "install": None,
+ "add_ssh_config": True,
+ "parallelism": 1,
+ }
+ form.update(form_extra)
+ return build_spec([{"name": "vm-a"}], [], form)
+
+ def test_the_checkbox_reaches_the_spec(self):
+ self.assertTrue(self._spec({"gpu3d": True})["gpu3d"])
+
+ def test_its_default_is_off(self):
+ self.assertFalse(self._spec({})["gpu3d"])
+
+
+class LAbiFigee(unittest.TestCase):
+ """Une 3D demandée que l'ABI figée annule doit se voir.
+
+ Depuis libvirt 12.5.0, le vidéo porte un attribut « device »
+ qui grave le device QEMU retenu, pour tenir l'ABI de l'invité stable
+ d'un démarrage à l'autre. Il l'emporte sur « accel3d » : une VM
+ démarrée une première fois sans 3D garde le device sans GL, et cocher
+ la 3D ensuite ne change rien à ce que QEMU reçoit. La définition et
+ la ligne de commande se contredisent alors en silence.
+ """
+
+ XML = (
+ "vm8"
+ "33554432"
+ ""
+ ""
+ ""
+ ""
+ )
+
+ def _etat(self, device="", accel="yes"):
+ from script.todo.qemu_hardware import hw_state
+
+ attr = f"device='{device}'" if device else ""
+ return hw_state(self.XML.format(attr=attr, accel=accel), False)
+
+ def test_the_pinned_device_is_read(self):
+ self.assertEqual(
+ self._etat("virtio-vga")["video_device"], "virtio-vga"
+ )
+ self.assertEqual(self._etat()["video_device"], "")
+
+ def test_a_non_gl_pin_defeats_the_requested_3d(self):
+ from script.todo.qemu_hardware import pin_defeats_3d
+
+ self.assertTrue(pin_defeats_3d(self._etat("virtio-vga")))
+
+ def test_a_gl_pin_does_not(self):
+ """Le suffixe « -gl » est ce qui distingue les deux devices.
+
+ Sans cette lecture, tout épinglage passerait pour une panne et la
+ VM correctement accélérée porterait un avertissement à tort.
+ """
+ from script.todo.qemu_hardware import pin_defeats_3d
+
+ self.assertFalse(pin_defeats_3d(self._etat("virtio-vga-gl")))
+ self.assertFalse(pin_defeats_3d(self._etat("virtio-gpu-gl")))
+
+ def test_no_pin_and_no_3d_are_not_flagged(self):
+ """Deux cas voisins qu'un test trop large confondrait : libvirt
+ antérieur à 12.5.0 n'écrit pas l'attribut, et une VM sans 3D
+ demandée n'a rien à signaler même si son device est figé."""
+ from script.todo.qemu_hardware import pin_defeats_3d
+
+ self.assertFalse(pin_defeats_3d(self._etat()))
+ self.assertFalse(pin_defeats_3d(self._etat("virtio-vga", accel="no")))
+
+ def test_the_summary_says_so(self):
+ """La ligne de résumé est celle sur laquelle on décide : elle ne
+ doit pas afficher « 3D » tout court quand la 3D ne tourne pas."""
+ from script.todo.qemu_hardware import hw_summary
+
+ figee = hw_summary(self._etat("virtio-vga"))
+ vivante = hw_summary(self._etat("virtio-vga-gl"))
+ self.assertIn("virtio-vga", figee)
+ self.assertIn("⚠", figee)
+ self.assertNotIn("⚠", vivante)
+
+
+try:
+ import yaml
+except ImportError:
+ yaml = None
+
+
+class LesGroupesGpuDeLInvite(unittest.TestCase):
+ """Le compte de l'invité doit pouvoir OUVRIR le nœud de rendu.
+
+ Le matériel virtuel accéléré ne suffit pas : dans l'invité, le nœud
+ appartient à « root:render » en 0660, et un compte hors de ce groupe
+ retombe sur le rendu logiciel alors que la négociation VIRGL a réussi.
+ Rien ne le signale. En session graphique locale logind pose une ACL
+ pour l'utilisateur du siège ; en SSH ou en tty, personne ne la pose.
+ """
+
+ def _args(self, distro="ubuntu", gpu="auto"):
+ return DQ.build_parser().parse_args(
+ ["--distro", distro, "--gpu", gpu, "--hostname", "vm"]
+ )
+
+ def _cc(self, **kw):
+ return DQ.build_cloud_config(self._args(**kw), None, [])
+
+ def test_the_account_joins_the_gpu_groups(self):
+ """Sur les groupes DU COMPTE, et non sur le texte du document : le
+ bloc qui déclare les groupes y porte déjà les deux mots, si bien
+ qu'y chercher « render » passerait sans que personne n'y entre."""
+ noms = DQ.user_groups("ubuntu", gpu=True).split(", ")
+ self.assertIn("render", noms)
+ self.assertIn("video", noms)
+ self.assertNotIn("render", DQ.user_groups("ubuntu").split(", "))
+
+ def test_gpu_off_leaves_the_account_alone(self):
+ """« off » est un refus explicite : ne rien ajouter alors."""
+ cc = self._cc(gpu="off")
+ self.assertNotIn("render", cc)
+ self.assertNotIn("video", cc)
+
+ def test_the_admin_group_survives_every_distro(self):
+ """Le groupe d'administration ne doit pas être perdu en chemin :
+ sans lui la commodité disparaît, et un nom inconnu ferait bien
+ pire — cloud-init ne créerait pas le compte du tout."""
+ for distro, attendu in (
+ ("ubuntu", "sudo"),
+ ("debian", "sudo"),
+ ("arch", "wheel"),
+ ("almalinux", "wheel"),
+ ):
+ with self.subTest(distro=distro):
+ self.assertIn(
+ attendu, DQ.user_groups(distro, gpu=True).split(", ")
+ )
+
+ def test_opensuse_still_gets_no_admin_group(self):
+ """Son cloud-init n'en met pas et « wheel » n'y est pas garanti :
+ en ajouter un risquerait un compte jamais créé."""
+ noms = DQ.user_groups("opensuse", gpu=True).split(", ")
+ self.assertNotIn("wheel", noms)
+ self.assertNotIn("sudo", noms)
+ self.assertIn("render", noms)
+
+ @unittest.skipIf(yaml is None, "PyYAML absent")
+ def test_the_groups_are_declared_before_being_used(self):
+ """« useradd -G » échoue sur un nom de groupe INCONNU, et cloud-init
+ ne crée alors pas le compte : ni mot de passe ni clé SSH, la VM
+ démarre inaccessible. « render » manque des images anciennes, donc
+ le déclarer n'est pas une précaution de style."""
+ doc = yaml.safe_load(self._cc())
+ self.assertEqual(["render", "video"], doc["groups"])
+ for nom in ("render", "video"):
+ self.assertIn(nom, doc["users"][0]["groups"].split(", "))
+
+ @unittest.skipIf(yaml is None, "PyYAML absent")
+ def test_the_document_stays_parsable_everywhere(self):
+ for distro in ("ubuntu", "debian", "arch", "almalinux", "opensuse"):
+ for gpu in ("auto", "on", "off"):
+ with self.subTest(distro=distro, gpu=gpu):
+ doc = yaml.safe_load(self._cc(distro=distro, gpu=gpu))
+ self.assertEqual("vm", doc["hostname"])
+
+ def test_the_preseed_creates_the_groups_before_using_them(self):
+ """Debian passe par le preseed et non par cloud-init : la parité
+ promise par build_preseed s'y perdrait sans cela. L'ORDRE compte —
+ « usermod -aG » sur un groupe absent échoue."""
+ pre = DQ.build_preseed(self._args(distro="debian"), "$6$x$y", [])
+ i_add = pre.index("groupadd -f render")
+ i_use = pre.index("usermod -aG render,video")
+ self.assertLess(i_add, i_use)
+
+ def test_the_preseed_says_nothing_when_gpu_is_off(self):
+ pre = DQ.build_preseed(
+ self._args(distro="debian", gpu="off"), "$6$x$y", []
+ )
+ self.assertNotIn("groupadd", pre)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py
index 19f66eb..0d1d3b2 100644
--- a/test/test_qemu_monitor_pve.py
+++ b/test/test_qemu_monitor_pve.py
@@ -571,9 +571,9 @@ class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase):
def test_a_local_delete_checks_the_uuid(self):
cmd = mon.delete_vm_cmd("vm-a", True, "5d55d05a-1e77")
- self.assertIn("virsh domuuid vm-a", cmd)
+ self.assertIn("domuuid vm-a", cmd)
self.assertIn("5d55d05a-1e77", cmd)
- self.assertLess(cmd.index("domuuid"), cmd.index("virsh destroy vm-a"))
+ self.assertLess(cmd.index("domuuid"), cmd.index("destroy vm-a"))
def test_an_old_manifest_without_identity_still_deletes(self):
# Un manifeste écrit avant ce correctif n'a pas d'UUID. Refuser toute
@@ -581,7 +581,7 @@ class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase):
# d'avant, la confirmation à deux mains.
cmd = mon.delete_vm_cmd("vm-a", True)
self.assertNotIn("domuuid", cmd)
- self.assertIn("virsh undefine vm-a", cmd)
+ self.assertIn("undefine vm-a", cmd)
sans_nom = mon.delete_vm_cmd_pve({"target": "pve9", "vmid": 101})
self.assertNotIn("qm config", sans_nom)
self.assertIn("qm destroy 101", sans_nom)
@@ -930,7 +930,7 @@ class TestLeWebEtLaSuppression(unittest.TestCase):
def test_deleting_a_local_vm_is_unchanged(self):
cmd = mon.delete_vm_cmd("vm-a", True)
- self.assertIn("virsh undefine", cmd)
+ self.assertIn("undefine vm-a", cmd)
self.assertIn("/var/lib/libvirt/images/vm-a.qcow2", cmd)
diff --git a/test/test_qemu_orphan_disk.py b/test/test_qemu_orphan_disk.py
new file mode 100644
index 0000000..1c4562c
--- /dev/null
+++ b/test/test_qemu_orphan_disk.py
@@ -0,0 +1,143 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Un disque resté seul bloque la création : le proposer à l'effacement.
+
+Une création interrompue laisse son qcow2 sans VM définie. deploy_qemu refuse
+alors d'écraser, et la création échoue APRÈS avoir fait attendre. Le
+formulaire plein écran, lui, signale le disque mais ne peut pas l'effacer :
+cela demande root, et une invite de mot de passe n'a nulle part où s'afficher
+dans une application Textual.
+
+Ce que ces tests gardent :
+
+- l'effacement est PROPOSÉ, jamais fait d'office — le même nom peut désigner
+ le disque d'une VM retirée à la main, dont on voulait garder les données ;
+- un refus n'enchaîne pas en silence vers l'échec : il redemande ;
+- le chemin plein écran passe par la même proposition que le chemin en ligne.
+"""
+
+import os
+import sys
+import tempfile
+import unittest
+from pathlib import Path
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo.todo import TODO # noqa: E402
+
+
+class PropositionEffacement(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.efface = []
+ self.todo._cleanup_delete_files = self._faux_effacement
+
+ def _faux_effacement(self, title, items, prompt):
+ self.efface.append([p for _s, p in items])
+
+ def _avec_disques(self, presents, reponses=()):
+ """Simule des orphelins et rend (accepté, effacements demandés)."""
+ it = iter(reponses)
+ restants = {"v": list(presents)}
+
+ def faux_orphans(names):
+ return [
+ (n, f"/var/lib/libvirt/images/{n}.qcow2")
+ for n in names
+ if n in restants["v"]
+ ]
+
+ def effacer(title, items, prompt):
+ self.efface.append([p for _s, p in items])
+ restants["v"] = []
+
+ self.todo._qemu_orphan_disks = faux_orphans
+ self.todo._cleanup_delete_files = effacer
+ with mock.patch("builtins.input", lambda *a: next(it, "")), mock.patch(
+ "builtins.print"
+ ):
+ ok = self.todo._qemu_offer_orphan_removal(["vm-a", "vm-b"])
+ return ok, self.efface
+
+ def test_without_orphans_nothing_is_asked(self):
+ self.todo._qemu_orphan_disks = lambda names: []
+ with mock.patch("builtins.print"):
+ self.assertTrue(self.todo._qemu_offer_orphan_removal(["vm-a"]))
+ self.assertEqual([], self.efface)
+
+ def test_an_orphan_is_offered_for_deletion(self):
+ ok, efface = self._avec_disques(["vm-a"])
+ self.assertTrue(ok)
+ self.assertEqual([["/var/lib/libvirt/images/vm-a.qcow2"]], efface)
+
+ def test_a_kept_disk_asks_again_instead_of_failing(self):
+ """Refuser l'effacement mène à un échec certain : le dire et
+ redemander, plutôt qu'enchaîner en silence."""
+ self.todo._qemu_orphan_disks = lambda names: [
+ (n, f"/var/lib/libvirt/images/{n}.qcow2")
+ for n in names
+ if n == "vm-a"
+ ]
+ with mock.patch("builtins.input", return_value="n"), mock.patch(
+ "builtins.print"
+ ):
+ self.assertFalse(
+ self.todo._qemu_offer_orphan_removal(["vm-a", "vm-b"])
+ )
+
+ def test_a_kept_disk_can_still_be_forced_through(self):
+ self.todo._qemu_orphan_disks = lambda names: [
+ (n, f"/var/lib/libvirt/images/{n}.qcow2")
+ for n in names
+ if n == "vm-a"
+ ]
+ with mock.patch("builtins.input", return_value="o"), mock.patch(
+ "builtins.print"
+ ):
+ self.assertTrue(self.todo._qemu_offer_orphan_removal(["vm-a"]))
+
+ def test_the_size_is_read_from_the_real_file(self):
+ """La taille annoncée est celle du fichier : un chiffre inventé
+ empêcherait de juger ce qu'on efface."""
+ with tempfile.TemporaryDirectory() as tmp:
+ chemin = os.path.join(tmp, "vm-a.qcow2")
+ with open(chemin, "wb") as fh:
+ fh.write(b"x" * 4096)
+ self.todo._qemu_orphan_disks = lambda names: [("vm-a", chemin)]
+ vus = []
+ self.todo._cleanup_delete_files = lambda t_, items, p: vus.extend(
+ items
+ )
+ with mock.patch("builtins.input", return_value="n"), mock.patch(
+ "builtins.print"
+ ):
+ self.todo._qemu_offer_orphan_removal(["vm-a"])
+ self.assertEqual([(4096, chemin)], vus)
+
+ def test_an_unreadable_file_does_not_break_the_offer(self):
+ self.todo._qemu_orphan_disks = lambda names: [("vm-a", "/nulle/part")]
+ vus = []
+ self.todo._cleanup_delete_files = lambda t_, items, p: vus.extend(
+ items
+ )
+ with mock.patch("builtins.input", return_value="n"), mock.patch(
+ "builtins.print"
+ ):
+ self.todo._qemu_offer_orphan_removal(["vm-a"])
+ self.assertEqual([(0, "/nulle/part")], vus)
+
+
+class LeFormulaireYPasseAussi(unittest.TestCase):
+ def test_the_tui_path_calls_the_offer(self):
+ """Le formulaire avertit mais ne peut pas effacer : sans cet appel,
+ un F5 de plus mène droit à l'échec."""
+ source = Path("script/todo/qemu_deploy.py").read_text(encoding="utf-8")
+ debut = source.index('if self._qemu_ask_ui() == "tui":')
+ fin = source.index("got = self._qemu_collect_vms_cli(mod)", debut)
+ self.assertIn("_qemu_offer_orphan_removal", source[debut:fin])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_privilege.py b/test/test_qemu_privilege.py
new file mode 100644
index 0000000..9f66ede
--- /dev/null
+++ b/test/test_qemu_privilege.py
@@ -0,0 +1,252 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Le privilège se sonde, il ne se suppose pas.
+
+Appartenir au groupe libvirt suffit à joindre qemu:///system. Préfixer alors
+chaque commande de « sudo » ne donne aucun droit de plus et réclame un mot de
+passe à chaque entrée de menu.
+
+Ce que ces tests gardent :
+
+- la question se tranche en ESSAYANT ; /etc/group ne dit que ce qui est
+ DÉCLARÉ, et les groupes d'un processus sont figés à l'ouverture de session ;
+- root ne demande jamais sudo, et une machine sans virsh non plus — une invite
+ de mot de passe pour une commande introuvable ne mène nulle part ;
+- l'avertissement d'avant-installation se tait quand l'accès est déjà là.
+"""
+
+import io
+import os
+import sys
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo import qemu_privilege as qp # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+
+class Sondage(unittest.TestCase):
+ def setUp(self):
+ qp.reset_cache()
+
+ def tearDown(self):
+ qp.reset_cache()
+
+ def _sonde(self, rc):
+ class Res:
+ returncode = rc
+ stdout = ""
+ stderr = ""
+
+ return mock.patch.object(qp.subprocess, "run", return_value=Res())
+
+ def test_reachable_means_no_sudo(self):
+ with mock.patch.object(
+ qp.shutil, "which", return_value="/usr/bin/virsh"
+ ), mock.patch.object(qp.os, "geteuid", return_value=1000), self._sonde(
+ 0
+ ):
+ self.assertFalse(qp.needs_sudo())
+ self.assertEqual(qp.sudo_prefix(), "")
+
+ def test_unreachable_means_sudo(self):
+ with mock.patch.object(
+ qp.shutil, "which", return_value="/usr/bin/virsh"
+ ), mock.patch.object(qp.os, "geteuid", return_value=1000), self._sonde(
+ 1
+ ):
+ self.assertTrue(qp.needs_sudo())
+ self.assertEqual(qp.sudo_prefix(), "sudo ")
+
+ def test_root_never_needs_sudo(self):
+ with mock.patch.object(
+ qp.shutil, "which", return_value="/usr/bin/virsh"
+ ), mock.patch.object(qp.os, "geteuid", return_value=0), self._sonde(1):
+ self.assertFalse(qp.needs_sudo())
+
+ def test_without_virsh_no_password_prompt(self):
+ """Demander un mot de passe pour lancer une commande introuvable ne
+ mène nulle part : l'échec doit être « command not found »."""
+ with mock.patch.object(
+ qp.shutil, "which", return_value=None
+ ), mock.patch.object(qp.os, "geteuid", return_value=1000):
+ self.assertFalse(qp.needs_sudo())
+
+ def test_the_probe_runs_once(self):
+ """Chaque entrée de menu la demande : un virsh par commande se
+ verrait."""
+ with mock.patch.object(
+ qp.shutil, "which", return_value="/usr/bin/virsh"
+ ), mock.patch.object(qp.os, "geteuid", return_value=1000):
+ with self._sonde(0) as run:
+ for _ in range(5):
+ qp.needs_sudo()
+ self.assertEqual(run.call_count, 1)
+
+ def test_a_dead_probe_falls_back_on_sudo(self):
+ """Un virsh qui n'arrive pas au bout ne prouve pas l'accès : mieux
+ vaut une invite de mot de passe qu'une commande refusée."""
+ with mock.patch.object(
+ qp.shutil, "which", return_value="/usr/bin/virsh"
+ ), mock.patch.object(
+ qp.os, "geteuid", return_value=1000
+ ), mock.patch.object(
+ qp.subprocess, "run", side_effect=OSError("boom")
+ ):
+ self.assertTrue(qp.needs_sudo())
+
+
+class AvertissementAvantInstallation(unittest.TestCase):
+ def setUp(self):
+ qp.reset_cache()
+ self.todo = TODO.__new__(TODO)
+
+ def tearDown(self):
+ qp.reset_cache()
+
+ def _rendu(self, joignable, declare, actif):
+ with mock.patch.object(
+ qp, "libvirt_reachable", return_value=joignable
+ ), mock.patch.object(qp, "group_state", return_value=(declare, actif)):
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ self.todo._qemu_warn_libvirt_access()
+ return buf.getvalue()
+
+ def test_it_stays_quiet_when_access_is_there(self):
+ self.assertEqual("", self._rendu(True, True, True))
+
+ def test_it_names_usermod_when_the_group_is_missing(self):
+ rendu = self._rendu(False, False, False)
+ self.assertIn("usermod -aG libvirt", rendu)
+
+ def test_a_declared_but_inactive_group_asks_for_a_new_session(self):
+ """Refaire un usermod déjà fait ne changerait rien : ce qui manque est
+ une session, pas une ligne dans /etc/group."""
+ rendu = self._rendu(False, True, False)
+ self.assertIn("newgrp libvirt", rendu)
+ self.assertNotIn("usermod", rendu)
+
+ def test_an_active_group_says_nothing_even_if_virsh_fails(self):
+ """Le groupe est porté par le processus : la cause est ailleurs
+ (démon arrêté, socket absente) et l'accuser tromperait."""
+ self.assertEqual("", self._rendu(False, True, True))
+
+
+class LUriEstToujoursExplicite(unittest.TestCase):
+ """Sans « --connect », un virsh non root vise qemu:///session.
+
+ Cet hyperviseur-là est SÉPARÉ : aucune VM du système n'y existe, et
+ « list --all » y rend une liste vide, sans erreur ni avertissement. Tant
+ que les commandes passaient par sudo, l'URI de root masquait l'omission ;
+ la retirer l'a mise au jour.
+ """
+
+ def test_the_builder_always_names_the_uri(self):
+ for besoin in (True, False):
+ with mock.patch.object(qp, "needs_sudo", return_value=besoin):
+ argv = qp.virsh_argv("list", "--all")
+ self.assertIn("--connect", argv)
+ self.assertEqual(
+ qp.LIBVIRT_URI, argv[argv.index("--connect") + 1]
+ )
+ self.assertIn(f"--connect {qp.LIBVIRT_URI}", qp.virsh_cmd("x"))
+
+ def test_sudo_only_when_the_probe_asks_for_it(self):
+ with mock.patch.object(qp, "needs_sudo", return_value=False):
+ self.assertNotIn("sudo", qp.virsh_argv("list"))
+ with mock.patch.object(qp, "needs_sudo", return_value=True):
+ self.assertEqual("sudo", qp.virsh_argv("list")[0])
+
+ def test_no_menu_call_builds_virsh_by_hand(self):
+ """Un virsh écrit à la main échapperait au constructeur, donc à
+ l'URI : c'est exactement ce qui vidait la liste des VM."""
+ for chemin in (
+ "script/todo/qemu_manage.py",
+ "script/todo/qemu_install_monitor.py",
+ ):
+ source = Path(chemin).read_text(encoding="utf-8")
+ for num, ligne in enumerate(source.splitlines(), 1):
+ if '"virsh"' not in ligne and "}virsh " not in ligne:
+ continue
+ voisin = "\n".join(
+ source.splitlines()[max(0, num - 4) : num + 4]
+ )
+ self.assertIn(
+ "--connect",
+ voisin,
+ f"{chemin}:{num} appelle virsh sans URI",
+ )
+
+
+class LePathDesOutilsSysteme(unittest.TestCase):
+ """TODO tourne dans son venv, dont le « bin » est en tête du PATH.
+
+ Ce répertoire contient un « python3 ». Un outil de la distribution amorcé
+ par « #!/usr/bin/env python3 » y trouve donc l'interpréteur du venv, où
+ les modules du système n'existent pas — l'import échoue sur un module que
+ la machine possède pourtant. Sous sudo le piège était invisible : sudo
+ réinitialise le PATH.
+ """
+
+ def test_the_project_venvs_are_dropped(self):
+ chemin = os.pathsep.join(
+ [
+ "/home/x/git/erplibre/.venv.erplibre/bin",
+ "/home/x/git/erplibre/.venv.odoo18.0_python3.12.10/bin",
+ "/usr/local/bin",
+ "/usr/bin",
+ ]
+ )
+ with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
+ propre = qp.system_path(chemin)
+ self.assertNotIn(".venv", propre)
+ # L'ordre du reste ne bouge pas : il décide quel outil gagne.
+ self.assertEqual(["/usr/local/bin", "/usr/bin"], propre.split(":"))
+
+ def test_the_active_venv_is_dropped_even_without_the_name(self):
+ """Un venv hors du dépôt ne porte pas « .venv » : VIRTUAL_ENV le
+ désigne, et c'est ce nom-là qui tranche."""
+ with mock.patch.dict(
+ qp.os.environ, {"VIRTUAL_ENV": "/opt/env"}, clear=False
+ ):
+ propre = qp.system_path("/opt/env/bin:/usr/bin")
+ self.assertEqual("/usr/bin", propre)
+
+ def test_a_path_without_any_venv_is_untouched(self):
+ with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
+ self.assertEqual(
+ "/usr/local/bin:/usr/bin",
+ qp.system_path("/usr/local/bin:/usr/bin"),
+ )
+
+ def test_empty_entries_do_not_become_the_current_directory(self):
+ """Une entrée vide dans PATH signifie « le répertoire courant » : la
+ recopier ferait chercher un outil système là où on se trouve."""
+ with mock.patch.dict(qp.os.environ, {"VIRTUAL_ENV": ""}, clear=False):
+ self.assertEqual("/usr/bin", qp.system_path("/usr/bin::"))
+
+ def test_the_env_carries_the_cleaned_path(self):
+ with mock.patch.dict(
+ qp.os.environ,
+ {"VIRTUAL_ENV": "/opt/env", "PATH": "/opt/env/bin:/usr/bin"},
+ clear=False,
+ ):
+ env = qp.system_env()
+ self.assertEqual("/usr/bin", env["PATH"])
+
+ def test_the_hardware_plan_uses_it(self):
+ """virt-xml est un script Python du système : sans PATH assaini, il
+ s'amorce sur l'interpréteur du venv."""
+ source = Path("script/todo/qemu_manage.py").read_text(encoding="utf-8")
+ debut = source.index("def _qemu_adjust_hardware")
+ corps = source[debut : source.index("\n def ", debut + 10)]
+ self.assertIn("system_path()", corps)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_recover.py b/test/test_qemu_recover.py
new file mode 100644
index 0000000..a51440b
--- /dev/null
+++ b/test/test_qemu_recover.py
@@ -0,0 +1,261 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Récupérer des fichiers dans le disque d'une VM, sans la démarrer.
+
+Une VM qui ne démarre plus garde ses fichiers, et libguestfs sait monter son
+qcow2 sans elle. Le risque n'est pas la lecture : c'est d'ouvrir en ÉCRITURE
+le disque d'une machine allumée, ce qui corrompt son système de fichiers.
+
+Ce que ces tests gardent :
+
+- « --ro » sur CHAQUE commande, sans exception : c'est lui qui rend l'opération
+ sûre sur une VM en marche ;
+- le nom du paquet suit la distribution — celui de Debian n'existe nulle part
+ ailleurs ;
+- les chemins saisis par l'utilisateur sont échappés avant d'atteindre le
+ shell.
+"""
+
+import re
+import shlex
+import sys
+import unittest
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo import qemu_recover as qr # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+DISQUE = "/var/lib/libvirt/images/vm-a.qcow2"
+
+
+class LesOutils(unittest.TestCase):
+ def test_each_package_manager_gets_its_own_name(self):
+ """« libguestfs-tools » est le nom Debian : il n'existe ni sur Arch,
+ ni sur Fedora, ni sur openSUSE."""
+ attendu = {
+ "apt-get": "libguestfs-tools",
+ "dnf": "guestfs-tools",
+ "pacman": "libguestfs",
+ "zypper": "guestfs-tools",
+ }
+ for binaire, paquet in attendu.items():
+ with self.subTest(binaire=binaire):
+ with mock.patch.object(
+ qr.shutil,
+ "which",
+ side_effect=lambda b, cible=binaire: (
+ "/usr/bin/x" if b == cible else None
+ ),
+ ):
+ cmd, nom = qr.guestfs_install_cmd()
+ self.assertEqual(paquet, nom)
+ self.assertIn(paquet, cmd)
+
+ def test_an_unknown_host_proposes_nothing(self):
+ """Proposer une commande qui échouera vaut moins que de dire qu'on ne
+ sait pas — sur macOS, libguestfs ne tourne pas."""
+ with mock.patch.object(qr.shutil, "which", return_value=None):
+ self.assertEqual((None, None), qr.guestfs_install_cmd())
+
+
+class LaLectureEstToujoursSeule(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_every_command_carries_read_only(self):
+ """Sans « --ro », ouvrir le disque d'une VM allumée corrompt son
+ système de fichiers. Aucune commande n'a de raison de s'en passer."""
+ cmds = [
+ self.todo._qemu_guestfish_cmd(DISQUE),
+ self.todo._qemu_guestfish_cmd(DISQUE, "run", "list-filesystems"),
+ self.todo._qemu_guestfish_cmd(
+ DISQUE, "run", "mount /dev/sda3 /", "ls /home"
+ ),
+ ]
+ for cmd in cmds:
+ with self.subTest(cmd=cmd):
+ self.assertIn("--ro", cmd)
+ self.assertIn(shlex.quote(DISQUE), cmd)
+
+ def test_the_commands_are_chained_the_guestfish_way(self):
+ cmd = self.todo._qemu_guestfish_cmd(
+ DISQUE, "run", "mount /dev/sda3 /", "ls /home"
+ )
+ self.assertIn("run : mount /dev/sda3 / : ls /home", cmd)
+
+ def test_a_hostile_path_cannot_escape_the_shell(self):
+ """Le chemin vient de l'utilisateur : sans échappement, « ; » ouvre
+ une seconde commande."""
+ cmd = self.todo._qemu_guestfish_cmd("/tmp/x.qcow2; rm -rf /", "run")
+ # Le chemin entier tient dans UNE apostrophe : le « ; » y est du
+ # texte, pas un séparateur de commande.
+ self.assertIn("'/tmp/x.qcow2; rm -rf /'", cmd)
+ self.assertNotIn(
+ "qcow2; rm", cmd.replace("'/tmp/x.qcow2; rm -rf /'", "")
+ )
+
+
+class LaVmAllumee(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_a_stopped_vm_goes_straight_through(self):
+ self.todo._qemu_domstate = lambda n: "shut off"
+ self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
+
+ def test_a_running_vm_offers_three_ways(self):
+ self.todo._qemu_domstate = lambda n: "running"
+ vus = {}
+
+ def faux_pick(titre, valeurs, defaut, labels=None):
+ vus["valeurs"] = valeurs
+ vus["defaut"] = defaut
+ return "read"
+
+ self.todo._qemu_pick = faux_pick
+ with mock.patch("builtins.print"):
+ self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
+ self.assertEqual(["read", "shutdown", "cancel"], vus["valeurs"])
+ # L'arrêt propre est le défaut : c'est la seule voie qui donne une
+ # copie fidèle.
+ self.assertEqual("shutdown", vus["defaut"])
+
+ def test_cancelling_stops_everything(self):
+ self.todo._qemu_domstate = lambda n: "running"
+ self.todo._qemu_pick = lambda *a, **k: "cancel"
+ with mock.patch("builtins.print"):
+ self.assertFalse(self.todo._qemu_recover_ready("vm-a"))
+
+ def test_choosing_shutdown_waits_for_it(self):
+ self.todo._qemu_domstate = lambda n: "running"
+ self.todo._qemu_pick = lambda *a, **k: "shutdown"
+ appels = []
+ self.todo._qemu_shutdown_wait = lambda n: appels.append(n) or True
+ with mock.patch("builtins.print"):
+ self.assertTrue(self.todo._qemu_recover_ready("vm-a"))
+ self.assertEqual(["vm-a"], appels)
+
+
+class LExtraction(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.lances = []
+ self.todo.execute = mock.MagicMock()
+ self.todo.execute.exec_command_live.side_effect = (
+ lambda cmd, **k: self.lances.append(cmd) or 0
+ )
+
+ def test_copy_out_names_both_ends(self):
+ with mock.patch.object(qr.os, "makedirs"), mock.patch(
+ "builtins.print"
+ ):
+ ok = self.todo._qemu_recover_copy_out(
+ DISQUE, "/dev/sda3", "/home/erplibre", "/tmp/vm-a-backup"
+ )
+ self.assertTrue(ok)
+ cmd = self.lances[0]
+ self.assertIn("copy-out /home/erplibre /tmp/vm-a-backup", cmd)
+ self.assertIn("mount /dev/sda3 /", cmd)
+ self.assertIn("--ro", cmd)
+
+ def test_an_uncreatable_destination_stops_before_running(self):
+ """guestfish s'arrêterait sur une erreur qui ne dit pas laquelle des
+ deux extrémités manque."""
+ with mock.patch.object(
+ qr.os, "makedirs", side_effect=OSError("lecture seule")
+ ), mock.patch("builtins.print"):
+ ok = self.todo._qemu_recover_copy_out(
+ DISQUE, "/dev/sda3", "/home", "/interdit"
+ )
+ self.assertFalse(ok)
+ self.assertEqual([], self.lances)
+
+
+class LesDiagnostics(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.lances = []
+ self.todo.execute = mock.MagicMock()
+ self.todo.execute.exec_command_live.side_effect = (
+ lambda cmd, **k: self.lances.append(cmd) or 0
+ )
+
+ def test_the_four_probes_answer_four_questions(self):
+ with mock.patch("builtins.print"):
+ self.todo._qemu_recover_diagnostics(DISQUE)
+ joint = " ".join(self.lances)
+ for attendu in (
+ "virt-filesystems",
+ "virt-df",
+ "inspect-os",
+ "libguestfs-test-tool",
+ ):
+ self.assertIn(attendu, joint)
+
+ def test_no_diagnostic_writes_to_the_disk(self):
+ with mock.patch("builtins.print"):
+ self.todo._qemu_recover_diagnostics(DISQUE)
+ for cmd in self.lances:
+ if "guestfish" in cmd:
+ self.assertIn("--ro", cmd)
+
+
+class LeMenu(unittest.TestCase):
+ def test_the_entry_is_wired_in_the_manage_section(self):
+ from pathlib import Path
+
+ source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
+ self.assertIn("Recover files from a VM disk (libguestfs)", source)
+ self.assertIn("self._qemu_recover_files()", source)
+
+ def test_the_config_entry_still_reaches_its_command(self):
+ """L'entrée du catalogue vient de todo.json : elle n'a pas de branche
+ « elif » et dépend du repli par indice. Insérer une entrée codée en
+ dur la décale, et un décalage manqué lancerait la mauvaise. Le numéro
+ se lit sur le menu rendu plutôt qu'écrit ici en dur."""
+ from unittest import mock as m
+
+ from script.todo.todo_i18n import set_lang
+
+ set_lang("fr")
+ todo = TODO()
+ todo._menu_header = lambda: "x"
+ lancees = []
+ todo.execute_from_configuration = lambda e: lancees.append(e)
+ # Le numéro se DÉDUIT du menu : l'entrée de config est la dernière
+ # des entrées numérotées. L'écrire en dur ferait passer le test au
+ # premier réarrangement de sections, sans rien prouver.
+ with m.patch("script.todo.qemu_menu.click") as click, m.patch.object(
+ todo, "_qemu_ensure_tools", return_value=True
+ ), m.patch("builtins.print"):
+ click.prompt.side_effect = ["0"]
+ todo.prompt_execute_qemu()
+ aide = click.prompt.call_args[0][0]
+ numeros = re.findall(r"^\[(\d+)\]", aide, re.M)
+ dernier = max(int(n) for n in numeros)
+ with m.patch("script.todo.qemu_menu.click") as click, m.patch.object(
+ todo, "_qemu_ensure_tools", return_value=True
+ ), m.patch("builtins.print"):
+ click.prompt.side_effect = [str(dernier), "0"]
+ todo.prompt_execute_qemu()
+ self.assertEqual(1, len(lancees), lancees)
+ self.assertIn("dry-run", lancees[0].get("bash_command", ""))
+
+ def test_the_branches_stay_in_order_and_unique(self):
+ """Insérer une entrée décale tout ce qui suit : un numéro en double
+ rendrait une commande inatteignable."""
+ import re
+ from pathlib import Path
+
+ source = Path("script/todo/qemu_menu.py").read_text(encoding="utf-8")
+ debut = source.index("def prompt_execute_qemu")
+ corps = source[debut : source.index("\n def ", debut + 10)]
+ nums = [int(n) for n in re.findall(r'status == "(\d+)"', corps)]
+ self.assertEqual(sorted(nums), nums, nums)
+ self.assertEqual(len(set(nums)), len(nums), nums)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_qemu_start_egl.py b/test/test_qemu_start_egl.py
new file mode 100644
index 0000000..718dd94
--- /dev/null
+++ b/test/test_qemu_start_egl.py
@@ -0,0 +1,750 @@
+#!/usr/bin/env python3
+# © 2026 TechnoLibre (http://www.technolibre.ca)
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
+"""Une 3D écrite dans la définition d'une VM se met à l'épreuve au démarrage.
+
+Le nœud de rendu peut exister sans qu'EGL y démarre : QEMU refuse alors le
+domaine, et la VM reste inutilisable tant que quelqu'un ne défait pas le
+réglage. Rien ne permet de le savoir avant l'essai — la création a déjà son
+repli, le réglage d'une VM existante avait besoin du sien.
+
+Ce que ces tests gardent :
+
+- l'échec EGL est reconnu au démarrage et le retrait est proposé ;
+- le retrait ne touche QUE la 3D : passer « autostart » à None l'éteindrait,
+ et « heads » ferait naître une étape sans rapport ;
+- un échec de démarrage qui n'est pas celui d'EGL n'est pas rattrapé.
+"""
+
+import io
+import os
+import re
+import shlex
+import sys
+import tempfile
+import unittest
+from contextlib import redirect_stdout
+from pathlib import Path
+from unittest import mock
+
+sys.argv = ["todo.py"]
+from script.todo import qemu_hardware as hw # noqa: E402
+from script.todo import qemu_manage as qm # noqa: E402
+from script.todo.todo import TODO # noqa: E402
+
+SORTIE_EGL = (
+ "error: erreur interne : le processus s'est arrêté pendant la connexion"
+ " au moniteur: qemu-system-x86_64: egl: eglInitialize failed:"
+ " EGL_NOT_INITIALIZED\n"
+ "qemu-system-x86_64: egl: render node init failed"
+)
+SORTIE_AUTRE = (
+ "error: Failed to start domain: internal error:"
+ " qemu unexpectedly closed"
+)
+
+ETAT = {
+ "name": "vm-a",
+ "vcpus": 16,
+ "mem_mib": 32768,
+ "video": "virtio",
+ "accel3d": True,
+ "egl": True,
+ "render": "/dev/dri/renderD128",
+ "screen": True,
+ "heads": 1,
+ "cpu": "",
+ "net": "default",
+ "autostart": True,
+}
+
+
+class LaSignature(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+
+ def test_it_recognises_the_egl_failure(self):
+ self.assertTrue(self.todo._qemu_egl_failed(SORTIE_EGL))
+
+ def test_it_ignores_another_failure(self):
+ self.assertFalse(self.todo._qemu_egl_failed(SORTIE_AUTRE))
+
+ def test_an_unloadable_module_does_not_raise(self):
+ """Le menu ne doit pas tomber parce que la source de vérité manque."""
+ self.todo._qemu_import_module = mock.Mock(side_effect=OSError("x"))
+ self.assertFalse(self.todo._qemu_egl_failed(SORTIE_EGL))
+
+
+class LeRetrait(unittest.TestCase):
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.lances = []
+ self.todo.execute = mock.MagicMock()
+ self.todo.execute.exec_command_live.side_effect = (
+ lambda cmd, **k: self.lances.append(cmd) or 0
+ )
+ self.todo._qemu_dumpxml = lambda n, **k: ""
+ self.todo._qemu_autostart = lambda n: True
+ self.todo._qemu_import_module = lambda: _FauxModule()
+
+ def _lancer(self, sortie, reponse="o"):
+ with mock.patch.object(hw, "hw_state", return_value=dict(ETAT)):
+ with mock.patch("builtins.input", return_value=reponse):
+ with mock.patch("builtins.print"):
+ self.todo._qemu_start_failed(
+ "vm-a", "virsh start vm-a", sortie
+ )
+ return self.lances
+
+ def test_another_failure_is_left_alone(self):
+ self.assertEqual([], self._lancer(SORTIE_AUTRE))
+
+ def test_a_refusal_changes_nothing(self):
+ self.assertEqual([], self._lancer(SORTIE_EGL, reponse="n"))
+
+ def test_it_removes_the_3d_then_starts_again(self):
+ lances = self._lancer(SORTIE_EGL)
+ joint = " ".join(lances)
+ self.assertIn("accel3d=off", joint)
+ self.assertIn("--remove-device --graphics type=egl-headless", joint)
+ # Le démarrage revient EN DERNIER : le retrait doit être écrit avant.
+ self.assertEqual("virsh start vm-a", lances[-1])
+
+ def test_it_touches_only_the_3d(self):
+ """Le test précédent passerait même si le retrait éteignait aussi le
+ démarrage automatique : c'est ce que fait « autostart » à None, et
+ seul un contrôle sur les commandes RÉELLEMENT lancées l'attrape."""
+ lances = self._lancer(SORTIE_EGL)
+ # TOUT ce qui précède le démarrage, sans filtrer sur l'outil : la
+ # commande d'autostart passe par virsh et non par virt-xml, donc un
+ # filtre sur « virt-xml » laisserait justement passer la fautive.
+ retraits = lances[:-1]
+ self.assertEqual(2, len(retraits), retraits)
+ joint = " ".join(retraits)
+ self.assertNotIn("--disable", joint)
+ self.assertNotIn("autostart", joint)
+ self.assertNotIn("model.heads", joint)
+
+
+class LeRapport3D(unittest.TestCase):
+ """Ce que l'hôte doit fournir pour qu'une 3D de VM démarre.
+
+ Deux briques distinctes : « egl-headless » ouvre le nœud et crée le
+ contexte EGL par GBM — c'est Mesa qui répond — et virglrenderer ne sert
+ qu'ENSUITE. Les chercher par fichier plutôt que par paquet : les noms de
+ paquets changent d'une distribution à l'autre, pas les emplacements.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
+
+ def _rendu(self, presents):
+ self.todo._qemu_lib_present = lambda motif: (
+ f"/usr/lib/{motif}.0" if motif in presents else ""
+ )
+ buf = io.StringIO()
+ with redirect_stdout(buf):
+ self.todo._qemu_gpu_3d_report()
+ return buf.getvalue()
+
+ def test_it_names_the_two_bricks(self):
+ """virglrenderer sans EGL ne sert à rien : les deux se lisent."""
+ rendu = self._rendu(set())
+ self.assertIn("virglrenderer", rendu)
+ self.assertIn("libEGL.so", rendu)
+ self.assertIn("ui-egl-headless.so", rendu)
+
+ def test_a_missing_piece_is_marked(self):
+ rendu = self._rendu({"libgbm.so"})
+ lignes = [l for l in rendu.splitlines() if "libgbm" in l]
+ self.assertTrue(lignes and lignes[0].strip().startswith("✅"))
+ manquant = [l for l in rendu.splitlines() if "virglrenderer" in l]
+ self.assertTrue(manquant[0].strip().startswith("❌"))
+
+ def test_it_says_the_node_alone_proves_nothing(self):
+ """Le nœud existe dans le cas qui échoue : le rapport doit donc
+ proposer d'éprouver EGL, pas se contenter de le lister."""
+ rendu = self._rendu(set())
+ self.assertIn("/dev/dri/renderD128", rendu)
+ self.assertIn("eglinfo", rendu)
+
+ def test_it_changes_nothing(self):
+ """Un rapport qui installerait quoi que ce soit ne serait plus un
+ rapport."""
+ self.todo.execute = mock.MagicMock()
+ self._rendu(set())
+ self.todo.execute.exec_command_live.assert_not_called()
+
+
+class LeDiagnostic(unittest.TestCase):
+ """Un relevé destiné à quelqu'un qui n'a pas accès à la machine.
+
+ Il doit donc être complet, ne RIEN modifier, et dire à celui qui l'envoie
+ ce qu'il contient — un rapport de machine porte son nom, ses chemins de
+ compte et ses adresses.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
+ self.todo._qemu_lib_present = lambda motif: ""
+ # La proposition d'outils a ses propres tests : ici elle ne ferait
+ # qu'attendre une réponse que personne ne donne.
+ self.todo._qemu_diag_offer_tools = lambda: False
+ self.lances = []
+
+ def _lancer(self, tmp):
+ def faux_run(cmd, **k):
+ self.lances.append(cmd)
+
+ class R:
+ stdout = f"sortie de {cmd}\n"
+ stderr = ""
+ # Le vrai objet de subprocess en porte un, et les helpers
+ # qui appellent virsh s'en servent pour distinguer une
+ # sortie d'un échec. L'omettre faisait mentir le double.
+ returncode = 0
+
+ return R()
+
+ with mock.patch.object(
+ __import__("script.todo.qemu_manage", fromlist=["x"]).subprocess,
+ "run",
+ side_effect=faux_run,
+ ), mock.patch.object(
+ os.path, "expanduser", return_value=tmp
+ ), mock.patch(
+ "builtins.print"
+ ):
+ self.todo._qemu_diagnostics()
+ return sorted(os.listdir(tmp))
+
+ def test_it_writes_one_readable_report(self):
+ with tempfile.TemporaryDirectory() as tmp:
+ fichiers = self._lancer(tmp)
+ self.assertEqual(1, len(fichiers), fichiers)
+ self.assertTrue(fichiers[0].startswith("qemu-diagnostic-"))
+ contenu = Path(tmp, fichiers[0]).read_text(encoding="utf-8")
+ # Les quatre familles qui décident d'un problème QEMU : la machine,
+ # l'hyperviseur, le GPU, et l'interpréteur qui porte virt-xml.
+ # « 3D » couvre l'hôte, « 3D par VM » ce que chaque définition
+ # livrera : deux questions distinctes, deux sections.
+ for attendu in (
+ "uname",
+ "virsh",
+ "dri",
+ "virt-xml",
+ "===== 3D =====",
+ "===== 3D par VM =====",
+ ):
+ self.assertIn(attendu, contenu)
+
+ # Programmes qui ne peuvent que LIRE. « command -v » cherche un outil
+ # sans le lancer : la présence du mot « virt-install » dans une sonde ne
+ # dit donc rien, seul le programme en tête de commande compte.
+ LECTURE_SEULE = {
+ "uname",
+ "cat",
+ "systemd-detect-virt",
+ "ls",
+ "lspci",
+ "eglinfo",
+ "command",
+ "head",
+ "df",
+ "id",
+ "true",
+ "grep",
+ "ps",
+ "sort",
+ "echo",
+ }
+ VIRSH_LECTURE = {"version", "list", "net-list", "dumpxml", "dominfo"}
+
+ def test_every_probe_is_read_only(self):
+ """Un rapport qui modifie l'hôte n'est plus un rapport."""
+ with tempfile.TemporaryDirectory() as tmp:
+ self._lancer(tmp)
+ operateurs = {";", "|", "||", "&&"}
+ for cmd in self.lances:
+ # Le relevé lance deux formes : les sondes, chaînes passées au
+ # shell, et les helpers qui appellent virsh en argv-liste. Une
+ # liste est DÉJÀ découpée — la passer à shlex lèverait.
+ if isinstance(cmd, (list, tuple)):
+ self._juger(list(cmd), cmd)
+ continue
+ # shlex plutôt qu'un découpage sur « | » : le motif de grep en
+ # contient un, et le couper au milieu ferait juger « 3d » comme
+ # s'il était un programme.
+ morceau = []
+ for jeton in shlex.split(cmd) + [";"]:
+ if jeton not in operateurs:
+ morceau.append(jeton)
+ continue
+ if morceau:
+ self._juger(morceau, cmd)
+ morceau = []
+
+ def _juger(self, jetons, cmd):
+ """Un morceau de commande ne doit que lire.
+
+ « sudo » n'est qu'un préfixe : l'admettre en tête ouvrirait la porte
+ à tout. On le retire et on juge le programme qu'il porte.
+ """
+ while jetons and (
+ os.path.basename(jetons[0]) == "sudo" or jetons[0].startswith("-")
+ ):
+ jetons = jetons[1:]
+ if not jetons:
+ return
+ tete = os.path.basename(jetons[0])
+ if tete == "python3":
+ return
+ if tete == "virsh":
+ sous = [
+ j
+ for j in jetons[1:]
+ if not j.startswith("-") and "://" not in j
+ ]
+ self.assertIn(sous[0], self.VIRSH_LECTURE, cmd)
+ return
+ self.assertIn(tete, self.LECTURE_SEULE, cmd)
+
+ def test_a_hanging_probe_does_not_hold_the_report(self):
+ """Une commande qui pend ne doit pas retenir le rapport : son absence
+ de réponse est elle-même une information."""
+ import subprocess as sp
+
+ with tempfile.TemporaryDirectory() as tmp:
+ with mock.patch.object(
+ __import__(
+ "script.todo.qemu_manage", fromlist=["x"]
+ ).subprocess,
+ "run",
+ side_effect=sp.TimeoutExpired("x", 30),
+ ), mock.patch.object(
+ os.path, "expanduser", return_value=tmp
+ ), mock.patch(
+ "builtins.print"
+ ):
+ self.todo._qemu_diagnostics()
+ contenu = Path(tmp, os.listdir(tmp)[0]).read_text(encoding="utf-8")
+ self.assertIn("(timeout)", contenu)
+
+
+class LesOutilsDuRapport(unittest.TestCase):
+ """eglinfo est le seul qui ÉPROUVE EGL : sans lui le rapport dit ce qui
+ est installé, jamais si ça démarre — et c'est la question posée."""
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.lances = []
+ self.todo.execute = mock.MagicMock()
+ self.todo.execute.exec_command_live.side_effect = (
+ lambda cmd, **k: self.lances.append(cmd) or 0
+ )
+
+ def _proposer(self, presents, reponse="o"):
+ """`presents` : les outils du rapport déjà installés.
+
+ UN SEUL patch de shutil.which : « qm.shutil » et le shutil de
+ qemu_privilege sont le MÊME objet, et deux patchs concurrents dessus
+ se recouvrent — l'hôte est donc simulé en une fois, gestionnaire de
+ paquets compris.
+ """
+ connus = set(presents) | {"pacman"}
+
+ def faux_which(binaire):
+ return "/usr/bin/x" if binaire in connus else None
+
+ with mock.patch.object(
+ qm.shutil, "which", side_effect=faux_which
+ ), mock.patch("builtins.input", return_value=reponse), mock.patch(
+ "builtins.print"
+ ):
+ return self.todo._qemu_diag_offer_tools()
+
+ def test_nothing_is_offered_when_all_are_there(self):
+ self.assertFalse(self._proposer({"eglinfo", "lspci"}))
+ self.assertEqual([], self.lances)
+
+ def test_the_package_follows_the_distribution(self):
+ """« mesa-utils » n'existe pas sur Fedora, qui livre « mesa-demos »."""
+ self._proposer(set())
+ joint = " ".join(self.lances)
+ self.assertIn("pacman", joint)
+ self.assertIn("mesa-utils", joint)
+ self.assertIn("pciutils", joint)
+
+ def test_the_command_is_announced_before_the_question(self):
+ """« Les installer ? » ne dit ni ce qui sera lancé ni avec quels
+ droits : la commande entière, sudo compris, précède la question."""
+ connus = {"pacman"}
+ vus = []
+ with mock.patch.object(
+ qm.shutil,
+ "which",
+ side_effect=lambda b: "/usr/bin/x" if b in connus else None,
+ ), mock.patch(
+ "builtins.input",
+ side_effect=lambda p="": (vus.append(("?", p)), "n")[1],
+ ), mock.patch(
+ "builtins.print",
+ side_effect=lambda *a, **k: vus.append(
+ ("!", " ".join(str(x) for x in a))
+ ),
+ ):
+ self.todo._qemu_diag_offer_tools()
+ avant = [txt for genre, txt in vus[: [g for g, _ in vus].index("?")]]
+ joint = "\n".join(avant)
+ self.assertIn("sudo pacman", joint)
+ self.assertIn("mesa-utils", joint)
+
+ def test_a_refusal_installs_nothing(self):
+ self.assertFalse(self._proposer(set(), reponse="n"))
+ self.assertEqual([], self.lances)
+
+ def test_without_a_terminal_it_installs_nothing(self):
+ """Un lancement scripté n'a personne pour répondre : l'invite y lève
+ EOFError, et le rapport — déjà écrit — ne doit pas tomber avec."""
+ connus = {"pacman"}
+ with mock.patch.object(
+ qm.shutil,
+ "which",
+ side_effect=lambda b: "/usr/bin/x" if b in connus else None,
+ ), mock.patch("builtins.input", side_effect=EOFError), mock.patch(
+ "builtins.print"
+ ):
+ self.assertFalse(self.todo._qemu_diag_offer_tools())
+ self.assertEqual([], self.lances)
+
+ def test_only_the_missing_ones_are_proposed(self):
+ self._proposer({"lspci"})
+ self.assertEqual(1, len(self.lances), self.lances)
+ self.assertIn("mesa-utils", self.lances[0])
+
+
+class LeConseilAcl(unittest.TestCase):
+ """La liste de périphériques n'est proposée que si elle explique le
+ blocage : hôte à carte NVIDIA propriétaire, dont la liste ne nomme pas
+ ses nœuds. libvirt y ajoute le nœud de rendu quand le domaine le
+ déclare, jamais ceux de la carte, et la pile propriétaire ouvre les deux.
+ """
+
+ def setUp(self):
+ self.todo = TODO.__new__(TODO)
+ self.todo._qemu_host_gpu_node = lambda: "/dev/dri/renderD128"
+
+ def _conseil(self, nodes, acl):
+ vus = []
+ with mock.patch.object(
+ self.todo, "_qemu_nvidia_nodes", return_value=nodes
+ ), mock.patch.object(
+ self.todo, "_qemu_acl_active", return_value=acl
+ ), mock.patch(
+ "builtins.print",
+ side_effect=lambda *a, **k: vus.append(
+ " ".join(str(x) for x in a)
+ ),
+ ):
+ parle = self.todo._qemu_nvidia_acl_advice()
+ return parle, "\n".join(vus)
+
+ def test_a_host_without_nvidia_hears_nothing(self):
+ parle, rendu = self._conseil([], "")
+ self.assertFalse(parle)
+ self.assertEqual("", rendu)
+
+ def test_a_list_that_already_names_them_is_left_alone(self):
+ """Répéter un conseil déjà suivi le rend invisible quand il compte."""
+ nodes = ["/dev/nvidia0", "/dev/nvidiactl"]
+ parle, _ = self._conseil(
+ nodes, 'cgroup_device_acl = ["/dev/nvidia0", "/dev/nvidiactl"]'
+ )
+ self.assertFalse(parle)
+
+ def test_a_missing_node_brings_the_whole_list_back(self):
+ """La clé REMPLACE le défaut au lieu de s'y ajouter : proposer
+ seulement les nœuds manquants ferait perdre /dev/kvm."""
+ parle, rendu = self._conseil(
+ ["/dev/nvidia0", "/dev/nvidiactl"],
+ 'cgroup_device_acl = ["/dev/nvidia0"]',
+ )
+ self.assertTrue(parle)
+ for attendu in ("/dev/kvm", "/dev/null", "/dev/dri/renderD128"):
+ self.assertIn(attendu, rendu)
+ self.assertIn("AJOUTER", rendu)
+
+ def test_an_unreadable_file_says_so_instead_of_concluding(self):
+ parle, rendu = self._conseil(["/dev/nvidia0"], None)
+ self.assertTrue(parle)
+ self.assertIn("grep -n cgroup_device_acl", rendu)
+
+ def test_unreadable_and_absent_are_not_the_same_answer(self):
+ """Ne pas pouvoir lire n'est pas savoir qu'il n'y a rien : le premier
+ cas doit rendre None, pour que le conseil le DISE au lieu de conclure
+ qu'aucune liste n'existe."""
+ import tempfile
+ from pathlib import Path
+
+ with tempfile.TemporaryDirectory() as tmp:
+ vide = Path(tmp, "vide.conf")
+ vide.write_text("# tout en commentaire\n", encoding="utf-8")
+ with mock.patch.object(type(self.todo), "_QEMU_CONF", str(vide)):
+ self.assertEqual("", self.todo._qemu_acl_active())
+ with mock.patch.object(
+ type(self.todo), "_QEMU_CONF", str(Path(tmp, "absent.conf"))
+ ):
+ self.assertIsNone(self.todo._qemu_acl_active())
+ regle = Path(tmp, "regle.conf")
+ regle.write_text(
+ '#cgroup_device_acl = ["/dev/vieux"]\n'
+ 'cgroup_device_acl = ["/dev/nvidia0"]\n',
+ encoding="utf-8",
+ )
+ with mock.patch.object(type(self.todo), "_QEMU_CONF", str(regle)):
+ actif = self.todo._qemu_acl_active()
+ # La ligne en COMMENTAIRE ne compte pas : la lire ferait croire à un
+ # réglage que libvirt ignore.
+ self.assertIn("/dev/nvidia0", actif)
+ self.assertNotIn("/dev/vieux", actif)
+
+ def test_it_names_the_restart_and_the_vm_recreation(self):
+ """Un redémarrage de l'invité ne suffit pas : la liste s'applique au
+ lancement de QEMU, donc il faut éteindre et rallumer."""
+ _, rendu = self._conseil(["/dev/nvidia0"], "")
+ self.assertIn("systemctl restart", rendu)
+ self.assertIn("is-active", rendu)
+
+
+class _FauxModule:
+ """deploy_qemu réduit à ce que le menu lui demande."""
+
+ @staticmethod
+ def egl_failed(sortie):
+ return "eglInitialize failed" in (sortie or "")
+
+
+class LaSondeVideo(unittest.TestCase):
+ """La 3D d'une VM se lit sur la ligne de commande de son QEMU.
+
+ La définition dit ce qui est DEMANDÉ ; la ligne de commande dit ce qui
+ a été REÇU. Entre les deux, libvirt peut retirer l'accélération sans le
+ signaler, et « egl-headless » s'affiche dans les deux cas — chercher ce
+ seul mot fait conclure à tort que la 3D est en place. Le suffixe
+ « -gl » du device est la pièce qui tranche.
+ """
+
+ ARGV = (
+ "/usr/bin/qemu-system-x86_64",
+ "-name",
+ "guest=une-vm,debug-threads=on",
+ "-device",
+ "%s,id=video0,max_outputs=1,bus=pcie.0",
+ "-display",
+ "egl-headless,rendernode=/dev/dri/renderD128",
+ "-audiodev",
+ '{"id":"audio1","driver":"none"}',
+ )
+
+ def _sonder(self, device, argv0=None, extra=()):
+ """Sortie de la sonde devant un /proc bâti pour l'occasion."""
+ from script.todo.qemu_manage import _DIAG_VIDEO_PY
+
+ import subprocess
+
+ with tempfile.TemporaryDirectory() as tmp:
+ proc = Path(tmp) / "proc" / "4242"
+ proc.mkdir(parents=True)
+ argv = list(self.ARGV)
+ argv[0] = argv0 or argv[0]
+ argv[4] = argv[4] % device
+ argv.extend(extra)
+ (proc / "cmdline").write_bytes("\0".join(argv).encode() + b"\0")
+ programme = _DIAG_VIDEO_PY.replace(
+ "/proc/", str(Path(tmp) / "proc") + "/"
+ ).replace('chemin.split("/")[2]', "chemin")
+ fini = subprocess.run(
+ [sys.executable, "-c", programme],
+ capture_output=True,
+ text=True,
+ )
+ self.assertEqual(fini.returncode, 0, fini.stderr)
+ return fini.stdout
+
+ def test_the_accelerated_device_is_reported(self):
+ sortie = self._sonder("virtio-vga-gl")
+ self.assertIn("une-vm", sortie)
+ self.assertIn("virtio-vga-gl", sortie)
+
+ def test_software_rendering_is_told_apart(self):
+ """L'épreuve du rapport : les deux cas ne doivent PAS se lire pareil.
+
+ « egl-headless » est présent des deux côtés ; un rapport qui ne
+ montrerait que lui laisserait croire la 3D acquise.
+ """
+ accelere = self._sonder("virtio-vga-gl")
+ logiciel = self._sonder("virtio-vga")
+ self.assertIn("egl-headless", accelere)
+ self.assertIn("egl-headless", logiciel)
+ self.assertNotEqual(accelere, logiciel)
+ self.assertNotIn("virtio-vga-gl", logiciel)
+
+ def test_a_process_merely_naming_qemu_is_ignored(self):
+ """Le tri se fait sur argv[0], et non sur la ligne entière.
+
+ Un processus quelconque peut porter « qemu-system » dans ses
+ arguments — un pager ouvert sur un journal, et la sonde elle-même,
+ dont le programme contient le mot. Les compter ferait naître des
+ VM qui n'existent pas. Le contre-exemple porte donc le motif
+ AILLEURS qu'en tête, sans quoi il ne départage rien.
+ """
+ argv = ["/var/log/qemu-system-x86_64.log"]
+ self.assertTrue(any("qemu-system" in a for a in argv))
+ self.assertEqual(
+ self._sonder("virtio-vga-gl", argv0="/usr/bin/less", extra=argv),
+ "",
+ )
+
+ def test_the_diagnostic_carries_the_probe(self):
+ from script.todo.qemu_manage import QemuManageMixin
+
+ sondes = dict(QemuManageMixin._DIAG_PROBES)
+ self.assertIn("video du qemu en cours", sondes)
+
+
+class LesPieces3D(unittest.TestCase):
+ """Le rapport 3D cherche les DEUX devices accélérés, non un seul.
+
+ « virtio-vga-gl » et « virtio-gpu-gl » sont des modules distincts, et
+ les distributions qui découpent QEMU les empaquettent séparément.
+ Celui qui sert dépend du type de vidéo demandé ; n'en chercher qu'un
+ rend un rapport tout vert à côté de la pièce absente.
+ """
+
+ def test_both_accelerated_devices_are_looked_for(self):
+ from script.todo.qemu_manage import QemuManageMixin
+
+ motifs = [m for _, m in QemuManageMixin._GPU_3D_PIECES]
+ for module in (
+ "qemu/hw-display-virtio-vga-gl.so",
+ "qemu/hw-display-virtio-gpu-gl.so",
+ ):
+ self.assertIn(module, motifs)
+
+
+class La3DParVM(unittest.TestCase):
+ """Le rapport dit, VM par VM, ce que le prochain démarrage livrera.
+
+ Trois valeurs y répondent ENSEMBLE et aucune seule : le type de vidéo,
+ « accel3d », et le device figé par libvirt. Une VM dont l'ABI est
+ figée sur un device sans GL tourne sans 3D quoi que demande sa
+ définition, et c'est le cas qu'un rapport doit nommer.
+ """
+
+ XML = (
+ "{n}"
+ ""
+ ""
+ ""
+ )
+
+ def _rendu(self, vms):
+ """Sortie du relevé pour {nom: (device figé, accel3d)}."""
+ faux = {
+ nom: self.XML.format(
+ n=nom,
+ attr=f"device='{dev}'" if dev else "",
+ a=accel,
+ )
+ for nom, (dev, accel) in vms.items()
+ }
+ todo = TODO.__new__(TODO)
+ vus = []
+ with mock.patch.object(
+ TODO, "_qemu_list_domains", lambda s: list(faux)
+ ), mock.patch.object(
+ TODO,
+ "_qemu_dumpxml",
+ staticmethod(lambda n, inactive=True: faux[n]),
+ ), mock.patch(
+ "builtins.print",
+ side_effect=lambda *a, **k: vus.append(
+ " ".join(str(x) for x in a)
+ ),
+ ):
+ todo._qemu_vm_3d_report()
+ return "\n".join(vus)
+
+ def test_a_non_gl_pin_is_named_and_explained(self):
+ rendu = self._rendu({"vm": ("virtio-vga", "yes")})
+ self.assertIn("❌", rendu)
+ self.assertIn("device=virtio-vga", rendu)
+ self.assertIn("accel3d=on", rendu)
+
+ def test_the_repair_is_written_not_run(self):
+ """Un rapport ne modifie rien : la commande est ÉCRITE.
+
+ Elle passe par « define » et non par virt-xml, dont le vocabulaire
+ ne connaît pas partout cet attribut.
+ """
+ rendu = self._rendu({"vm": ("virtio-vga", "yes")})
+ self.assertIn("virtio-vga-gl", rendu)
+ self.assertIn("define", rendu)
+
+ def test_a_gl_pin_is_left_alone(self):
+ """Le suffixe « -gl » distingue les deux devices : sans cette
+ lecture, une VM correctement accélérée serait accusée à tort."""
+ rendu = self._rendu({"vm": ("virtio-vga-gl", "yes")})
+ self.assertIn("✅", rendu)
+ self.assertNotIn("❌", rendu)
+ self.assertNotIn("define", rendu)
+
+ def test_a_vm_that_never_asked_for_3d_is_not_accused(self):
+ rendu = self._rendu({"vm": ("virtio-vga", "no")})
+ self.assertNotIn("❌", rendu)
+ self.assertNotIn("define", rendu)
+
+ def test_only_the_frozen_one_is_counted(self):
+ """Un lot mêlé : le bloc de réparation ne doit paraître qu'une
+ fois, et nommer une VM réellement figée."""
+ rendu = self._rendu(
+ {
+ "saine": ("virtio-vga-gl", "yes"),
+ "figee": ("virtio-vga", "yes"),
+ "sans": ("virtio-vga", "no"),
+ }
+ )
+ self.assertEqual(1, rendu.count("❌"))
+ self.assertEqual(1, rendu.count("define"))
+ self.assertIn("vm=figee", rendu)
+
+
+class LesSectionsDuRapport(unittest.TestCase):
+ """Le rapport s'écrit d'un bloc à la FIN.
+
+ Une section qui lève emporterait donc tout ce qui a été relevé avant
+ elle, et l'utilisateur se retrouverait sans fichier au moment précis
+ où il en a besoin.
+ """
+
+ def test_a_failing_section_becomes_a_line_not_a_loss(self):
+ def casse():
+ raise RuntimeError("sonde cassée")
+
+ texte = TODO._diag_section("essai", casse)
+ self.assertIn("===== essai =====", texte)
+ self.assertIn("RuntimeError", texte)
+ self.assertIn("sonde cassée", texte)
+
+ def test_a_healthy_section_keeps_its_output(self):
+ texte = TODO._diag_section("essai", lambda: print("relevé"))
+ self.assertIn("relevé", texte)
+ self.assertNotIn("Error", texte)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py
index 9066c7c..fb58c3f 100644
--- a/test/test_todo_menu.py
+++ b/test/test_todo_menu.py
@@ -381,6 +381,8 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
"Remote desktop tunnel": "_qemu_tunnel_menu",
"Android emulator": "_qemu_emulator_menu",
"List available images": "_qemu_list_images",
+ "Recover files from a VM disk (libguestfs)": "_qemu_recover_files",
+ "Diagnostics (report to share)": "_qemu_diagnostics",
}