Merge branch 'develop'

[IMP] qemu, todo, git : sous-réseau recréable, sudo motivé, lots tenus

12 commits. Une VM prend son adresse dans le /24 de son réseau et en sort par
le .1 porté par le pont : déplacer ce /24 sous une VM allumée, ou abattre le
réseau, lui laisse un bail qui ne mène nulle part et un tap hors du pont. Le
réseau se lit et se recrée dans l'ordre, un préfixe qui recouvre ce que l'hôte
route déjà est refusé, et l'autostart ne s'arme plus après un net-start refusé
— d'où un hôte sans réseau au redémarrage. Le déploiement dit sur quel constat
sudo est requis et pose ce qu'il installe ; au menu, un dépôt fautif n'emporte
plus son lot, une invite se voit avant la réponse, script/ ne masque plus
GitPython. Vérifié : 4639 tests, hors les 9 de qemu_proxmox que « hostname »
manque à l'hôte.

--- EN ---

12 commits. A VM takes its address in its network's /24 and leaves by the .1
carried by the bridge: moving that /24 under a live VM, or tearing the network
down, leaves it a lease leading nowhere and a tap off the bridge. The network
is read and recreated in order, a prefix overlapping what the host already
routes is refused, and autostart is no longer armed after a refused net-start
— hence a host with no network at the next boot. A deployment says on what it
checked sudo is required and poses what it installs; at the menu, a faulty
repository no longer takes its batch down, a prompt is seen before the answer,
script/ no longer hides GitPython. Checked: 4639 tests, bar the 9 of
qemu_proxmox that a missing `hostname` fails on the host.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-04 04:43:03 -04:00
commit fd25e0d328
33 changed files with 3890 additions and 199 deletions

View file

@ -41,12 +41,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
<!-- [en] -->
- A **Network** section in the QEMU/KVM menu, and the script behind it, `script/qemu/network_qemu.py`. `--status` reads what the libvirt network serves, which bridge carries it, which VMs are attached, what their leases say, and what the host routes elsewhere — nothing is modified. `--recreate` puts the subnet back under those VMs in three ordered steps, and the order IS the feature: the attached VMs are shut down FIRST, since redefining a network under a live VM leaves it with a lease that leads nowhere and a tap no longer on any bridge; then the network is redefined on the wanted prefix — 192.168.122 by default, libvirt's own, the one that ~/.ssh/config entries and old notes assume — and restarted; then exactly the VMs it stopped are started again, a VM already off staying off. A VM that ignores the shutdown CANCELS the redefinition rather than losing its bridge, and `--force-off` is what cuts its power. A target prefix that overlaps what the host already routes is refused: that is how a machine loses its own gateway to a bridge. A network already serving the wanted prefix is not redefined, and the stop-and-start alone still repairs the taps a torn-down network detached
- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone
- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/<version>` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone
- 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
- Development assistants installed INSIDE a VM at deployment, and the pre-configuration that makes them usable without retyping anything: rtk with its global auto-rewrite hook, starship with its shell hook, one agent — Claude Code or opencode —, tig, htop and vim, `merge.conflictStyle=zdiff3`, the checkout's git hooks, the five Claude commands of the repository, and `source .venv.erplibre/bin/activate` in the shell history, where the up arrow finds it. 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. The option works in TWO phases, and the second is named rather than silently dropped: hooks and command templates LIVE in the checkout, so a VM that installs nothing gets the installs and is told what the missing clone costs it. `core.editor` is only posed where there is none, the host's editor already travelling to the VM's `~/.gitconfig` — two authorities over one setting is one too many. Everything in that second phase returns 0: a pre-configuration is a comfort, and the install alone carries the VM's verdict
- An option's help at the deployment form, opened by `?` or F1 and closed by Esc: what each install setting POSES in the VM, listing every step rather than the two or three a checkbox label has room for — the AI tools box alone engages eight. The text lives in the same table as the boxes, so an option added without help shows an empty block instead of a wrong one, and both screens read it, the QEMU/KVM one and the Proxmox VE one
- 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
@ -141,12 +143,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [fr] -->
- Une section **Réseau** au menu QEMU/KVM, et le script qui la porte, `script/qemu/network_qemu.py`. `--status` lit ce que sert le réseau libvirt, quel pont le porte, quelles VM y sont attachées, ce que disent leurs baux et ce que l'hôte route par ailleurs — rien n'est modifié. `--recreate` remet le sous-réseau sous ces VM en trois gestes ordonnés, et l'ordre EST la fonctionnalité : les VM attachées sont arrêtées D'ABORD, redéfinir un réseau sous une VM vivante lui laissant un bail qui ne mène nulle part et un tap qui n'est plus sur aucun pont ; puis le réseau est redéfini sur le préfixe voulu — 192.168.122 par défaut, celui de libvirt, celui que supposent les entrées ~/.ssh/config et les notes prises avant — et redémarré ; puis exactement les VM qu'il a arrêtées sont rallumées, une VM déjà éteinte le restant. Une VM qui n'obéit pas au shutdown ANNULE la redéfinition plutôt que d'y perdre son pont, et « --force-off » est ce qui lui coupe le courant. Un préfixe visé qui recouvre ce que l'hôte route déjà est refusé : c'est ainsi qu'une machine perd sa propre passerelle au profit d'un pont. Un réseau qui sert déjà le préfixe voulu n'est pas redéfini, et le seul cycle d'arrêt-relance répare encore les taps qu'un réseau abattu a détachés
- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent
- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/<version>` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu
- 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
- Les assistants de développement installés DANS une VM au déploiement, et la pré-configuration qui permet de s'en servir sans rien retaper : rtk et son hook global de réécriture, starship avec son accroche au shell, un agent — Claude Code ou opencode —, tig, htop et vim, « merge.conflictStyle=zdiff3 », les hooks git du dépôt, les cinq commandes Claude du dépôt, et « source .venv.erplibre/bin/activate » dans l'historique du shell, là où la flèche du haut le retrouve. 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. L'option travaille en DEUX temps, et le second est nommé plutôt que tu : les hooks et les gabarits de commandes VIVENT dans le dépôt, si bien qu'une VM qui n'installe rien reçoit les poses et s'entend dire ce que le clone absent lui coûte. « core.editor » n'est posé que là où il n'y en a pas, l'éditeur de l'hôte voyageant déjà jusqu'au ~/.gitconfig de la VM — deux autorités sur un même réglage en font une de trop. Tout ce second temps rend 0 : une pré-configuration est un confort, et l'installation seule porte le verdict de la VM
- L'aide d'une option au formulaire de déploiement, ouverte par « ? » ou F1 et fermée par Esc : ce que chaque réglage d'installation POSE dans la VM, toutes ses étapes plutôt que les deux ou trois que le libellé d'une case peut porter — celle des outils IA en engage huit à elle seule. Le texte vit dans la même table que les cases, si bien qu'une option ajoutée sans aide affiche un bloc vide plutôt qu'un texte faux, et les DEUX écrans le lisent, celui de QEMU/KVM comme celui de Proxmox VE
- 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é
@ -247,6 +251,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
<!-- [en] -->
- The RTK menu shows an icon before each of its six entries, the last menu whose lines were told apart by their number alone; the glyphs are taken from those the file already uses rather than picked for themselves, a recent character rendering as tofu on a terminal whose font does not know it
- 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
@ -257,6 +262,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
<!-- [fr] -->
- Le menu RTK affiche une icône devant chacune de ses six entrées, dernier menu dont les lignes ne se distinguaient que par leur numéro ; les glyphes sont pris parmi ceux que le fichier emploie déjà plutôt que choisis pour eux-mêmes, un caractère récent s'affichant en tofu sur un terminal dont la police l'ignore
- 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
@ -271,6 +277,15 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Corrigé
<!-- [en] -->
- A deployment says WHY it needs a password before sudo asks for one, and on what it checked. sudo never states what it is about to do: the prompt lands between two log lines, and one types a password without knowing whether it covers libvirt, a package or a file — the more so as being in the `libvirt` group looks like it should be enough. It is not, and libvirt is not the reason: the disk and the cloud-init seed are written into libvirt's default pool, a root-owned directory where the group grants no write right at all. The reason is therefore CHECKED and not asserted — writing is tested, since an ACL can grant it where `drwxr-xr-x root root` seems to refuse it — then named with the directory, its owner and its mode, next to what the group really does cover: the qemu:///system socket, probed by trying. Said once per run, before the first privileged command, and last on the final review page, which is the screen right before the prompt. Root is told nothing, no prompt being due; a missing virsh reads as missing rather than as a group at fault
- A libvirt network no longer counts as its own collision. A started network carries and routes its /24 on its bridge, and that bridge was read as « the host already occupies this », so the verdict was « collision » on every machine where the network ran, whatever subnet it served. `--setup-host` — and every deployment, which calls the same check — then tore the network down and moved it onto a free /24 where nothing conflicted, leaving the VMs attached to it without a gateway and with a detached tap. The bridge of the network being examined is now excluded from what the host occupies, addresses being read one interface per line so each address can be tied to its own; an unreadable bridge name excludes nothing, silence weighing on the cautious side. The XML handed to `net-define` goes through a real temporary file, unpredictable and removed even when virsh fails, where a name composed of the network's own was a guessable path in a directory everyone writes to
- `--setup-host` no longer leaves a machine that loses its network at the next boot. libvirt's `default` network serves 192.168.122.0/24, and every VM this repository deploys LIVES in that network: its bridge would take the .1 address, which is that machine's own gateway. virsh refuses such a start — but only while the route is there, and at boot libvirtd raises its networks BEFORE the host's DHCP lease arrives: nothing signals the collision any more, virbr0 takes the gateway address, and the host has no network left. Autostart was armed even when the start had just failed, and the message then advised a reboot. The network is now MOVED onto a free /24 by redefinition, which needs neither bridge nor kernel module and therefore works where the start does not, keeping its UUID, bridge name and MAC so the domains naming it still find it; autostart is armed only where no collision remains, and REMOVED where one does. A network found active on a route of the host is torn down first — that is the already-broken machine, and it is what gives the host its network back, before anything else needs downloading. One reboot still suffices where the only obstacle is a kernel replaced since boot
- The state of a libvirt network is read in English. virsh TRANSLATES its labels: under a French locale `net-info` answers « Actif : non », where a pattern on `Active: yes` never matches — so every network read as off, `--setup-host` declared the host not ready whatever its state, and its advice was to reboot for nothing. Every parsed virsh output now goes through one call that forces `LC_ALL=C`, the same gesture, for the same reason, as the QEMU management screen
- The starship installer no longer feeds a 404 page to a shell. The upstream URL pointed at a path that does not exist; the server answers it with a 34 kB HTML page, which `curl -sS` writes to its output anyway while exiting 0, so `sh` stopped on `Syntax error` at its second line — a diagnosis naming neither the wrong URL nor the fact that nothing was installed. The URL is corrected, and `-f` makes curl write nothing and return 22 on any error response, as the file's four other upstream installers already did
- A question asked by a menu command is seen before it is answered. Output was read line by line, so a prompt carrying no newline stayed held back until the following line and reached the screen AFTER the answer: every `[y/N]` was answered blind. Reading now happens in chunks, the trailing fragment is shown at once, and an incremental decoder protects accented characters split across two reads
- One faulty repository no longer takes a whole remote-rewriting batch down with it. A directory emptied by hand passes the is-a-directory test then makes opening the repository raise — an interrupted clone, a deleted `.git`, both common on a development checkout — which abandoned every repository left in a batch of more than a hundred. Such a repository is skipped, the deviations are listed at the END rather than printed as they happen where the trace drowns them, and the exit code stays zero
- The commit-msg hook finds its own library without hiding GitPython. Putting `script/` at the head of `sys.path` made `import git` resolve to the repository's own `script/git/` package rather than to GitPython, for every module imported AFTERWARDS in the same process; the repository root did the same to the standard `test` package. Both go to the tail instead, so installed packages keep their precedence — `unittest discover -p 'test_git*.py'` goes from 49 tests and an import error to 84 green
- A checkout without `.repo` keeps its root repository. Two early returns jumped over the handling of the root, so a missing manifest returned an empty list although the root had been asked for: the script that rewrites the remotes found nothing to do and reported it as a success. Adding the root and sorting go through a single exit point, and a missing origin falls back to the default URL rather than raising
- The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after
- 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'`
@ -297,6 +312,15 @@ 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
<!-- [fr] -->
- Un déploiement dit POURQUOI il faut un mot de passe avant que sudo ne le demande, et sur quel constat. sudo ne dit jamais ce qu'il sert à faire : l'invite tombe entre deux lignes de journal, et l'on tape un mot de passe sans savoir s'il porte sur libvirt, sur un paquet ou sur un fichier — d'autant qu'appartenir au groupe `libvirt` a tout l'air de suffire. Il ne suffit pas, et libvirt n'y est pour rien : le disque et le seed cloud-init s'écrivent dans le pool par défaut de libvirt, un répertoire de root où le groupe ne donne aucun droit d'écriture. La raison est donc CONSTATÉE et non affirmée — l'écriture se teste, une ACL pouvant l'accorder là où « drwxr-xr-x root root » semble la refuser —, puis nommée avec le répertoire, son propriétaire et son mode, à côté de ce que le groupe couvre vraiment : la socket qemu:///system, sondée en essayant. Dit une fois par exécution, avant la première commande privilégiée, et en dernière ligne du récapitulatif final, qui est l'écran juste avant l'invite. Root ne s'entend rien annoncer, aucune invite ne lui étant due ; un virsh absent se lit comme absent, et non comme un groupe en défaut
- Un réseau libvirt ne compte plus comme sa propre collision. Un réseau démarré porte et route son /24 sur son pont, et ce pont était lu comme « l'hôte occupe déjà ceci » : le verdict était donc « collision » sur toute machine où le réseau tournait, quel que soit le sous-réseau qu'il servait. `--setup-host` — et tout déploiement, qui passe par la même vérification — abattait alors le réseau et le déplaçait sur un /24 libre là où rien n'entrait en conflit, laissant les VM qui y étaient attachées sans passerelle et le tap détaché. Le pont du réseau examiné est désormais écarté de ce que l'hôte occupe, les adresses étant lues une interface par ligne pour que chacune se rattache à la sienne ; un nom de pont illisible n'écarte rien, le silence pesant du côté prudent. Le XML remis à « net-define » passe par un vrai fichier temporaire, imprévisible et retiré même quand virsh échoue, là où un nom composé de celui du réseau était un chemin devinable dans un répertoire où tout le monde écrit
- `--setup-host` ne laisse plus derrière lui une machine qui perd son réseau au démarrage suivant. Le réseau « default » de libvirt sert 192.168.122.0/24, et toute VM déployée par ce dépôt VIT dans ce réseau : son pont y prendrait l'adresse .1, celle de sa propre passerelle. virsh refuse ce démarrage — mais seulement tant que la route est là, et au démarrage libvirtd monte ses réseaux AVANT que le bail DHCP de l'hôte n'arrive : plus rien ne signale la collision, virbr0 prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. L'autostart était armé même quand le démarrage venait d'échouer, et le message invitait alors à redémarrer. Le réseau est désormais DÉPLACÉ sur un /24 libre par redéfinition — laquelle ne demande ni pont ni module du noyau, donc elle passe là où le démarrage ne passe pas — en gardant son UUID, le nom de son pont et son adresse MAC, si bien que les domaines qui le nomment le retrouvent ; l'autostart ne s'arme que là où aucune collision ne reste, et se RETIRE là où il en reste une. Un réseau trouvé actif sur une route de l'hôte est abattu d'abord : c'est la machine déjà cassée, et c'est ce qui lui rend son accès au réseau, avant même qu'il y ait de quoi télécharger un correctif. Un seul redémarrage suffit toujours quand le seul obstacle est un noyau remplacé depuis le démarrage
- L'état d'un réseau libvirt est lu en anglais. virsh TRADUIT ses étiquettes : sous une locale française, « net-info » répond « Actif : non », où un motif sur « Active: yes » ne trouve jamais rien — tout réseau se lisait donc éteint, « --setup-host » déclarait l'hôte pas prêt quel que soit son état, et son conseil était de redémarrer pour rien. Toute sortie de virsh que l'on analyse passe maintenant par un appel unique qui force « LC_ALL=C », le même geste, pour la même raison, que l'écran de gestion QEMU
- L'installateur de starship ne fait plus avaler une page 404 à un shell. L'URL amont pointait sur un chemin qui n'existe pas ; le serveur y répond par une page HTML de 34 ko, que `curl -sS` écrit tout de même sur sa sortie en rendant 0, si bien que `sh` s'arrêtait sur `Syntax error` à sa deuxième ligne — un diagnostic qui ne dit ni l'URL fautive ni que rien n'a été posé. L'URL est corrigée, et `-f` fait taire curl et rendre 22 sur toute réponse d'erreur, comme le faisaient déjà les quatre autres installateurs amont du fichier
- Une question posée par une commande du menu se voit avant qu'on y réponde. La sortie était lue ligne par ligne, donc une invite sans saut de ligne restait retenue jusqu'à la ligne suivante et arrivait à l'écran APRÈS la réponse : tout « [o/N] » se répondait à l'aveugle. La lecture se fait désormais par blocs, le reliquat est montré aussitôt, et un décodeur incrémental protège les accents coupés entre deux lectures
- Un dépôt fautif n'emporte plus tout un lot de réécriture de remotes. Un répertoire vidé à la main passe le test du répertoire puis fait lever l'ouverture du dépôt — clone interrompu, `.git` effacé, deux états courants sur un checkout de développement — ce qui abandonnait tous les dépôts restants d'un lot de plus de cent. Un tel dépôt est ignoré, les écarts sont listés à la FIN plutôt qu'affichés au fil de l'eau où la trace les noie, et la sortie reste nulle
- Le hook commit-msg trouve sa bibliothèque sans masquer GitPython. Mettre `script/` en tête de `sys.path` faisait résoudre `import git` vers le paquet `script/git/` du dépôt plutôt que vers GitPython, pour tout module importé ENSUITE dans le même processus ; la racine du dépôt faisait de même au paquet standard `test`. Les deux passent en queue, si bien que les paquets installés gardent la priorité — `unittest discover -p 'test_git*.py'` passe de 49 tests et une erreur d'import à 84 verts
- Un checkout sans `.repo` garde son dépôt racine. Deux retours anticipés sautaient par-dessus le traitement de la racine, si bien qu'un manifeste absent rendait une liste vide alors que la racine était demandée : le script qui réécrit les remotes ne trouvait rien à faire et l'annonçait comme un succès. L'ajout de la racine et le tri passent par un point de sortie unique, et une origine absente vaut l'URL par défaut plutôt qu'une exception
- Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après
- 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' »

View file

@ -15,12 +15,14 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Ajouté
- Une section **Réseau** au menu QEMU/KVM, et le script qui la porte, `script/qemu/network_qemu.py`. `--status` lit ce que sert le réseau libvirt, quel pont le porte, quelles VM y sont attachées, ce que disent leurs baux et ce que l'hôte route par ailleurs — rien n'est modifié. `--recreate` remet le sous-réseau sous ces VM en trois gestes ordonnés, et l'ordre EST la fonctionnalité : les VM attachées sont arrêtées D'ABORD, redéfinir un réseau sous une VM vivante lui laissant un bail qui ne mène nulle part et un tap qui n'est plus sur aucun pont ; puis le réseau est redéfini sur le préfixe voulu — 192.168.122 par défaut, celui de libvirt, celui que supposent les entrées ~/.ssh/config et les notes prises avant — et redémarré ; puis exactement les VM qu'il a arrêtées sont rallumées, une VM déjà éteinte le restant. Une VM qui n'obéit pas au shutdown ANNULE la redéfinition plutôt que d'y perdre son pont, et « --force-off » est ce qui lui coupe le courant. Un préfixe visé qui recouvre ce que l'hôte route déjà est refusé : c'est ainsi qu'une machine perd sa propre passerelle au profit d'un pont. Un réseau qui sert déjà le préfixe voulu n'est pas redéfini, et le seul cycle d'arrêt-relance répare encore les taps qu'un réseau abattu a détachés
- Un outil VPN, cinq technologies libres au menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — accessible depuis **TODO › Execute › Réseau › VPN** et depuis la section Déploiement. Ce qui n'est pas secret (hôte, utilisateur, routes, MTU) vit dans une configuration JSON lisible et les clés pré-partagées comme les mots de passe dans un coffre KeePassXC : un profil peut donc être montré, comparé et partagé sans donner de quoi monter le tunnel. Les secrets s'écrivent en tmpfs sous 0700 et jamais sur un disque persistant ; `--dry-run` montre chaque geste privilégié sans en exécuter un, et l'outil tourne sous votre identité, chaque étape appelant sudo d'elle-même. Demander tout le trafic par le tunnel ne coupe plus la session SSH qui vient d'en donner l'ordre — l'adresse de l'opérateur, lue dans `SSH_CONNECTION`, reçoit sa propre route de survie, et toutes sont retirées au démontage. Seul L2TP/IPsec a monté un tunnel contre un concentrateur réel ; les quatre autres portent au choix une étoile disant que seuls des tests unitaires les couvrent
- `diagnose` nomme l'étage fautif du plus bas au plus haut, pour que la première ligne fausse soit la cause et non une conséquence : ce que le noyau expose · les paquets · la vérification propre à la technologie · interface et adresses · chaque route déclarée · une adresse témoin qui ne répond qu'à travers le tunnel · les journaux. L'étage du noyau attrape ce qu'aucune configuration ne rattrape : mettre à jour le paquet du noyau remplace `/lib/modules/<version>` et le noyau qui tourne ne peut plus charger aucun module, si bien que l'IPsec devient indisponible sur un noyau qui le prend en charge, que charon abandonne à l'initialisation et que le symptôme ressort trois étages plus haut en connexion jamais chargée. Le seul remède est un redémarrage, et il est PROPOSÉ, jamais fait : ni à blanc, ni sans terminal pour répondre, et seulement quand la capacité manque ET que les modules ont disparu
- 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
- Les assistants de développement installés DANS une VM au déploiement, et la pré-configuration qui permet de s'en servir sans rien retaper : rtk et son hook global de réécriture, starship avec son accroche au shell, un agent — Claude Code ou opencode —, tig, htop et vim, « merge.conflictStyle=zdiff3 », les hooks git du dépôt, les cinq commandes Claude du dépôt, et « source .venv.erplibre/bin/activate » dans l'historique du shell, là où la flèche du haut le retrouve. 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. L'option travaille en DEUX temps, et le second est nommé plutôt que tu : les hooks et les gabarits de commandes VIVENT dans le dépôt, si bien qu'une VM qui n'installe rien reçoit les poses et s'entend dire ce que le clone absent lui coûte. « core.editor » n'est posé que là où il n'y en a pas, l'éditeur de l'hôte voyageant déjà jusqu'au ~/.gitconfig de la VM — deux autorités sur un même réglage en font une de trop. Tout ce second temps rend 0 : une pré-configuration est un confort, et l'installation seule porte le verdict de la VM
- L'aide d'une option au formulaire de déploiement, ouverte par « ? » ou F1 et fermée par Esc : ce que chaque réglage d'installation POSE dans la VM, toutes ses étapes plutôt que les deux ou trois que le libellé d'une case peut porter — celle des outils IA en engage huit à elle seule. Le texte vit dans la même table que les cases, si bien qu'une option ajoutée sans aide affiche un bloc vide plutôt qu'un texte faux, et les DEUX écrans le lisent, celui de QEMU/KVM comme celui de Proxmox VE
- 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é
@ -117,6 +119,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Modifié
- Le menu RTK affiche une icône devant chacune de ses six entrées, dernier menu dont les lignes ne se distinguaient que par leur numéro ; les glyphes sont pris parmi ceux que le fichier emploie déjà plutôt que choisis pour eux-mêmes, un caractère récent s'affichant en tofu sur un terminal dont la police l'ignore
- 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
@ -127,6 +130,15 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi
## Corrigé
- Un déploiement dit POURQUOI il faut un mot de passe avant que sudo ne le demande, et sur quel constat. sudo ne dit jamais ce qu'il sert à faire : l'invite tombe entre deux lignes de journal, et l'on tape un mot de passe sans savoir s'il porte sur libvirt, sur un paquet ou sur un fichier — d'autant qu'appartenir au groupe `libvirt` a tout l'air de suffire. Il ne suffit pas, et libvirt n'y est pour rien : le disque et le seed cloud-init s'écrivent dans le pool par défaut de libvirt, un répertoire de root où le groupe ne donne aucun droit d'écriture. La raison est donc CONSTATÉE et non affirmée — l'écriture se teste, une ACL pouvant l'accorder là où « drwxr-xr-x root root » semble la refuser —, puis nommée avec le répertoire, son propriétaire et son mode, à côté de ce que le groupe couvre vraiment : la socket qemu:///system, sondée en essayant. Dit une fois par exécution, avant la première commande privilégiée, et en dernière ligne du récapitulatif final, qui est l'écran juste avant l'invite. Root ne s'entend rien annoncer, aucune invite ne lui étant due ; un virsh absent se lit comme absent, et non comme un groupe en défaut
- Un réseau libvirt ne compte plus comme sa propre collision. Un réseau démarré porte et route son /24 sur son pont, et ce pont était lu comme « l'hôte occupe déjà ceci » : le verdict était donc « collision » sur toute machine où le réseau tournait, quel que soit le sous-réseau qu'il servait. `--setup-host` — et tout déploiement, qui passe par la même vérification — abattait alors le réseau et le déplaçait sur un /24 libre là où rien n'entrait en conflit, laissant les VM qui y étaient attachées sans passerelle et le tap détaché. Le pont du réseau examiné est désormais écarté de ce que l'hôte occupe, les adresses étant lues une interface par ligne pour que chacune se rattache à la sienne ; un nom de pont illisible n'écarte rien, le silence pesant du côté prudent. Le XML remis à « net-define » passe par un vrai fichier temporaire, imprévisible et retiré même quand virsh échoue, là où un nom composé de celui du réseau était un chemin devinable dans un répertoire où tout le monde écrit
- `--setup-host` ne laisse plus derrière lui une machine qui perd son réseau au démarrage suivant. Le réseau « default » de libvirt sert 192.168.122.0/24, et toute VM déployée par ce dépôt VIT dans ce réseau : son pont y prendrait l'adresse .1, celle de sa propre passerelle. virsh refuse ce démarrage — mais seulement tant que la route est là, et au démarrage libvirtd monte ses réseaux AVANT que le bail DHCP de l'hôte n'arrive : plus rien ne signale la collision, virbr0 prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. L'autostart était armé même quand le démarrage venait d'échouer, et le message invitait alors à redémarrer. Le réseau est désormais DÉPLACÉ sur un /24 libre par redéfinition — laquelle ne demande ni pont ni module du noyau, donc elle passe là où le démarrage ne passe pas — en gardant son UUID, le nom de son pont et son adresse MAC, si bien que les domaines qui le nomment le retrouvent ; l'autostart ne s'arme que là où aucune collision ne reste, et se RETIRE là où il en reste une. Un réseau trouvé actif sur une route de l'hôte est abattu d'abord : c'est la machine déjà cassée, et c'est ce qui lui rend son accès au réseau, avant même qu'il y ait de quoi télécharger un correctif. Un seul redémarrage suffit toujours quand le seul obstacle est un noyau remplacé depuis le démarrage
- L'état d'un réseau libvirt est lu en anglais. virsh TRADUIT ses étiquettes : sous une locale française, « net-info » répond « Actif : non », où un motif sur « Active: yes » ne trouve jamais rien — tout réseau se lisait donc éteint, « --setup-host » déclarait l'hôte pas prêt quel que soit son état, et son conseil était de redémarrer pour rien. Toute sortie de virsh que l'on analyse passe maintenant par un appel unique qui force « LC_ALL=C », le même geste, pour la même raison, que l'écran de gestion QEMU
- L'installateur de starship ne fait plus avaler une page 404 à un shell. L'URL amont pointait sur un chemin qui n'existe pas ; le serveur y répond par une page HTML de 34 ko, que `curl -sS` écrit tout de même sur sa sortie en rendant 0, si bien que `sh` s'arrêtait sur `Syntax error` à sa deuxième ligne — un diagnostic qui ne dit ni l'URL fautive ni que rien n'a été posé. L'URL est corrigée, et `-f` fait taire curl et rendre 22 sur toute réponse d'erreur, comme le faisaient déjà les quatre autres installateurs amont du fichier
- Une question posée par une commande du menu se voit avant qu'on y réponde. La sortie était lue ligne par ligne, donc une invite sans saut de ligne restait retenue jusqu'à la ligne suivante et arrivait à l'écran APRÈS la réponse : tout « [o/N] » se répondait à l'aveugle. La lecture se fait désormais par blocs, le reliquat est montré aussitôt, et un décodeur incrémental protège les accents coupés entre deux lectures
- Un dépôt fautif n'emporte plus tout un lot de réécriture de remotes. Un répertoire vidé à la main passe le test du répertoire puis fait lever l'ouverture du dépôt — clone interrompu, `.git` effacé, deux états courants sur un checkout de développement — ce qui abandonnait tous les dépôts restants d'un lot de plus de cent. Un tel dépôt est ignoré, les écarts sont listés à la FIN plutôt qu'affichés au fil de l'eau où la trace les noie, et la sortie reste nulle
- Le hook commit-msg trouve sa bibliothèque sans masquer GitPython. Mettre `script/` en tête de `sys.path` faisait résoudre `import git` vers le paquet `script/git/` du dépôt plutôt que vers GitPython, pour tout module importé ENSUITE dans le même processus ; la racine du dépôt faisait de même au paquet standard `test`. Les deux passent en queue, si bien que les paquets installés gardent la priorité — `unittest discover -p 'test_git*.py'` passe de 49 tests et une erreur d'import à 84 verts
- Un checkout sans `.repo` garde son dépôt racine. Deux retours anticipés sautaient par-dessus le traitement de la racine, si bien qu'un manifeste absent rendait une liste vide alors que la racine était demandée : le script qui réécrit les remotes ne trouvait rien à faire et l'annonçait comme un succès. L'ajout de la racine et le tri passent par un point de sortie unique, et une origine absente vaut l'URL par défaut plutôt qu'une exception
- Le coffre KeePassXC s'ouvre sur une machine sans tkinter, c'est-à-dire sur tout serveur. Les deux imports partageaient un seul `try`, si bien que l'absence de tkinter mettait aussi PyKeePass à None : le coffre restait inouvrable même avec chemin et mot de passe configurés, alors que le journal annonçait « pykeepass is not installed » et que pykeepass 4.2 était là. tkinter ne sert qu'au sélecteur de fichier, quand aucun chemin n'est configuré. L'invite nomme aussi le coffre avant d'en demander le mot de passe, et non après
- 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' »

View file

@ -15,12 +15,14 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Added
- A **Network** section in the QEMU/KVM menu, and the script behind it, `script/qemu/network_qemu.py`. `--status` reads what the libvirt network serves, which bridge carries it, which VMs are attached, what their leases say, and what the host routes elsewhere — nothing is modified. `--recreate` puts the subnet back under those VMs in three ordered steps, and the order IS the feature: the attached VMs are shut down FIRST, since redefining a network under a live VM leaves it with a lease that leads nowhere and a tap no longer on any bridge; then the network is redefined on the wanted prefix — 192.168.122 by default, libvirt's own, the one that ~/.ssh/config entries and old notes assume — and restarted; then exactly the VMs it stopped are started again, a VM already off staying off. A VM that ignores the shutdown CANCELS the redefinition rather than losing its bridge, and `--force-off` is what cuts its power. A target prefix that overlaps what the host already routes is refused: that is how a machine loses its own gateway to a bridge. A network already serving the wanted prefix is not redefined, and the stop-and-start alone still repairs the taps a torn-down network detached
- A VPN tool, five free technologies at the menu — L2TP/IPsec PSK, WireGuard, OpenVPN, OpenConnect, sshuttle — reachable from **TODO › Execute › Network › VPN** and from the Deployment section. What is not secret (host, user, routes, MTU) lives in readable JSON and the pre-shared keys and passwords in a KeePassXC vault, so a profile can be shown, compared and shared without handing over the means to raise the tunnel. Secrets are written to tmpfs at 0700 and never to a persistent disk; `--dry-run` shows every privileged step without running one, and the tool runs as yourself, each step calling sudo on its own. Asking for all traffic through the tunnel no longer cuts the SSH session that gave the order — the operator's address, read from `SSH_CONNECTION`, gets a survival route of its own and every one of them is withdrawn on teardown. Only L2TP/IPsec has been raised against a real concentrator; the four others are starred at the picker as covered by unit tests alone
- `diagnose` names the failing stage lowest first, so the first false line is the cause and not a consequence: what the kernel exposes · packages · the technology's own check · interface and addresses · each declared route · a witness address that answers only through the tunnel · the journals. The kernel stage catches what no configuration can fix: upgrading the kernel package replaces `/lib/modules/<version>` and the running kernel can load no further module, so IPsec turns unavailable on a kernel that supports it, charon aborts at initialisation, and the symptom surfaces three stages higher as a connection never loaded. The only remedy is a reboot, and it is OFFERED, never done: not on a dry run, not without a terminal to answer, and only when the capability is missing AND the modules are gone
- 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
- Development assistants installed INSIDE a VM at deployment, and the pre-configuration that makes them usable without retyping anything: rtk with its global auto-rewrite hook, starship with its shell hook, one agent — Claude Code or opencode —, tig, htop and vim, `merge.conflictStyle=zdiff3`, the checkout's git hooks, the five Claude commands of the repository, and `source .venv.erplibre/bin/activate` in the shell history, where the up arrow finds it. 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. The option works in TWO phases, and the second is named rather than silently dropped: hooks and command templates LIVE in the checkout, so a VM that installs nothing gets the installs and is told what the missing clone costs it. `core.editor` is only posed where there is none, the host's editor already travelling to the VM's `~/.gitconfig` — two authorities over one setting is one too many. Everything in that second phase returns 0: a pre-configuration is a comfort, and the install alone carries the VM's verdict
- An option's help at the deployment form, opened by `?` or F1 and closed by Esc: what each install setting POSES in the VM, listing every step rather than the two or three a checkbox label has room for — the AI tools box alone engages eight. The text lives in the same table as the boxes, so an option added without help shows an empty block instead of a wrong one, and both screens read it, the QEMU/KVM one and the Proxmox VE one
- 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
@ -115,6 +117,7 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Changed
- The RTK menu shows an icon before each of its six entries, the last menu whose lines were told apart by their number alone; the glyphs are taken from those the file already uses rather than picked for themselves, a recent character rendering as tofu on a terminal whose font does not know it
- 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
@ -125,6 +128,15 @@ Recreating the virtual environment, use installation guide from tool `make`.
## Fixed
- A deployment says WHY it needs a password before sudo asks for one, and on what it checked. sudo never states what it is about to do: the prompt lands between two log lines, and one types a password without knowing whether it covers libvirt, a package or a file — the more so as being in the `libvirt` group looks like it should be enough. It is not, and libvirt is not the reason: the disk and the cloud-init seed are written into libvirt's default pool, a root-owned directory where the group grants no write right at all. The reason is therefore CHECKED and not asserted — writing is tested, since an ACL can grant it where `drwxr-xr-x root root` seems to refuse it — then named with the directory, its owner and its mode, next to what the group really does cover: the qemu:///system socket, probed by trying. Said once per run, before the first privileged command, and last on the final review page, which is the screen right before the prompt. Root is told nothing, no prompt being due; a missing virsh reads as missing rather than as a group at fault
- A libvirt network no longer counts as its own collision. A started network carries and routes its /24 on its bridge, and that bridge was read as « the host already occupies this », so the verdict was « collision » on every machine where the network ran, whatever subnet it served. `--setup-host` — and every deployment, which calls the same check — then tore the network down and moved it onto a free /24 where nothing conflicted, leaving the VMs attached to it without a gateway and with a detached tap. The bridge of the network being examined is now excluded from what the host occupies, addresses being read one interface per line so each address can be tied to its own; an unreadable bridge name excludes nothing, silence weighing on the cautious side. The XML handed to `net-define` goes through a real temporary file, unpredictable and removed even when virsh fails, where a name composed of the network's own was a guessable path in a directory everyone writes to
- `--setup-host` no longer leaves a machine that loses its network at the next boot. libvirt's `default` network serves 192.168.122.0/24, and every VM this repository deploys LIVES in that network: its bridge would take the .1 address, which is that machine's own gateway. virsh refuses such a start — but only while the route is there, and at boot libvirtd raises its networks BEFORE the host's DHCP lease arrives: nothing signals the collision any more, virbr0 takes the gateway address, and the host has no network left. Autostart was armed even when the start had just failed, and the message then advised a reboot. The network is now MOVED onto a free /24 by redefinition, which needs neither bridge nor kernel module and therefore works where the start does not, keeping its UUID, bridge name and MAC so the domains naming it still find it; autostart is armed only where no collision remains, and REMOVED where one does. A network found active on a route of the host is torn down first — that is the already-broken machine, and it is what gives the host its network back, before anything else needs downloading. One reboot still suffices where the only obstacle is a kernel replaced since boot
- The state of a libvirt network is read in English. virsh TRANSLATES its labels: under a French locale `net-info` answers « Actif : non », where a pattern on `Active: yes` never matches — so every network read as off, `--setup-host` declared the host not ready whatever its state, and its advice was to reboot for nothing. Every parsed virsh output now goes through one call that forces `LC_ALL=C`, the same gesture, for the same reason, as the QEMU management screen
- The starship installer no longer feeds a 404 page to a shell. The upstream URL pointed at a path that does not exist; the server answers it with a 34 kB HTML page, which `curl -sS` writes to its output anyway while exiting 0, so `sh` stopped on `Syntax error` at its second line — a diagnosis naming neither the wrong URL nor the fact that nothing was installed. The URL is corrected, and `-f` makes curl write nothing and return 22 on any error response, as the file's four other upstream installers already did
- A question asked by a menu command is seen before it is answered. Output was read line by line, so a prompt carrying no newline stayed held back until the following line and reached the screen AFTER the answer: every `[y/N]` was answered blind. Reading now happens in chunks, the trailing fragment is shown at once, and an incremental decoder protects accented characters split across two reads
- One faulty repository no longer takes a whole remote-rewriting batch down with it. A directory emptied by hand passes the is-a-directory test then makes opening the repository raise — an interrupted clone, a deleted `.git`, both common on a development checkout — which abandoned every repository left in a batch of more than a hundred. Such a repository is skipped, the deviations are listed at the END rather than printed as they happen where the trace drowns them, and the exit code stays zero
- The commit-msg hook finds its own library without hiding GitPython. Putting `script/` at the head of `sys.path` made `import git` resolve to the repository's own `script/git/` package rather than to GitPython, for every module imported AFTERWARDS in the same process; the repository root did the same to the standard `test` package. Both go to the tail instead, so installed packages keep their precedence — `unittest discover -p 'test_git*.py'` goes from 49 tests and an import error to 84 green
- A checkout without `.repo` keeps its root repository. Two early returns jumped over the handling of the root, so a missing manifest returned an empty list although the root had been asked for: the script that rewrites the remotes found nothing to do and reported it as a success. Adding the root and sorting go through a single exit point, and a missing origin falls back to the default URL rather than raising
- The KeePassXC vault opens on a machine without tkinter, which is every server. Both imports shared a single `try`, so a missing tkinter set PyKeePass to None as well: the vault stayed unopenable even with path and password configured, while the log said `pykeepass is not installed` and pykeepass 4.2 was there. tkinter serves only the file picker, when no path is configured. The prompt also names the vault before asking for its password, rather than after
- 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'`

View file

@ -8,6 +8,7 @@
# migration meurt sur un TypeError avant d'avoir rien fait.
from __future__ import annotations
import codecs
import datetime
import logging
import os
@ -183,31 +184,43 @@ class Execute:
executable="/bin/bash",
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1, # Disable buffering for live output
universal_newlines=True, # Handle line breaks correctly
# Octets bruts, SANS tampon. « readline » attendait le saut de
# ligne pour rendre la main : une invite qui n'en porte pas —
# « Continuer ? [o/N] » — restait donc invisible jusqu'à ce que
# la réponse soit déjà tapée. La question s'affichait APRÈS la
# réponse, et l'on répondait à l'aveugle.
bufsize=0,
env=my_env,
)
sink = getattr(self, "log_sink", None)
while True:
line = process.stdout.readline()
if not line:
break
# La sortie du sous-processus passe par le meme filtre que
# la commande : un outil qui reaffiche ses propres arguments
# Le tube porte des octets, et une lecture peut couper un caractère
# accentué ou un emoji en deux. Le décodeur incrémental garde le
# morceau incomplet en attente au lieu de rendre un « ? ».
decoder = codecs.getincrementaldecoder("utf-8")("replace")
fd = process.stdout.fileno()
# « pending » est la ligne en cours, pas encore terminée ; « shown »
# compte ce qui en a déjà été envoyé au terminal, pour ne jamais
# afficher deux fois le même morceau d'invite quand la ligne finit
# par se terminer.
pending = ""
shown = 0
def retenir(ligne):
"""Journaliser et retenir une ligne complète, caviardée."""
nonlocal sink
# La sortie du sous-processus passe par le MÊME filtre que la
# commande : un outil qui réaffiche ses propres arguments
# (« set -x », une trace, odoo_bin.sh) y remettrait le secret
# que la ligne 165 venait d'ecarter.
line = redact_secrets(line)
if not quiet:
print(line, end="")
# que l'affichage de la commande venait d'écarter.
clean = redact_secrets(ligne)
if sink:
# Chaque ligne passe DÉJÀ ici : c'est le seul endroit où
# journaliser sans rien changer à ce que le terminal
# montre. Une erreur d'écriture ne doit jamais faire
# échouer la commande qu'on est en train de suivre.
try:
sink.write(line)
sink.write(clean)
except Exception:
sink = None
if (
@ -216,11 +229,45 @@ class Execute:
):
# Remove last \n char
output_lines.append(
line.removesuffix("\r\n")
clean.removesuffix("\r\n")
.removesuffix("\n")
.removesuffix("\r")
)
while True:
chunk = os.read(fd, 65536)
if not chunk:
break
pending += decoder.decode(chunk)
while True:
coupe = pending.find("\n")
if coupe < 0:
break
ligne = pending[: coupe + 1]
pending = pending[coupe + 1 :]
if not quiet:
print(redact_secrets(ligne[shown:]), end="")
shown = 0
retenir(ligne)
if not quiet:
if len(pending) > shown:
# Le reliquat sans saut de ligne EST l'invite : la
# montrer tout de suite, avant que la commande ne se
# bloque sur la lecture de la réponse.
print(redact_secrets(pending[shown:]), end="")
shown = len(pending)
# Sans vidage explicite, cette invite resterait dans le
# tampon de Python : second endroit où la question se
# perdait, la sortie n'étant vidée qu'au saut de ligne.
sys.stdout.flush()
pending += decoder.decode(b"", True)
if pending:
if not quiet:
print(redact_secrets(pending[shown:]), end="")
sys.stdout.flush()
retenir(pending)
process.wait()
exit_code = process.returncode
if process.returncode != 0 and not quiet:

View file

@ -21,7 +21,13 @@ import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
# APPEND et non insert(0) : ce répertoire est `script/`, qui contient
# `git/`. En tête de `sys.path`, il fait résoudre `import git` vers
# `script/git/` au lieu de GitPython, pour tout module importé ensuite —
# une bibliothèque tierce disparaît alors parce qu'une des nôtres porte le
# même nom. En queue, `lib_identifiant` se trouve toujours, et les paquets
# installés gardent la priorité qui leur revient.
sys.path.append(str(Path(__file__).resolve().parents[1]))
from lib_identifiant import ( # noqa: E402
NOMS_INTERDITS,
@ -29,7 +35,9 @@ from lib_identifiant import ( # noqa: E402
termes_interdits,
)
sys.path.insert(0, str(Path(__file__).resolve().parents[2]))
# Même raison : la racine du dépôt porte un `test/`, qui masquerait le
# paquet `test` de la bibliothèque standard.
sys.path.append(str(Path(__file__).resolve().parents[2]))
try:
from script.todo.todo_i18n import t

View file

@ -7,7 +7,9 @@ import logging
import os
import sys
from colorama import Fore, Style
from git import Repo
from git.exc import InvalidGitRepositoryError, NoSuchPathError
new_path = os.path.normpath(
os.path.join(os.path.dirname(__file__), "..", "..")
@ -59,36 +61,99 @@ def get_config():
return args
def change_remote(git_tool, repo_path, upstream_name, git_to_https):
"""Réécrit les remotes de `repo_path`. Rend le nombre de remotes changés.
Lève ce que GitPython lève : c'est l'appelant qui décide si un dépôt
fautif arrête le lot ou seulement lui-même.
"""
repo_sm = Repo(repo_path)
if upstream_name:
remotes = [a for a in repo_sm.remotes if upstream_name == a.name]
else:
remotes = list(repo_sm.remotes)
for remote in remotes:
url, url_https, url_git = git_tool.get_url(remote.url)
new_url = url_https if git_to_https else url_git
remote.set_url(new_url)
print(f'Remote "{remote.name}" update for {new_url}')
return len(remotes)
def change_all_remotes(
git_tool, lst_repo, root_path, upstream_name="", git_to_https=False
):
"""Parcourt `lst_repo`. Rend (remotes changés, [(chemin, raison)]).
Un dépôt fautif n'arrête PAS le lot : un répertoire vidé à la main, un
clone interrompu ou un `.git` effacé sont des états courants d'un
checkout de développement, et ils n'ont rien à voir avec les cent
quarante autres dépôts qui, eux, attendent leur nouveau remote.
Les ennuis sont RENDUS plutôt qu'affichés au fil de l'eau : noyés dans
la trace d'un lot de cette taille, ils ne se voient plus.
"""
skipped = []
changed = 0
total = len(lst_repo)
for i, repo in enumerate(lst_repo, start=1):
print(f"Nb element {i}/{total}")
repo_name = repo.get("name")
repo_path = os.path.join(root_path, repo_name)
if not os.path.isdir(repo_path):
print(f"Ignore repo {repo_path}")
skipped.append((repo_path, "directory is missing"))
continue
try:
changed += change_remote(
git_tool, repo_path, upstream_name, git_to_https
)
except InvalidGitRepositoryError:
reason = "directory exists but holds no git repository"
except NoSuchPathError:
reason = "path vanished while running"
except Exception as err:
reason = f"{type(err).__name__}: {err}"
else:
continue
print(f"Ignore repo {repo_path}: {reason}")
skipped.append((repo_path, reason))
return changed, skipped
def print_report(total, changed, skipped):
"""Le bilan, en fin de course et en couleur.
Une ligne d'avertissement au moment où elle survient est perdue : ce
que l'humain lit d'un lot long, c'est sa fin.
"""
print(f"\n{'=' * 72}")
print(f"{total} repo, {changed} remote updated, {len(skipped)} skipped")
if not skipped:
return
print(f"{Fore.YELLOW}Skipped{Style.RESET_ALL}:")
for repo_path, reason in skipped:
print(f" · {repo_path} — {reason}")
def main():
git_tool = GitTool()
config = get_config()
upstream_name = config.upstream
lst_repo = git_tool.get_repo_info(config.dir, add_root=True)
i = 0
total = len(lst_repo)
for repo in lst_repo:
i += 1
print(f"Nb element {i}/{total}")
repo_name = repo.get("name")
relative_path = os.path.join(new_path, repo_name)
if not os.path.isdir(relative_path):
print(f"Ignore repo {relative_path}")
continue
repo_sm = Repo(repo_name)
if upstream_name:
remote_upstream_name = [
a for a in repo_sm.remotes if upstream_name == a.name
]
else:
remote_upstream_name = [a for a in repo_sm.remotes]
for remote in remote_upstream_name:
url, url_https, url_git = git_tool.get_url(remote.url)
new_url = url_https if config.git_to_https else url_git
remote.set_url(new_url)
print(f'Remote "{remote.name}" update for {new_url}')
changed, skipped = change_all_remotes(
git_tool,
lst_repo,
new_path,
upstream_name=config.upstream,
git_to_https=config.git_to_https,
)
print_report(len(lst_repo), changed, skipped)
# Sortie nulle même avec des dépôts ignorés : tout ce qui POUVAIT être
# changé l'a été, et le bilan porte le reste. C'est déjà le contrat
# tenu pour un répertoire absent, qui n'a jamais fait échouer le lot.
return 0
if __name__ == "__main__":
main()
sys.exit(main())

View file

@ -116,6 +116,48 @@ class GitTool:
repo_path=repo_path, add_root=add_root
)
def _with_root_repo(
self, repos: list, repo_path: str, add_root: bool
) -> list:
"""`repos` précédée du dépôt racine si `add_root`, puis triée.
TOUS les chemins de sortie passent par ici. Le dépôt racine ne
dépend ni du manifeste ni des sous-modules : un retour anticipé qui
saute cette étape rend une liste VIDE alors que la racine était
demandée, et l'appelant en conclut qu'il n'a rien à faire. Sur un
checkout sans `.repo`, le remote du dépôt principal n'était alors
jamais changé, sans qu'aucun message ne le signale.
Une origine absente vaut l'URL par défaut plutôt qu'une exception :
la liste sert à RÉÉCRIRE les remotes, et un dépôt sans origine est
précisément un de ceux qu'on vient corriger.
"""
if not add_root:
return sorted(repos, key=lambda k: k.get("name"))
repo_root = Repo(repo_path)
try:
url = repo_root.git.remote("get-url", "origin")
except Exception:
print(
f"{Fore.YELLOW}WARNING{Style.RESET_ALL}: Missing origin"
f" remote, use default url {DEFAULT_REMOTE_URL}. Suggest"
" to add a remote origin: \n> git remote add origin"
f" {DEFAULT_REMOTE_URL}"
)
url = DEFAULT_REMOTE_URL
url, url_https, url_git = self.get_url(url)
repos.insert(
0,
{
"url": url,
"url_https": url_https,
"url_git": url_git,
"path": repo_path,
"name": "",
},
)
return sorted(repos, key=lambda k: k.get("name"))
def get_repo_info_submodule(
self, repo_path: str = ".", add_root: bool = False
) -> list:
@ -181,22 +223,7 @@ class GitTool:
}
repos.append(data)
if add_root:
repo_root = Repo(repo_path)
url = repo_root.git.remote("get-url", "origin")
url, url_https, url_git = self.get_url(url)
data = {
"url": url,
"url_https": url_https,
"url_git": url_git,
"path": repo_path,
"name": "",
}
repos.insert(0, data)
# Sort
repos = sorted(repos, key=lambda k: k.get("name"))
return repos
return self._with_root_repo(repos, repo_path, add_root)
def get_repo_info_manifest_xml(
self, repo_path: str = ".", add_root: bool = False, filter_group=None
@ -219,7 +246,7 @@ class GitTool:
filter_groups = filter_group.split(",") if filter_group else []
manifest_file = self.get_manifest_file(repo_path=repo_path)
if not manifest_file:
return []
return self._with_root_repo([], repo_path, add_root)
if os.path.isabs(manifest_file):
# This is a absolute path
filename = manifest_file
@ -231,7 +258,7 @@ class GitTool:
xml_dict = xmltodict.parse(xml_as_string)
manifest_data = xml_dict.get("manifest")
if not manifest_data:
return []
return self._with_root_repo([], repo_path, add_root)
if manifest_data.get("default"):
default_remote = manifest_data.get("default").get("@remote")
else:
@ -274,31 +301,7 @@ class GitTool:
}
repos.append(data)
if add_root:
repo_root = Repo(repo_path)
try:
url = repo_root.git.remote("get-url", "origin")
except Exception as e:
print(
f"{Fore.YELLOW}WARNING{Style.RESET_ALL}: Missing origin"
f" remote, use default url {DEFAULT_REMOTE_URL}. Suggest"
" to add a remote origin: \n> git remote add origin"
f" {DEFAULT_REMOTE_URL}"
)
url = DEFAULT_REMOTE_URL
url, url_https, url_git = self.get_url(url)
data = {
"url": url,
"url_https": url_https,
"url_git": url_git,
"path": repo_path,
"name": "",
}
repos.insert(0, data)
# Sort
repos = sorted(repos, key=lambda k: k.get("name"))
return repos
return self._with_root_repo(repos, repo_path, add_root)
def get_manifest_xml_info(
self, repo_path: str = ".", filename=None, add_root: bool = False

View file

@ -685,6 +685,34 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
it first, or redeploy with `--force`.
## The VMs' subnet
Every VM deployed here lives in the libvirt network `default`, which serves a
/24 — `192.168.122.0/24` out of the box. The VMs take an address in it by DHCP
and leave through its `.1`, carried by the bridge. Move that /24 under a
running VM and it keeps a lease that leads nowhere; tear the network down and
its tap is no longer on any bridge, which libvirt does not undo by itself.
`network_qemu.py` reads that state, and puts the subnet back under the VMs:
```bash
# What the network serves, its bridge, its VMs, their leases — reads only
./script/qemu/network_qemu.py --status
# Put the subnet back: stop the attached VMs, redefine, start them again
./script/qemu/network_qemu.py --recreate
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
./script/qemu/network_qemu.py --recreate --force-off # VMs that ignore ACPI
```
The default prefix is libvirt's own, `192.168.122`: it is what the `.ssh/config`
entries and the notes written before assume. A prefix that overlaps what the
host already routes is refused — a bridge taking the host's gateway address is
how a machine loses its own network. A VM that ignores the shutdown cancels the
redefinition rather than losing its bridge under it.
Both are also at **TODO › Execute › Deploy › QEMU/KVM**, section **Network**.
## SSH access from another machine (ProxyJump)
With the default NAT network the VM is reachable **only from the KVM host**.
@ -697,6 +725,36 @@ host as a jump host (it already reaches the VM). Get the VM IP with
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
`undefine` d'abord, ou redéployez avec `--force`.
## Le sous-réseau des VM
Toute VM déployée ici vit dans le réseau libvirt « default », qui sert un /24 —
`192.168.122.0/24` à l'installation. Les VM y prennent une adresse par DHCP et
en sortent par le `.1`, porté par le pont. Déplacer ce /24 sous une VM allumée
lui laisse un bail qui ne mène nulle part ; abattre le réseau détache son tap
du pont, et libvirt ne l'y remet pas de lui-même.
`network_qemu.py` lit cet état, et remet le sous-réseau sous les VM :
```bash
# Ce que sert le réseau, son pont, ses VM, leurs baux — ne modifie rien
./script/qemu/network_qemu.py --status
# Remettre le sous-réseau : arrêter les VM attachées, redéfinir, relancer
./script/qemu/network_qemu.py --recreate
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
./script/qemu/network_qemu.py --recreate --force-off # VM sourdes à l'ACPI
```
Le préfixe par défaut est celui de libvirt, `192.168.122` : c'est celui que
supposent les entrées de `.ssh/config` et les notes prises avant. Un préfixe
qui recouvre ce que l'hôte route déjà est refusé — un pont qui prend l'adresse
de la passerelle de l'hôte, c'est ainsi qu'une machine perd son réseau. Une VM
qui n'obéit pas au shutdown annule la redéfinition plutôt que d'y perdre son
pont.
Les deux sont aussi au menu **TODO › Execute › Deploy › QEMU/KVM**, section
**Réseau**.
## Accès SSH depuis une autre machine (ProxyJump)
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte

View file

@ -371,6 +371,36 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
définition. Pour recréer proprement une VM du même nom, faites `destroy` +
`undefine` d'abord, ou redéployez avec `--force`.
## Le sous-réseau des VM
Toute VM déployée ici vit dans le réseau libvirt « default », qui sert un /24 —
`192.168.122.0/24` à l'installation. Les VM y prennent une adresse par DHCP et
en sortent par le `.1`, porté par le pont. Déplacer ce /24 sous une VM allumée
lui laisse un bail qui ne mène nulle part ; abattre le réseau détache son tap
du pont, et libvirt ne l'y remet pas de lui-même.
`network_qemu.py` lit cet état, et remet le sous-réseau sous les VM :
```bash
# Ce que sert le réseau, son pont, ses VM, leurs baux — ne modifie rien
./script/qemu/network_qemu.py --status
# Remettre le sous-réseau : arrêter les VM attachées, redéfinir, relancer
./script/qemu/network_qemu.py --recreate
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
./script/qemu/network_qemu.py --recreate --force-off # VM sourdes à l'ACPI
```
Le préfixe par défaut est celui de libvirt, `192.168.122` : c'est celui que
supposent les entrées de `.ssh/config` et les notes prises avant. Un préfixe
qui recouvre ce que l'hôte route déjà est refusé — un pont qui prend l'adresse
de la passerelle de l'hôte, c'est ainsi qu'une machine perd son réseau. Une VM
qui n'obéit pas au shutdown annule la redéfinition plutôt que d'y perdre son
pont.
Les deux sont aussi au menu **TODO › Execute › Deploy › QEMU/KVM**, section
**Réseau**.
## Accès SSH depuis une autre machine (ProxyJump)
Avec le réseau NAT par défaut, la VM n'est joignable que **depuis l'hôte

View file

@ -330,6 +330,34 @@ sudo virsh domifaddr <nom-vm> # adresse IP de la VM / VM IP address
definition. To fully recreate a VM with the same name, `destroy` + `undefine`
it first, or redeploy with `--force`.
## The VMs' subnet
Every VM deployed here lives in the libvirt network `default`, which serves a
/24 — `192.168.122.0/24` out of the box. The VMs take an address in it by DHCP
and leave through its `.1`, carried by the bridge. Move that /24 under a
running VM and it keeps a lease that leads nowhere; tear the network down and
its tap is no longer on any bridge, which libvirt does not undo by itself.
`network_qemu.py` reads that state, and puts the subnet back under the VMs:
```bash
# What the network serves, its bridge, its VMs, their leases — reads only
./script/qemu/network_qemu.py --status
# Put the subnet back: stop the attached VMs, redefine, start them again
./script/qemu/network_qemu.py --recreate
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
./script/qemu/network_qemu.py --recreate --force-off # VMs that ignore ACPI
```
The default prefix is libvirt's own, `192.168.122`: it is what the `.ssh/config`
entries and the notes written before assume. A prefix that overlaps what the
host already routes is refused — a bridge taking the host's gateway address is
how a machine loses its own network. A VM that ignores the shutdown cancels the
redefinition rather than losing its bridge under it.
Both are also at **TODO › Execute › Deploy › QEMU/KVM**, section **Network**.
## SSH access from another machine (ProxyJump)
With the default NAT network the VM is reachable **only from the KVM host**.

View file

@ -47,15 +47,19 @@ from __future__ import annotations
import argparse
import base64
import contextlib
import getpass
import grp
import gzip
import hashlib
import ipaddress
import zlib
import os
import re
import shutil
import pwd
import socket
import stat as stat_mod
import subprocess
import sys
import tempfile
@ -439,7 +443,12 @@ FEDORA_SECONDARY_MASTER = (
# Répertoire de cache par défaut des images cloud (cohérent avec --disk-dir /
# --seed-dir). L'écriture y nécessite root : le déploiement tourne de toute
# façon sous sudo (virt-install). Surchargez avec --image-dir au besoin.
DEFAULT_IMAGE_DIR = Path("/var/lib/libvirt/images/iso")
# Le pool par défaut de libvirt, et son sous-répertoire de cache d'images.
# Nommés ici et non dans argparse : sudo_facts() doit constater les droits du
# répertoire que le déploiement utilisera VRAIMENT, et deux écritures du même
# chemin divergent dès qu'on en change une.
DEFAULT_DISK_DIR = Path("/var/lib/libvirt/images")
DEFAULT_IMAGE_DIR = DEFAULT_DISK_DIR / "iso"
# Emplacements de la base osinfo-db (détection d'un --osinfo connu).
OSINFO_DB_DIRS: tuple[str, ...] = (
@ -677,6 +686,98 @@ def list_images() -> None:
# --------------------------------------------------------------------------- #
# Utilitaires d'exécution
def repertoire_a_root(chemin: Path) -> tuple | None:
"""(chemin, propriétaire, mode) si l'écriture y est refusée, sinon None.
L'écriture se TESTE, elle ne se déduit pas du mode : une ACL ou un groupe
peut l'accorder là où « drwxr-xr-x root root » la refuse en apparence, et
l'inverse existe aussi. Le propriétaire et le mode ne sont lus qu'ENSUITE,
pour dire au lecteur ce qui bloque.
"""
try:
if not chemin.is_dir() or os.access(chemin, os.W_OK):
return None
infos = chemin.stat()
except OSError:
return None
try:
utilisateur = pwd.getpwuid(infos.st_uid).pw_name
except (KeyError, OSError):
utilisateur = str(infos.st_uid)
try:
groupe = grp.getgrgid(infos.st_gid).gr_name
except (KeyError, OSError):
groupe = str(infos.st_gid)
return (
str(chemin),
f"{utilisateur}:{groupe}",
stat_mod.filemode(infos.st_mode),
)
def sudo_facts(disk_dir: Path | None = None, image_dir: Path | None = None):
"""Pourquoi root est nécessaire ici : des FAITS, constatés sur la machine.
Rend une liste de couples (clé, valeurs) et non des phrases : ce script
les dit en français, le menu TODO les traduit, et la vérification ne vit
qu'à un endroit.
Deux clés. « ecriture » : un répertoire où le déploiement doit écrire et
ne peut pas — c'est le vrai motif, et il tient au RÉPERTOIRE, pas à
libvirt. « socket » : qemu:///system répond-il sans sudo, ce qui dit ce
que le groupe libvirt couvre RÉELLEMENT — appartenir au groupe et en
disposer dans la session courante sont deux choses.
Le tout parce que sudo demande un mot de passe sans jamais dire ce qu'il
sert à faire : l'invite tombe entre deux lignes de journal, et on la subit
sans savoir si elle porte sur libvirt, sur un paquet ou sur un fichier.
"""
faits = []
vus = set()
for chemin in (
disk_dir or DEFAULT_DISK_DIR,
image_dir or DEFAULT_IMAGE_DIR,
):
bloque = repertoire_a_root(Path(chemin))
if bloque and bloque[0] not in vus:
vus.add(bloque[0])
faits.append(("ecriture", bloque))
# Trois valeurs et non deux : « absent » quand virsh n'est pas là. Rendre
# « non » y ferait accuser le groupe libvirt d'un défaut qui n'est pas le
# sien — il n'y a simplement rien à joindre encore.
if shutil.which("virsh") is None:
faits.append(("socket", ("absent",)))
else:
faits.append(("socket", ("ok" if libvirt_ready(False) else "non",)))
return faits
def sudo_lignes(faits) -> list[str]:
"""Les faits mis en phrases, en français, pour ce script."""
lignes = []
for cle, valeurs in faits:
if cle == "ecriture":
chemin, proprio, mode = valeurs
lignes.append(
f"écrire dans {chemin} — vérifié : {proprio} {mode},"
" écriture refusée à cet utilisateur"
)
if any(cle == "ecriture" for cle, _v in faits):
lignes.append(
"le groupe libvirt ouvre la socket qemu:///system, pas ce"
" répertoire"
)
else:
lignes.append("les gestes système du script (service, groupe)")
for cle, valeurs in faits:
if cle == "socket" and valeurs[0] == "non":
lignes.append(
"la socket libvirt ne répond pas non plus sans sudo :"
" groupe absent de cette session, ou libvirt pas démarré"
)
return lignes
# --------------------------------------------------------------------------- #
class Runner:
"""Exécute (ou affiche, en dry-run) les commandes, avec sudo au besoin."""
@ -684,6 +785,24 @@ class Runner:
def __init__(self, use_sudo: bool, dry_run: bool) -> None:
self.use_sudo = use_sudo
self.dry_run = dry_run
# L'explication n'est due qu'UNE fois : sudo garde sa réponse quelques
# minutes, et la répéter à chaque étape noierait le journal.
self._sudo_dit = False
def _annoncer_sudo(self) -> None:
"""Dit pourquoi root, AVANT que sudo ne réclame le mot de passe.
Avant et non après : sudo n'explique jamais ce qu'il sert à faire, et
un mot de passe tapé sans savoir ce qu'il autorise est donné à
l'aveugle. Les raisons sont constatées sur la machine, pas affirmées —
voir sudo_facts().
"""
if self._sudo_dit:
return
self._sudo_dit = True
print("\n🔑 sudo va demander votre mot de passe, pour :")
for ligne in sudo_lignes(sudo_facts()):
print(f" {ligne}")
def run(
self,
@ -700,6 +819,7 @@ class Runner:
seul l'appel qui SAIT réessayer autrement doit le demander.
"""
if privileged and self.use_sudo:
self._annoncer_sudo()
cmd = ["sudo", *cmd]
printable = " ".join(cmd)
if self.dry_run:
@ -1149,10 +1269,15 @@ def setup_host(
ok = libvirt_ready(runner.use_sudo)
print(f" hyperviseur qemu:///system : {'OK' if ok else 'INJOIGNABLE'}")
active, autostart = network_state("default", runner.use_sudo)
# Le sous-réseau est dit ICI parce que c'est de lui que les VM tireront
# leur adresse : déplacé, il n'est plus celui que la documentation de
# libvirt fait attendre.
cidr = network_cidr("default", runner.use_sudo)
print(
f" réseau libvirt « default » : "
f"{'actif' if active else 'INACTIF'}"
f" / {'autostart' if autostart else 'PAS autostart'}"
+ (f" / {cidr}" if cidr else "")
)
if not active and stale:
# Le réseau est déjà « autostart » : après le redémarrage, libvirt le
@ -2955,32 +3080,317 @@ def network_name(network_arg: str) -> str | None:
return None
def c_locale_env() -> dict[str, str]:
"""L'environnement des commandes dont on PARSE la sortie.
virsh TRADUIT ses étiquettes : sous une locale française, « net-info »
répond « Actif : non » et « Démarrage automatique : oui », où un motif
« Active: yes » lit toujours faux. Un réseau démarré passait alors pour
éteint, l'hôte était déclaré « pas prêt » quel que soit son état, et le
message invitait à redémarrer pour rien. Le même geste, pour la même
raison, est dans script/todo/qemu_manage.py.
"""
return {**os.environ, "LC_ALL": "C", "LANG": "C"}
def virsh_out(args: list[str], use_sudo: bool, timeout: int = 20) -> str:
"""La sortie d'un virsh, en anglais, ou '' s'il n'a rien pu dire."""
cmd = (["sudo"] if use_sudo else []) + ["virsh", "-c", LIBVIRT_URI, *args]
try:
res = subprocess.run(
cmd,
capture_output=True,
text=True,
timeout=timeout,
env=c_locale_env(),
)
except (OSError, subprocess.SubprocessError):
return ""
return res.stdout if res.returncode == 0 else ""
def network_state(name: str, use_sudo: bool) -> tuple[bool, bool]:
"""(actif, autostart) d'un réseau libvirt, via « virsh net-info »."""
cmd = (["sudo"] if use_sudo else []) + [
"virsh",
"-c",
LIBVIRT_URI,
"net-info",
name,
]
try:
res = subprocess.run(cmd, capture_output=True, text=True, timeout=15)
except (OSError, subprocess.SubprocessError):
return (False, False)
active = bool(re.search(r"Active:\s*yes", res.stdout, re.IGNORECASE))
autostart = bool(re.search(r"Autostart:\s*yes", res.stdout, re.IGNORECASE))
out = virsh_out(["net-info", name], use_sudo, timeout=15)
active = bool(re.search(r"Active:\s*yes", out, re.IGNORECASE))
autostart = bool(re.search(r"Autostart:\s*yes", out, re.IGNORECASE))
return (active, autostart)
# Le troisième octet où commence la recherche d'un /24 libre. 122 est celui du
# « default » de libvirt, 123 celui de beaucoup d'installations toutes faites :
# partir au-dessus des deux coûte un octet. long_test/deep_qemu.py part du même
# nombre, en le déduisant de la profondeur là où il ne peut pas sonder.
LIBVIRT_NET_BASE = 131
def interface_de_ligne(ligne: str) -> str:
"""L'interface que porte une ligne de « ip route » ou « ip -o addr ».
Deux grammaires pour une seule question. Une route nomme son interface
après « dev » ; « ip -o -4 addr » la donne en deuxième champ, la ligne
commençant par son index (« 3: virbr0 inet … »). Le « -o » est ce qui
rend la seconde lisible : sans lui, l'interface est sur une ligne et ses
adresses sur les suivantes, plus rattachables l'une à l'autre.
"""
mots = ligne.split()
if "dev" in mots:
i = mots.index("dev")
if i + 1 < len(mots):
return mots[i + 1]
if len(mots) >= 2 and mots[0].rstrip(":").isdigit():
# « eth0@if12 » sur une interface appairée : le nom est avant l'arobase.
return mots[1].rstrip(":").split("@")[0]
return ""
def host_networks(exclure_ponts=()) -> list[ipaddress.IPv4Network]:
"""Les réseaux IPv4 que l'hôte porte ou route déjà.
Les deux, adresses ET routes : une interface peut router un réseau sans y
porter d'adresse, et c'est la ROUTE qui décide où part un paquet.
`exclure_ponts` écarte les interfaces dont le réseau EST celui qu'on
examine. Un réseau libvirt actif porte son propre /24 sur son pont, et le
route : compté comme « déjà pris par l'hôte », il se trouve en collision
avec lui-même. Le verdict était alors le même sur toute machine où le
réseau tournait, quel que soit son sous-réseau — d'où un déplacement là où
rien n'entrait en conflit, sous des VM qui perdaient leur passerelle. La
question posée est « quelqu'un D'AUTRE occupe-t-il ce /24 », et le pont du
réseau examiné n'est pas quelqu'un d'autre.
"""
# Un nom vide n'exclut RIEN : network_bridge rend '' quand le XML est
# illisible, et le garder dans l'ensemble écarterait toute ligne dont
# l'interface ne se lit pas — soit, sur une grammaire inattendue, la
# totalité de ce que l'hôte occupe.
exclure = {pont for pont in (exclure_ponts or ()) if pont}
vus: list[ipaddress.IPv4Network] = []
commandes = (
["ip", "-4", "route", "show"],
# « -o » : une adresse par ligne, sinon l'interface est sur la ligne
# d'en-tête et l'adresse sur la suivante, donc plus rattachables.
["ip", "-o", "-4", "addr", "show"],
)
for args in commandes:
try:
out = subprocess.run(
args,
capture_output=True,
text=True,
timeout=10,
env=c_locale_env(),
).stdout
except (OSError, subprocess.SubprocessError):
continue
for ligne in out.splitlines():
if interface_de_ligne(ligne) in exclure:
continue
for cidr in re.findall(r"\b(\d+\.\d+\.\d+\.\d+/\d+)\b", ligne):
try:
vus.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
continue
return vus
def network_cidr(name: str, use_sudo: bool) -> str:
"""Le réseau servi par un réseau libvirt (« 192.168.122.0/24 »), ou ''."""
return cidr_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo))
def bridge_from_network_xml(xml: str) -> str:
"""Le pont déclaré par un XML de réseau libvirt (« virbr0 »), ou ''."""
m = re.search(r"<bridge[^>]*name='([^']+)'", xml)
return m.group(1) if m else ""
def network_bridge(name: str, use_sudo: bool) -> str:
"""Le pont que ce réseau libvirt monte, ou ''.
C'est l'interface qui portera son adresse .1 une fois le réseau démarré :
la seule que la recherche de collision doit s'interdire de compter.
"""
return bridge_from_network_xml(virsh_out(["net-dumpxml", name], use_sudo))
def cidr_from_network_xml(xml: str) -> str:
"""Le réseau déclaré par un XML de réseau libvirt, ou ''.
Le masque est écrit en quatre octets (« 255.255.255.0 ») et non en
longueur de préfixe : ipaddress accepte les deux formes, le reste du code
n'en manipule qu'une.
"""
m = re.search(r"<ip address='([\d.]+)' netmask='([\d.]+)'", xml)
if not m:
return ""
try:
return str(
ipaddress.ip_network(f"{m.group(1)}/{m.group(2)}", strict=False)
)
except ValueError:
return ""
def network_collision(cidr: str, hote: list) -> str:
"""Ce que l'hôte route déjà dans ce réseau, ou '' s'il est libre.
Le « default » de libvirt sert 192.168.122.0/24. Une machine qui VIT dans
ce réseau — toute VM déployée par ce dépôt en sert une — ne peut pas le
servir à son tour : l'adresse .1 du pont est celle de sa propre
passerelle. virsh refuse d'ailleurs le démarrage (« Network is already in
use by interface eth0 »), mais seulement quand la route est LÀ : au
démarrage de la machine, libvirtd monte ses réseaux avant que le bail DHCP
ne soit arrivé, plus rien ne signale la collision, et l'hôte perd sa
passerelle au profit du pont.
"""
if not cidr:
return ""
try:
mien = ipaddress.ip_network(cidr, strict=False)
except ValueError:
return ""
for autre in hote:
if mien.overlaps(autre):
return str(autre)
return ""
def libvirt_networks_cidrs(name: str, use_sudo: bool) -> list:
"""Les réseaux servis par les AUTRES réseaux libvirt de l'hôte.
Un réseau inactif ne route rien : il n'apparaît donc pas dans host_networks
et rien n'empêcherait de lui reprendre son sous-réseau, pour buter dessus
au premier démarrage des deux.
"""
out = []
for autre in virsh_out(["net-list", "--all", "--name"], use_sudo).split():
if autre == name:
continue
cidr = network_cidr(autre, use_sudo)
if cidr:
try:
out.append(ipaddress.ip_network(cidr, strict=False))
except ValueError:
pass
return out
def free_subnet(pris: list) -> str:
"""Un préfixe « 192.168.X » qu'aucun réseau connu ne recouvre, ou ''."""
for troisieme in range(LIBVIRT_NET_BASE, 255):
candidat = ipaddress.ip_network(f"192.168.{troisieme}.0/24")
if not any(candidat.overlaps(autre) for autre in pris):
return f"192.168.{troisieme}"
return ""
def moved_network_xml(xml: str, ancien: str, nouveau: str) -> str:
"""Le XML du réseau, son sous-réseau déplacé, le reste INTACT.
Réécrit plutôt que reconstruit : l'UUID, le nom du pont et son adresse
MAC restent en place, si bien que le réseau garde son identité au lieu
d'en prendre une nouvelle — et les domaines qui le nomment le retrouvent.
"""
return xml.replace(f"{ancien}.", f"{nouveau}.")
@contextlib.contextmanager
def fichier_xml_temporaire(contenu: str, prefixe: str = "erplibre-net-"):
"""Un XML posé sur le disque le temps d'un « virsh net-define », puis ôté.
Créé par mkstemp, donc à un nom que personne ne peut prédire, et retiré à
la sortie même si virsh échoue. Un nom composé — d'un nom de réseau, par
exemple — est un chemin PRÉVISIBLE dans un répertoire où tout le monde
écrit : qui l'occupe d'avance par un lien symbolique choisit ce que root
va définir, et le laisser derrière donne à lire la configuration du parc.
Le fichier est lisible par tous : virsh le lit sous root, et le fichier
d'un utilisateur non privilégié ne l'est pas toujours. Ce qu'il contient
ne sort pas de la définition du réseau, que « net-dumpxml » rend à qui
peut déjà joindre libvirt.
"""
fd, chemin = tempfile.mkstemp(prefix=prefixe, suffix=".xml")
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(contenu)
os.chmod(chemin, 0o644)
yield chemin
finally:
try:
os.unlink(chemin)
except OSError:
pass
def define_network_xml(xml: str, runner: Runner) -> None:
"""Redéfinit un réseau libvirt à partir de son XML."""
with fichier_xml_temporaire(xml) as chemin:
runner.run(
["virsh", "-c", LIBVIRT_URI, "net-define", chemin],
privileged=True,
check=False,
)
def move_network(name: str, cidr: str, collision: str, runner: Runner) -> str:
"""Redéfinit le réseau sur un /24 libre. Rend le nouveau préfixe, ou ''.
Une REDÉFINITION et non un démarrage : elle ne demande ni pont ni module
du noyau, donc elle réussit même sur une machine dont le noyau a été
remplacé depuis le démarrage. C'est ce qui permet d'armer l'autostart
avant le redémarrage sans armer une collision.
"""
xml = virsh_out(["net-dumpxml", name], runner.use_sudo)
if not xml:
print(f" ⚠ réseau « {name} » illisible : rien n'est déplacé.")
return ""
ancien = cidr.rsplit(".", 1)[0].rsplit("/", 1)[0]
libre = free_subnet(
host_networks(exclure_ponts=[bridge_from_network_xml(xml)])
+ libvirt_networks_cidrs(name, runner.use_sudo)
)
if not libre:
print(" ⚠ aucun /24 libre en 192.168.x : rien n'est déplacé.")
return ""
print(
f" Le réseau « {name} » sert {cidr}, que l'hôte route déjà"
f" ({collision})."
)
print(
f" Déplacé sur {libre}.0/24 : sinon le pont prendrait l'adresse de"
" la passerelle de cette machine, qui perdrait son accès au réseau"
" au prochain démarrage."
)
define_network_xml(moved_network_xml(xml, ancien, libre), runner)
return libre
def ensure_network(name: str | None, runner: Runner) -> None:
"""Active le réseau libvirt si besoin. On vérifie d'abord son état pour
éviter le faux « error: network is already active » de virsh quand il
tourne déjà (message purement bruyant, sans conséquence)."""
"""Rend le réseau libvirt utilisable, et SÛR pour le prochain démarrage.
Trois gestes, et leur ordre est tout :
0. ABATTRE un réseau actif qui recouvre une route de l'hôte : c'est la
machine déjà cassée, et son pont porte l'adresse de la passerelle.
1. DÉPLACER le réseau s'il recouvre ce que l'hôte route déjà. Le
« default » de libvirt sert 192.168.122.0/24, et toute VM déployée par
ce dépôt vit dans ce réseau : son pont y prendrait l'adresse .1, celle
de sa propre passerelle. La redéfinition ne demande ni pont ni module
du noyau, donc elle passe même quand le démarrage, lui, ne passe pas.
2. DÉMARRER, puis relire l'état plutôt que de croire le code de retour.
3. ARMER l'autostart SEULEMENT si le sous-réseau est libre de collision.
C'est le geste qui cassait la machine : armé sur un réseau en
collision, libvirtd le monte au démarrage AVANT que le bail DHCP de
l'hôte ne soit là — plus aucune route ne signale la collision, virbr0
prend l'adresse de la passerelle, et l'hôte n'a plus de réseau. Un
réseau qui n'a pas pu démarrer faute de modules du noyau reste, lui,
armé : c'est ce qui rend l'hôte utilisable en UN seul redémarrage.
"""
if not name:
return
if runner.dry_run:
print(f" [dry-run] réseau libvirt '{name}' activé si nécessaire")
print(f" [dry-run] réseau libvirt '{name}' vérifié et activé")
print(" [dry-run] sous-réseau déplacé s'il entre en collision")
runner.run(
["virsh", "-c", LIBVIRT_URI, "net-start", name],
privileged=True,
@ -2993,16 +3403,72 @@ def ensure_network(name: str | None, runner: Runner) -> None:
)
return
active, autostart = network_state(name, runner.use_sudo)
if active and autostart:
cidr = network_cidr(name, runner.use_sudo)
# Son propre pont ne compte pas : un réseau démarré porte et route son /24
# là, et se verrait sinon en collision avec lui-même sur toute machine.
pont = network_bridge(name, runner.use_sudo)
collision = network_collision(cidr, host_networks(exclure_ponts=[pont]))
if collision:
# Un réseau ACTIF en collision est la machine DÉJÀ privée de réseau :
# libvirt refuse ce démarrage quand la route est là, donc le pont a
# pris l'adresse de la passerelle AVANT elle, au démarrage. L'abattre
# est ce qui rend l'accès au réseau à l'hôte, et tout de suite.
if active:
print(
f" ⚠ le réseau « {name} » est actif SUR {collision}, que"
" cette machine route : son pont porte l'adresse de la"
" passerelle. Arrêté pour rendre l'accès au réseau."
)
runner.run(
["virsh", "-c", LIBVIRT_URI, "net-destroy", name],
privileged=True,
check=False,
)
active = False
libre = move_network(name, cidr, collision, runner)
if libre:
cidr = network_cidr(name, runner.use_sudo)
collision = network_collision(
cidr, host_networks(exclure_ponts=[pont])
)
if active and autostart and not collision:
print(f" Réseau libvirt '{name}' déjà actif.")
return
print(f" Configuration du réseau libvirt '{name}'…")
if not active:
print(f" Configuration du réseau libvirt '{name}'…")
runner.run(
["virsh", "-c", LIBVIRT_URI, "net-start", name],
privileged=True,
check=False,
)
active, autostart = network_state(name, runner.use_sudo)
if collision:
# Rien n'a pu le rendre sûr : le désarmer est la seule chose qui
# protège le prochain démarrage.
if autostart:
print(
f" ⚠ autostart RETIRÉ au réseau « {name} » : il recouvre"
f" {collision}, et le monter au démarrage priverait cette"
" machine de sa passerelle."
)
runner.run(
[
"virsh",
"-c",
LIBVIRT_URI,
"net-autostart",
"--disable",
name,
],
privileged=True,
check=False,
)
return
if not autostart:
runner.run(
["virsh", "-c", LIBVIRT_URI, "net-autostart", name],
@ -3458,14 +3924,14 @@ def build_parser() -> argparse.ArgumentParser:
g_vm.add_argument(
"--disk-dir",
type=Path,
default=Path("/var/lib/libvirt/images"),
help="Répertoire du qcow2 de travail (défaut : /var/lib/libvirt/images).",
default=DEFAULT_DISK_DIR,
help=f"Répertoire du qcow2 de travail (défaut : {DEFAULT_DISK_DIR}).",
)
g_vm.add_argument(
"--seed-dir",
type=Path,
default=Path("/var/lib/libvirt/images/iso"),
help="Répertoire du seed.iso (défaut : .../images/iso).",
default=DEFAULT_IMAGE_DIR,
help=f"Répertoire du seed.iso (défaut : {DEFAULT_IMAGE_DIR}).",
)
g_vm.add_argument(
"--network",

480
script/qemu/network_qemu.py Executable file
View file

@ -0,0 +1,480 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le sous-réseau d'un réseau libvirt, et sa remise en place sous les VM.
Un réseau libvirt sert un /24 : ses VM y prennent une adresse par DHCP et le
quittent par le .1, porté par son pont. Changer ce /24 sous des VM allumées
les laisse avec un bail qui ne mène plus nulle part — leur passerelle n'existe
plus, et leur tap n'est même plus sur un pont si le réseau a été abattu.
D'où l'ordre que ce script tient, et qui est tout ce qu'il fait :
0. ARRÊTER les VM attachées au réseau — proprement, et en les attendant ;
1. REDÉFINIR le réseau sur le préfixe voulu, puis le redémarrer ;
2. REDÉMARRER les VM qu'il a arrêtées, elles seules.
Le préfixe par défaut est celui de libvirt (192.168.122) : c'est celui que la
documentation, les entrées ~/.ssh/config et les notes prises avant supposent.
Les fonctions qui savent lire libvirt vivent dans deploy_qemu.py, chargé ici
comme module ; ce fichier n'apporte que l'ordre des gestes.
./script/qemu/network_qemu.py --status
./script/qemu/network_qemu.py --recreate
./script/qemu/network_qemu.py --recreate --prefix 192.168.140
"""
from __future__ import annotations
import argparse
import importlib.util
import ipaddress
import os
import re
import shutil
import sys
import time
from pathlib import Path
# Le préfixe d'origine du « default » de libvirt. Le remettre est le cas
# courant : c'est de lui que partent les baux, les entrées SSH écrites à la
# main et les README.
PREFIXE_LIBVIRT = "192.168.122"
# Ce qu'on laisse à une VM pour s'éteindre d'elle-même. Un arrêt propre passe
# par l'invité : il démonte ses systèmes de fichiers, là où une coupure les
# laisse à rejouer au démarrage suivant.
DELAI_ARRET = 120
def deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait le menu TODO.
Chargé par CHEMIN et non par « import » : script/qemu n'est pas un paquet,
et ce script doit rester lançable depuis n'importe quel répertoire.
"""
path = Path(__file__).resolve().parent / "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()
def domaines(use_sudo: bool) -> list[str]:
"""Tous les domaines définis sur l'hôte, allumés ou non."""
return DQ.virsh_out(["list", "--all", "--name"], use_sudo).split()
def domaine_actif(nom: str, use_sudo: bool) -> bool:
"""La VM tourne-t-elle ? Lu en anglais, virsh traduisant « running »."""
etat = DQ.virsh_out(["domstate", nom], use_sudo).strip()
return etat.startswith("running") or etat.startswith("paused")
def domaines_du_reseau(reseau: str, use_sudo: bool) -> list[str]:
"""Les VM dont une interface nomme ce réseau.
Lu dans la définition PERSISTANTE (« --inactive ») : la vue vivante d'une
VM allumée décore l'interface de ce que libvirt lui a alloué, mais c'est
la définition qui dit à quel réseau la VM revient en démarrant.
"""
attaches = []
motif = re.compile(rf"<source network='{re.escape(reseau)}'")
for nom in domaines(use_sudo):
if motif.search(
DQ.virsh_out(["dumpxml", "--inactive", nom], use_sudo)
):
attaches.append(nom)
return attaches
def baux(reseau: str, use_sudo: bool) -> list[str]:
"""Les lignes de « net-dhcp-leases », en-tête compris, ou []."""
out = DQ.virsh_out(["net-dhcp-leases", reseau], use_sudo)
return [ligne for ligne in out.splitlines() if ligne.strip()]
def prefixe_de(cidr: str) -> str:
"""« 192.168.122.0/24 » -> « 192.168.122 ». '' si rien n'est lisible."""
if not cidr:
return ""
return cidr.split("/")[0].rsplit(".", 1)[0]
def etat(reseau: str, use_sudo: bool) -> None:
"""Ce que sert le réseau, qui y vit, et ce que l'hôte route par ailleurs.
Écrit AVANT toute modification, et lisible seul : c'est le rapport qu'on
relit pour décider s'il y a quelque chose à recréer.
"""
actif, autostart = DQ.network_state(reseau, use_sudo)
cidr = DQ.network_cidr(reseau, use_sudo)
pont = DQ.network_bridge(reseau, use_sudo)
print(f"\n== Réseau libvirt « {reseau} » ==")
print(f" état : {'actif' if actif else 'INACTIF'}")
print(f" autostart : {'oui' if autostart else 'non'}")
print(f" sous-réseau : {cidr or 'illisible'}")
print(f" pont : {pont or 'inconnu'}")
collision = DQ.network_collision(
cidr, DQ.host_networks(exclure_ponts=[pont])
)
if collision:
print(
f" ⚠ collision : l'hôte route déjà {collision} par une AUTRE"
" interface. Le pont y prendrait l'adresse de la passerelle."
)
else:
print(" collision : aucune")
print("\n Ce que l'hôte route (hors ce pont) :")
for reseau_hote in sorted(
{str(r) for r in DQ.host_networks(exclure_ponts=[pont])}
):
print(f" {reseau_hote}")
attaches = domaines_du_reseau(reseau, use_sudo)
print(f"\n VM attachées à « {reseau} » : {len(attaches)}")
for nom in attaches:
vivante = "allumée" if domaine_actif(nom, use_sudo) else "éteinte"
print(f" {nom} ({vivante})")
lignes = baux(reseau, use_sudo)
if lignes:
print("\n Baux DHCP :")
for ligne in lignes:
print(f" {ligne}")
def attendre_extinction(noms: list[str], delai: int, use_sudo: bool) -> list:
"""Attend que ces VM s'éteignent. Rend celles qui tournent encore.
Une seule attente pour tout le monde, et non un délai par VM : elles
s'éteignent en parallèle, les compter l'une après l'autre multiplierait
l'attente par leur nombre.
"""
fin = time.monotonic() + delai
restantes = list(noms)
while restantes and time.monotonic() < fin:
restantes = [n for n in restantes if domaine_actif(n, use_sudo)]
if not restantes:
break
time.sleep(2)
return restantes
def arreter(noms: list[str], runner, delai: int, forcer: bool) -> list[str]:
"""Étape 0 : éteint les VM attachées. Rend celles qui n'ont pas obéi.
L'arrêt est demandé à l'invité (« shutdown ») ; « destroy » coupe le
courant, et ne part qu'à la demande explicite de l'appelant.
"""
if not noms:
print(" Aucune VM attachée à arrêter.")
return []
for nom in noms:
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "shutdown", nom],
privileged=True,
check=False,
)
if runner.dry_run:
return []
print(f" Attente de l'extinction ({delai} s au plus)…")
restantes = attendre_extinction(noms, delai, runner.use_sudo)
if restantes and forcer:
print(f" Arrêt forcé : {', '.join(restantes)}")
for nom in restantes:
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "destroy", nom],
privileged=True,
check=False,
)
restantes = attendre_extinction(restantes, 30, runner.use_sudo)
return restantes
def redefinir(reseau: str, prefixe: str, runner) -> bool:
"""Étape 1 : pose le réseau sur `prefixe`, puis le démarre. Vrai si fait.
La redéfinition garde le XML tel quel, son seul sous-réseau réécrit :
l'UUID, le nom du pont et son adresse MAC restent, si bien que les
domaines qui nomment ce réseau le retrouvent sans être redéfinis eux aussi.
"""
xml = DQ.virsh_out(["net-dumpxml", reseau], runner.use_sudo)
if not xml:
print(f" ⚠ réseau « {reseau} » illisible : rien n'est changé.")
return False
cidr = DQ.cidr_from_network_xml(xml)
ancien = prefixe_de(cidr)
if not ancien:
print(
f" ⚠ sous-réseau de « {reseau} » illisible : rien n'est changé."
)
return False
pont = DQ.bridge_from_network_xml(xml)
vise = f"{prefixe}.0/24"
# Le pont du réseau examiné est écarté : il porte le sous-réseau ACTUEL, et
# le compter ferait passer un réseau démarré pour sa propre collision.
collision = DQ.network_collision(
vise, DQ.host_networks(exclure_ponts=[pont])
)
if collision:
print(
f" ⚠ {vise} recouvre {collision}, que cette machine route déjà."
"\n Le pont y prendrait l'adresse de la passerelle de l'hôte,"
" qui perdrait son accès au réseau. Rien n'est changé."
)
return False
for autre in DQ.libvirt_networks_cidrs(reseau, runner.use_sudo):
if autre.overlaps(ipaddress.ip_network(vise)):
print(
f" ⚠ {vise} est déjà servi par un autre réseau libvirt"
f" ({autre}). Rien n'est changé."
)
return False
actif, _autostart = DQ.network_state(reseau, runner.use_sudo)
if ancien == prefixe:
print(f" Le réseau sert déjà {vise} : aucune redéfinition.")
else:
print(f" {cidr} → {vise}")
if actif:
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "net-destroy", reseau],
privileged=True,
check=False,
)
actif = False
DQ.define_network_xml(
DQ.moved_network_xml(xml, ancien, prefixe), runner
)
if not actif:
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "net-start", reseau],
privileged=True,
check=False,
)
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "net-autostart", reseau],
privileged=True,
check=False,
)
return True
def redemarrer(noms: list[str], runner) -> None:
"""Étape 2 : rallume les VM que l'étape 0 a éteintes, elles seules.
Une VM déjà éteinte AVANT l'opération le reste : ce script recrée un
sous-réseau, il ne décide pas de ce qui doit tourner sur l'hôte.
"""
for nom in noms:
runner.run(
["virsh", "-c", DQ.LIBVIRT_URI, "start", nom],
privileged=True,
check=False,
)
def recreer(args, runner) -> int:
"""Les trois étapes, dans l'ordre. Rend le code de sortie du programme."""
reseau, prefixe = args.network, args.prefix
cidr = DQ.network_cidr(reseau, runner.use_sudo)
attaches = domaines_du_reseau(reseau, runner.use_sudo)
allumees = [n for n in attaches if domaine_actif(n, runner.use_sudo)]
print(f"\n== Recréer le sous-réseau de « {reseau} » ==")
print(f" {cidr or 'sous-réseau illisible'} → {prefixe}.0/24")
print(f" VM attachées : {len(attaches)}, dont allumées : {len(allumees)}")
for nom in allumees:
print(f" {nom}")
print(
"\n Les VM allumées seront ARRÊTÉES, le réseau redéfini, puis"
"\n ces mêmes VM redémarrées. Une VM à adresse fixe dans l'ancien"
"\n sous-réseau devra être corrigée dans l'invité."
)
if not args.assume_yes and not args.dry_run:
if not demander("\n Continuer ? (o/N) : "):
print(" Annulé.")
return 1
print("\n-- 0/2 Arrêt des VM attachées --")
restantes = arreter(allumees, runner, args.timeout, args.force_off)
if restantes:
print(
f" ⚠ encore allumées : {', '.join(restantes)}."
"\n Le réseau n'est PAS redéfini : le faire sous une VM vivante"
"\n la laisserait sans passerelle et sans pont."
"\n Éteignez-les, ou relancez avec --force-off."
)
return 1
print("\n-- 1/2 Redéfinition du réseau --")
if not redefinir(reseau, prefixe, runner):
print(
"\n Le réseau n'a pas été redéfini. Les VM arrêtées sont"
" redémarrées telles quelles."
)
redemarrer(allumees, runner)
return 1
print("\n-- 2/2 Redémarrage des VM --")
redemarrer(allumees, runner)
if not runner.dry_run:
print("\nTerminé. Le nouvel état :")
etat(reseau, runner.use_sudo)
print(
"\n Une VM absente des baux n'a pas encore renouvelé le sien :"
"\n laissez-lui le temps de démarrer, puis relancez --status."
)
return 0
def demander(question: str) -> bool:
"""Question fermée posée sur le terminal. Vaut NON par défaut.
Lue sur /dev/tty quand il existe : le menu TODO branche l'entrée standard
du script sur autre chose que le clavier, et une question sans réponse
possible vaudrait acceptation silencieuse.
"""
try:
with open("/dev/tty", "r+") as tty:
tty.write(question)
tty.flush()
reponse = tty.readline()
except OSError:
try:
reponse = input(question)
except EOFError:
return False
return reponse.strip().lower() in ("o", "oui", "y", "yes")
def build_parser() -> argparse.ArgumentParser:
p = argparse.ArgumentParser(
description=(
"État et remise en place du sous-réseau d'un réseau libvirt."
),
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"Exemples :\n"
" %(prog)s --status\n"
" %(prog)s --recreate\n"
" %(prog)s --recreate --prefix 192.168.140 --assume-yes\n"
),
)
p.add_argument(
"--network",
default="default",
help="Réseau libvirt visé (défaut : default).",
)
p.add_argument(
"--status",
action="store_true",
help="Affiche l'état du réseau, ses VM et ses baux, puis quitte.",
)
p.add_argument(
"--recreate",
action="store_true",
help="Arrête les VM attachées, redéfinit le réseau sur --prefix, "
"puis redémarre ces VM.",
)
p.add_argument(
"--prefix",
default=PREFIXE_LIBVIRT,
help=f"Préfixe /24 visé (défaut : {PREFIXE_LIBVIRT}, celui de "
"libvirt).",
)
p.add_argument(
"--timeout",
type=int,
default=DELAI_ARRET,
help=f"Secondes laissées aux VM pour s'éteindre (défaut : "
f"{DELAI_ARRET}).",
)
p.add_argument(
"--force-off",
action="store_true",
help="Coupe le courant des VM qui n'obéissent pas au shutdown.",
)
p.add_argument(
"-y",
"--assume-yes",
action="store_true",
help="Ne pose pas la question de confirmation.",
)
p.add_argument(
"--dry-run",
action="store_true",
help="Affiche les commandes sans rien exécuter.",
)
p.add_argument(
"--no-sudo",
action="store_true",
help="N'ajoute jamais sudo (utile dans le groupe libvirt).",
)
return p
def valider_prefixe(prefixe: str) -> str:
"""Refuse un préfixe qui n'est pas trois octets. Rend le préfixe."""
if not re.fullmatch(r"\d{1,3}\.\d{1,3}\.\d{1,3}", prefixe):
sys.exit(
f"Préfixe {prefixe!r} invalide : il en faut trois octets, "
"ex. 192.168.122."
)
try:
ipaddress.ip_network(f"{prefixe}.0/24")
except ValueError as err:
sys.exit(f"Préfixe {prefixe!r} invalide : {err}")
return prefixe
def main() -> None:
# Sortie ligne par ligne même dans un tube (menu TODO) : sinon les étapes
# restent bufferisées et l'opération paraît figée.
try:
sys.stdout.reconfigure(line_buffering=True)
except (AttributeError, ValueError):
pass
args = build_parser().parse_args()
if not shutil.which("virsh"):
sys.exit(
"virsh est absent : préparez l'hôte par\n"
" ./script/qemu/deploy_qemu.py --setup-host"
)
runner = DQ.Runner(
use_sudo=not args.no_sudo and os.geteuid() != 0,
dry_run=args.dry_run,
)
if runner.use_sudo:
# Dit avant que sudo ne réclame le mot de passe : la première lecture
# de l'état passe par lui, et un mot de passe tapé sans savoir ce
# qu'il autorise est donné à l'aveugle.
print(
"🔑 sudo va demander votre mot de passe : cet hôte n'ouvre"
" qemu:///system\n qu'à root (appartenir au groupe libvirt"
" l'éviterait)."
)
if args.recreate:
valider_prefixe(args.prefix)
sys.exit(recreer(args, runner))
etat(args.network, runner.use_sudo)
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
# Un Ctrl-C au milieu d'une attente d'extinction n'est pas une panne :
# la trace Python noierait l'état où l'opération s'est arrêtée.
print("\nInterrompu.")
sys.exit(130)

View file

@ -41,6 +41,7 @@ _TABLES = (
("timezones", list, "timezones"),
("timezone", str, "timezone"),
("vm_tools", list, "vm_tools"),
("tool_help", dict, "vm_tool_help"),
("tool_disk", dict, "vm_tool_disk"),
("tool_phases", dict, "vm_tool_phases"),
("tool_arches", dict, "vm_tool_arches"),
@ -53,6 +54,69 @@ _TABLES = (
)
# Ce que chaque réglage POSE, pour l'aide « ? ». Une clé de table en tête
# quand le bloc n'a de sens que si l'écran offre le réglage ; None quand il le
# porte toujours. Les textes disent l'effet dans la VM, pas la manœuvre à
# l'écran : le libellé de la case dit déjà où cliquer.
INSTALL_HELP = (
(
None,
# Le titre de la case, et non « ERPLibre » : elle commande TOUTE
# installation, l'hyperviseur Proxmox VE compris.
"Install software in the VM",
(
"clone of the checkout, then the make target of the profile",
"in ~/git/erplibre, or /opt/erplibre in production",
"unchecked: the VM stays a bare distribution",
"about +5 Go of disk",
),
),
(
None,
"Production (/opt, confined)",
(
"/opt/erplibre instead of ~/git/erplibre",
"Odoo registered as a systemd service",
"automatic updates left ON, unlike a dev VM",
),
),
(
"desktops",
"VM type",
(
"server: SSH only, no graphical session",
"graphical: the chosen desktop plus its remote access",
"an IDE needs a desktop, the build tools do not",
),
),
(
"app_stores",
"Application store",
(
"deb/rpm: packages of the distribution only",
"snap: snapd kept active where the distribution has it",
),
),
(
"timezones",
"Timezone",
(
"IANA name written by cloud-init at first boot",
"a misspelled name is IGNORED: the VM stays in UTC",
),
),
(
"mise_arches",
"Python interpreter",
(
"mise poses a precompiled CPython, in minutes",
"pyenv compiles it, which is much longer",
"mise publishes no binary for every architecture",
),
),
)
def extras_tables(ctx) -> dict:
"""Les tables lues du contexte, chacune ramenée à son type.
@ -187,7 +251,9 @@ class ExtrasMixin:
# Une case par outil, et non une liste déroulante : ils sont
# indépendants, et chacun se prend ou se laisse.
yield Static(
t("Development tools:"), id="t_tools", classes="grouptitle"
f"{t('Development tools:')} {t('(F1 or ? : details)')}",
id="t_tools",
classes="grouptitle",
)
for key, label, hint in tab["vm_tools"]:
gb = tab["tool_disk"].get(key, 0)
@ -236,6 +302,40 @@ class ExtrasMixin:
yield RadioButton(t("pyenv (compiles from source)"))
yield Static("", id="miswarn")
# ------------------------------------------------------------------ #
# L'aide
# ------------------------------------------------------------------ #
def extras_help_blocks(self) -> list:
"""[(titre, lignes)] : ce que chaque option de cet écran installe.
Les réglages communs d'abord, dans l'ordre du formulaire, puis un bloc
par outil OFFERT — un écran qui n'en propose pas n'a rien à en dire.
L'aide vient de la même table que les cases : une seule autorité, et
rien à synchroniser.
Un outil sans texte d'aide retombe sur son indice, qui tient en une
ligne : mieux vaut trop court que vide. Ce repli est un filet, pas une
place où se cacher — un test exige une aide pour chaque outil.
"""
tab = self._extras
blocs = []
for cle, titre, lignes in INSTALL_HELP:
if cle and not tab[cle]:
continue
blocs.append((t(titre), [t(x) for x in lignes]))
for key, label, hint in tab["vm_tools"]:
gb = tab["tool_disk"].get(key, 0)
lignes = list(tab["tool_help"].get(key) or [hint])
lignes.append(f"+{gb} Go")
blocs.append((label, lignes))
return blocs
def action_help(self) -> None:
"""Ouvre l'aide. Esc ou q la ferme, et rien n'a bougé."""
from script.todo.deploy_form_plan import help_screen
self.push_screen(help_screen()(self.extras_help_blocks()))
# ------------------------------------------------------------------ #
# Lire les widgets
# ------------------------------------------------------------------ #

View file

@ -483,6 +483,14 @@ PreviewScreen { align: center middle; }
}
#prevtitle { height: 1; color: $accent; text-style: bold; }
#prevbody { height: 1fr; overflow-y: auto; }
HelpScreen { align: center middle; }
#helpbox {
width: 84%; height: 80%; padding: 1 2;
border: thick $accent; background: $surface;
}
#helptitle { height: 1; color: $accent; text-style: bold; }
#helpbody { height: 1fr; }
.helphead { color: $accent; text-style: bold; }
RenameScreen { align: center middle; }
#renbox {
width: 60; height: auto; padding: 1 2;

View file

@ -50,7 +50,7 @@ _ECRANS = {}
def _build_screens():
from textual.app import ComposeResult
from textual.containers import Horizontal, Vertical
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.screen import ModalScreen
from textual.widgets import Button, Input, Static
@ -109,7 +109,49 @@ def _build_screens():
def action_close(self) -> None:
self.dismiss()
return {"rename": RenameScreen, "preview": PreviewScreen}
class HelpScreen(ModalScreen):
"""Ce que chaque option d'installation pose VRAIMENT dans la VM.
`blocks` : [(titre, [lignes])], dans l'ordre du formulaire. Une case
cochée engage parfois huit poses, et son libellé n'en nomme que trois :
cet écran est le seul endroit où la liste complète est lisible AVANT
de déployer.
La fenêtre ne fait que LIRE : elle se ferme sans rien changer, ce qui
permet de l'ouvrir en plein remplissage du formulaire.
"""
BINDINGS = [
("escape", "close", t("Close")),
("q", "close", t("Close")),
]
def __init__(self, blocks, title=""):
super().__init__()
self._blocks = blocks
self._title = title or t("What each option installs")
def compose(self) -> ComposeResult:
with Vertical(id="helpbox"):
yield Static(
f" {self._title} ({t('Esc to close')})",
id="helptitle",
)
with VerticalScroll(id="helpbody"):
for titre, lignes in self._blocks:
yield Static(f"▸ {titre}", classes="helphead")
for ligne in lignes:
yield Static(f" {ligne}")
yield Static("")
def action_close(self) -> None:
self.dismiss()
return {
"rename": RenameScreen,
"preview": PreviewScreen,
"help": HelpScreen,
}
def rename_screen():
@ -126,6 +168,13 @@ def preview_screen():
return _ECRANS["preview"]
def help_screen():
"""La fenêtre d'aide, construite au premier appel."""
if not _ECRANS:
_ECRANS.update(_build_screens())
return _ECRANS["help"]
class PlanMixin:
"""Les gestes du plan, communs à tous les formulaires de déploiement."""

View file

@ -21,12 +21,20 @@ RTK_UPSTREAM = (
# 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"
STARSHIP_URL = "https://starship.rs/install.sh"
# « -f » n'est pas un ornement quand la sortie part dans un shell : sans lui,
# curl écrit le CORPS d'une réponse d'erreur et sort avec 0, si bien qu'une
# page HTML de 34 ko arrive sur l'entrée de « sh » et s'y interprète comme
# un script. Le diagnostic obtenu est alors « Syntax error » sur une ligne du
# HTML, qui ne dit ni que l'URL est fautive ni que rien n'a été installé.
# Avec « -f », curl n'écrit rien et rend 22. « -L » suit une redirection,
# qu'un projet amont pose sans prévenir.
STARSHIP_UPSTREAM = f"curl -fsSL {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"
STARSHIP_UPSTREAM_YES = f"curl -fsSL {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

View file

@ -119,6 +119,7 @@ def run_proxmox_form(ctx, run_app: bool = True):
sur les invites textuelles. `run_app=False` rend l'instance sans la lancer
(tests headless)."""
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
@ -178,6 +179,12 @@ def run_proxmox_form(ctx, run_app: bool = True):
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("f1", "help", t("Help")),
# « ? » aussi, parce que c'est la touche qu'on essaie d'abord.
# Cachée du pied de page : la même action deux fois s'y lirait
# comme deux aides. Un champ de saisie qui a le focus l'avale, et
# c'est pourquoi F1 existe à côté.
Binding("?", "help", t("Help"), show=False),
("escape", "cancel", t("Cancel")),
]
# Le socle porte la mise en page et les modales ; ne reste ici que ce

View file

@ -72,6 +72,12 @@ class QemuDeployMixin:
if deferred
else ""
)
# « aidev » n'est pas dans « deferred » : ses installations, elles,
# ont bien lieu. C'est sa MOITIÉ de pré-configuration qui reste
# dehors, les hooks et les gabarits vivant dans le dépôt.
if "aidev" in (tools or ()):
saute = t("no checkout: git hooks and Claude commands skipped")
note += f'echo " ⚠ {saute}"; '
return (
"set -e; "
+ self._qemu_cloud_init_wait()
@ -96,13 +102,13 @@ class QemuDeployMixin:
svc = self._qemu_odoo_service_cmd(prod).strip().rstrip(";")
final_cmd = f"{final_cmd} && {{ {svc}; }}"
# VM de DÉVELOPPEMENT uniquement : couper les mises à jour automatiques.
# Vécu sur erplibre-ubuntu-2404 : unattended-upgrades s'est déclenché en
# pleine migration Odoo 12->13 et a redémarré le cluster PostgreSQL
# (« received fast shutdown request » x3) -> OpenUpgrade a perdu sa
# connexion et la base intermédiaire est restée à moitié migrée. Effet
# secondaire bienvenu : les timers apt-daily ne tiennent plus le verrou
# apt pendant l'installation. En PROD on ne touche à rien : les
# correctifs de sécurité automatiques doivent rester actifs.
# unattended-upgrades REDÉMARRE le cluster PostgreSQL sous lui-même —
# « received fast shutdown request » — et une migration Odoo en cours y
# perd sa connexion : OpenUpgrade s'arrête et la base intermédiaire
# reste à moitié migrée. Effet secondaire bienvenu : les timers
# apt-daily ne tiennent plus le verrou 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", ai_agent
@ -114,8 +120,7 @@ class QemuDeployMixin:
after_cmd = self._qemu_tools_remote_cmd(tools, prod, "after")
# APRÈS le make, et c'est mesuré : sur un dépôt cloné mais pas installé,
# PyCharm n'écrit AUCUN .idea — son configurateur d'interpréteur Python
# échoue faute de venv, et il renonce. « ⚠ pas de .idea », deux fois de
# suite sur erplibre-ubuntu-2604-gnome. Le même appel sur un dépôt
# échoue faute de venv, et il renonce. Le même appel sur un dépôt
# installé l'écrit en cinq minutes : erplibre.iml, misc.xml,
# modules.xml, vcs.xml.
#
@ -964,6 +969,52 @@ class QemuDeployMixin:
)
print(f" {t('~/.ssh/config:')} {cfg}")
print(f" {t('Parallelism:')} {spec['parallelism']} {t('at a time')}")
# DERNIÈRE ligne de la page, parce que l'invite de sudo tombe juste
# après : elle n'explique rien d'elle-même, et un mot de passe tapé
# sans savoir ce qu'il autorise est donné à l'aveugle.
for rang, ligne in enumerate(self._qemu_sudo_lines()):
print(f" {ligne}" if rang == 0 else f" {ligne}")
def _qemu_sudo_lines(self):
"""Pourquoi le déploiement va demander le mot de passe. Vide s'il ne
le demandera pas.
Les FAITS viennent de deploy_qemu, seule autorité sur ce que le
déploiement écrit et où ; leur mise en phrase revient au menu, qui
parle deux langues. Root ne verra aucune invite : ne rien annoncer
vaut mieux qu'annoncer une question qui ne viendra pas.
"""
if os.geteuid() == 0:
return []
try:
faits = self._qemu_import_module().sudo_facts()
except Exception:
return []
ecritures = [valeurs for cle, valeurs in faits if cle == "ecriture"]
lignes = [t("sudo password: asked when the deployment starts")]
for chemin, proprio, mode in ecritures:
lignes.append(
t("write into %s — checked: %s %s, writing refused here")
% (chemin, proprio, mode)
)
lignes.append(
t(
"the libvirt group opens the qemu:///system socket, not this"
" directory"
)
if ecritures
else t("the system steps of the script (service, group)")
)
if any(
cle == "socket" and valeurs[0] == "non" for cle, valeurs in faits
):
lignes.append(
t(
"the libvirt socket does not answer without sudo either:"
" group absent from this session, or libvirt not started"
)
)
return lignes
def _qemu_build_deploy_parts(
self,
@ -1179,9 +1230,9 @@ class QemuDeployMixin:
Le suivi d'installation tourne DÉTACHÉ, sans tty : il ne peut pas
répondre à une demande de mot de passe. « sudo -n » y échoue sur tout
hôte exigeant une authentification interactive (vécu sur erplibre01 avec
sudo-rs), et la VM devient alors introuvable dès que son bail DHCP
change. Le groupe libvirt est la seule voie qui n'exige ni root ni tty.
hôte exigeant une authentification interactive, et la VM devient alors
introuvable dès que son bail DHCP change. Le groupe libvirt est la
seule voie qui n'exige ni root ni tty.
Vérifié AVANT de créer quoi que ce soit : découvrir le problème après
vingt minutes d'installation coûte bien plus cher qu'une question ici.

View file

@ -85,6 +85,7 @@ def run_deploy_form(ctx, run_app: bool = True):
"""Formulaire de déploiement. Renvoie une spec, ou None si annulé.
`run_app=False` renvoie l'instance sans la lancer (tests headless)."""
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Horizontal, Vertical, VerticalScroll
from textual.widgets import (
Button,
@ -173,6 +174,12 @@ def run_deploy_form(ctx, run_app: bool = True):
("f6", "select_all", t("All")),
("f7", "select_main", t("Main versions")),
("f8", "select_none", t("None")),
("f1", "help", t("Help")),
# « ? » aussi, parce que c'est la touche qu'on essaie d'abord.
# Cachée du pied de page : la même action deux fois s'y lirait
# comme deux aides. Un champ de saisie qui a le focus l'avale, et
# c'est pourquoi F1 existe à côté.
Binding("?", "help", t("Help"), show=False),
("escape", "cancel", t("Cancel")),
]

View file

@ -761,6 +761,10 @@ class QemuInstallMixin:
_QEMU_VM_TOOLS = {
"pycharm": {
"label": "PyCharm",
"help": (
"JetBrains archive in /opt, launcher /usr/local/bin/pycharm",
"the checkout as project, .idea written like pycharm_configure",
),
"hint": "Python IDE, opens the ERPLibre checkout",
"disk_gb": 5,
"arches": ("amd64", "arm64"),
@ -771,6 +775,10 @@ class QemuInstallMixin:
},
"android": {
"label": "Android Studio",
"help": (
"Android Studio in /opt, desktop launcher and studio command",
"x86_64 only: Google publishes no other build",
),
"hint": "ERPLibre mobile development (x86_64 only)",
"disk_gb": 8,
"arches": ("amd64",),
@ -781,6 +789,11 @@ class QemuInstallMixin:
},
"gnome_ext": {
"label": "GNOME extensions",
"help": (
"the extension manager, plus the suggested extensions",
"distribution packages installed without being enabled",
"extensions named by UUID are enabled",
),
"hint": "suggested extensions + extension manager",
"disk_gb": 1,
"arches": (),
@ -802,6 +815,10 @@ class QemuInstallMixin:
# et sentencepiece clonés, node_modules, et les artefacts Gradle.
"mobile": {
"label": "ERPLibre mobile (build)",
"help": (
"Android SDK, then the debug APK and the Vitest run",
"a failed build marks the VM as failed",
),
"hint": "APK debug + Vitest, validates the VM",
"disk_gb": 12,
"arches": ("amd64",),
@ -827,6 +844,11 @@ class QemuInstallMixin:
# et les dépôts que l'utilisateur y poussera.
"forgejo": {
"label": "Forgejo (git forge)",
"help": (
"git forge on port 3000, SQLite database",
"system account, systemd service, admin account",
"posed by script/forgejo/install_forgejo.sh",
),
"hint": "self-hosted git forge on :3000, SQLite",
"disk_gb": 2,
"arches": ("amd64", "arm64"),
@ -838,23 +860,32 @@ class QemuInstallMixin:
# ni d'Odoo.
"phase": "after",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
# bloc vérifie et annonce plutôt que de laisser découvrir.
# Ni bureau ni famille de paquets : l'essentiel vient d'installateurs
# amont, qu'aucun dépôt de distribution ne porte, et les trois outils
# de terminal (tig, htop, vim) existent sous le même nom dans les
# quatre. Une VM serveur le prend donc aussi bien qu'une VM graphique
# — c'est en SSH qu'on s'en sert.
#
# 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.
# L'outil travaille en DEUX temps : les installations avant le clone,
# puis ce qui a besoin du dépôt — hooks git et commandes Claude. La
# phase déclarée ici est la première ; la seconde est un complément
# gardé, ajouté par _qemu_after_remote_cmd.
#
# 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",
"help": (
"tig, htop and vim",
"rtk, plus its global auto-rewrite hook",
"starship, hooked into ~/.bashrc",
"the chosen agent: Claude Code or opencode",
"git: merge.conflictStyle zdiff3, core.editor vim if unset",
"git hooks of the checkout: commit-msg, pre-commit",
"the Claude commands: /commit, /git_prepare_merge, /todo_*",
"source .venv.erplibre/bin/activate in the shell history",
),
"hint": "rtk, starship, one agent, git and Claude ready",
"disk_gb": 2,
"arches": (),
"desktops": (),
@ -865,8 +896,20 @@ class QemuInstallMixin:
# ne répond pas.
"phase": "before",
},
# L'émulateur n'a pas besoin de bureau DANS la VM : il s'affiche sur
# l'écran de qui s'y connecte, par « ssh -X ». Il a besoin, lui, de KVM
# dans la VM — donc de virtualisation imbriquée sur l'hôte, ce que le
# bloc vérifie et annonce plutôt que de laisser découvrir.
#
# Disque : ~1,5 Go d'image système, ~2 Go de données d'AVD, plus
# l'émulateur lui-même.
"avd": {
"label": "Android emulator (Pixel)",
"help": (
"Android SDK, then a Pixel AVD named erplibre",
"software rendering: it opens over ssh -X",
"needs nested virtualization on the host",
),
"hint": "AVD viewable over ssh -X",
"disk_gb": 6,
"arches": ("amd64",),
@ -913,6 +956,13 @@ class QemuInstallMixin:
"mise_arches": self.QEMU_MISE_ARCHES,
"vm_tools": self._qemu_vm_tool_choices(),
"vm_tool_disk": {k: v["disk_gb"] for k, v in outils.items()},
# Ce que l'aide « ? » du formulaire affiche, outil par outil :
# une case cochée engage parfois huit poses, et son libellé n'en
# nomme que trois.
"vm_tool_help": {
k: tuple(t(x) for x in v.get("help", ()))
for k, v in outils.items()
},
"vm_tool_arches": {k: v["arches"] for k, v in outils.items()},
"vm_tool_desktops": {k: v["desktops"] for k, v in outils.items()},
# « after » = l'outil vit DANS le dépôt ERPLibre (compilation
@ -1189,24 +1239,45 @@ class QemuInstallMixin:
# réclame que le .iml et misc.xml.
_QEMU_PYCHARM_OPEN_TRIES = 60
def _qemu_xvfb_install_cmd(self):
"""Pose Xvfb avec le gestionnaire de paquets présent, sans bruit."""
x = self._QEMU_XVFB_PKG
def _qemu_pkg_install_cmd(self, paquets, quiet=True):
"""Pose des paquets avec le gestionnaire de paquets présent dans la VM.
`paquets` : une chaîne (ou une suite) quand le nom vaut pour les quatre
familles, ou {apt, dnf, zypper, pacman} quand il diverge de l'une à
l'autre. `quiet` renvoie la sortie au néant, pour ce qui n'a rien à
raconter.
Chaque branche rend 0, y compris celle qui échoue : les appelants
posent du CONFORT — un serveur X sans écran, des outils de terminal —
et aucun ne doit emporter l'installation d'ERPLibre. Le bloc rend 0
aussi sur une machine qui n'a aucun des quatre gestionnaires, ce qui
laisse « set -e » tranquille.
"""
if isinstance(paquets, dict):
noms = paquets
else:
liste = paquets if isinstance(paquets, str) else " ".join(paquets)
noms = dict.fromkeys(("apt", "dnf", "zypper", "pacman"), liste)
muet = " >/dev/null 2>&1" if quiet else ""
return (
"if command -v apt-get >/dev/null 2>&1; then "
"sudo DEBIAN_FRONTEND=noninteractive apt-get "
f"-o DPkg::Lock::Timeout=600 install -y {x['apt']} "
">/dev/null 2>&1 || true; "
f"-o DPkg::Lock::Timeout=600 install -y {noms['apt']}"
f"{muet} || true; "
"elif command -v dnf >/dev/null 2>&1; then "
f"sudo dnf install -y {x['dnf']} >/dev/null 2>&1 || true; "
f"sudo dnf install -y {noms['dnf']}{muet} || true; "
"elif command -v zypper >/dev/null 2>&1; then "
"sudo zypper --non-interactive install --auto-agree-with-licenses "
f"{x['zypper']} >/dev/null 2>&1 || true; "
f"{noms['zypper']}{muet} || true; "
"elif command -v pacman >/dev/null 2>&1; then "
f"sudo pacman -S --needed --noconfirm {x['pacman']} "
">/dev/null 2>&1 || true; fi; "
f"sudo pacman -S --needed --noconfirm {noms['pacman']}"
f"{muet} || true; fi; "
)
def _qemu_xvfb_install_cmd(self):
"""Pose Xvfb avec le gestionnaire de paquets présent, sans bruit."""
return self._qemu_pkg_install_cmd(self._QEMU_XVFB_PKG)
def _qemu_pycharm_project_cmd(self, prod=False):
"""Crée le .idea/ du dépôt en ouvrant PyCharm une fois, sans écran.
@ -2006,14 +2077,19 @@ class QemuInstallMixin:
émulateur créé avec succès effacerait le verdict de la compilation."""
picked = [
k
for k in ("forgejo", "mobile", "avd")
for k in ("aidev", "forgejo", "mobile", "avd")
if k in (tools or ()) and k in self._QEMU_VM_TOOLS
]
if not picked:
return ""
el_dir = self._qemu_install_dir(prod)
parts = []
# Forgejo d'abord : une minute, contre une heure pour le SDK et l'APK.
# La pré-configuration IA en tête : quelques secondes de copies, contre
# une minute pour Forgejo et une heure pour le SDK. Elle est aussi la
# seule à rendre toujours 0 — ce qui suit porte le verdict de la VM.
if "aidev" in picked:
parts.append(f"{{ {self._qemu_aidev_after_cmd(prod)}; }}")
# Forgejo ensuite : une minute, contre une heure pour le SDK et l'APK.
# Un échec rapide se voit tôt plutôt qu'après le long.
if "forgejo" in picked:
parts.append(f"{{ {self._qemu_forgejo_steps(el_dir)}; }}")
@ -2048,8 +2124,37 @@ class QemuInstallMixin:
"""Émulateur seul."""
return self._qemu_after_remote_cmd(("avd",), prod)
# Les outils de terminal qui accompagnent les assistants. Ceux-là SONT
# empaquetés, sous le même nom dans les quatre familles : rien à résoudre.
_QEMU_AIDEV_PKGS = "tig htop vim"
# La commande qu'une VM neuve rend à la première flèche du haut. Le chemin
# est relatif : le venv est à la racine du dépôt, et c'est de là qu'on
# l'active, en dev comme en production.
_QEMU_AIDEV_HISTORY = "source .venv.erplibre/bin/activate"
# Les commandes Claude déployées dans la VM : nom de la commande -> gabarit
# de conf/. La même liste qu'au menu de l'hôte. Les deux « todo_ » vont
# ensemble — /todo_plan_max produit la spécification que /todo_add_command
# implémente, et l'une sans l'autre laisse la moitié de la chaîne.
_QEMU_AIDEV_CLAUDE_CMDS = (
("commit", "template_claude_commands_commit.md"),
("git_prepare_merge", "template_claude_commands_git_prepare_merge.md"),
("todo_plan_max", "template_claude_commands_todo_plan_max.md"),
("todo_add_command", "template_claude_commands_todo_add_command.md"),
(
"todo_generate_code",
"template_claude_commands_todo_generate_code.md",
),
)
def _qemu_aidev_remote_cmd(self, agent=""):
"""rtk, starship et UN agent, posés dans la VM.
"""Les outils d'assistance et leur pré-configuration, dans la VM.
Trois installateurs amont — rtk, starship, UN agent —, trois paquets de
terminal, et les réglages qui font qu'on s'en sert sans rien retaper :
le hook global de rtk, l'accroche du prompt, le PATH, la configuration
git, et une entrée d'historique pour activer le venv.
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
@ -2059,13 +2164,16 @@ class QemuInstallMixin:
« </dev/null » la lui fait rater tout de suite, « timeout » borne le
reste. C'est aussi pourquoi starship reçoit « -y ».
L'accroche du prompt et la ligne de PATH sont posées UNE fois :
sans le « grep » qui précède, chaque redéploiement d'une même VM
rallonge son ~/.bashrc d'une ligne identique.
Les lignes ajoutées à un fichier du HOME le sont UNE fois : sans le
« grep » qui précède, chaque redéploiement d'une même VM rallonge son
~/.bashrc — ou son historique — d'une ligne identique.
Le répertoire est écrit en « $HOME » et non en « ~ » : entre
guillemets, le tilde n'est pas étendu par le shell, et le PATH
porterait alors un chemin qui n'existe pas.
Ce que le dépôt seul peut donner — hooks git, commandes Claude — n'est
pas ici : voir _qemu_aidev_after_cmd.
"""
commande, repertoire = dev_tools.AGENTS.get(
agent or dev_tools.AGENT_DEFAUT,
@ -2074,6 +2182,13 @@ class QemuInstallMixin:
repertoire = repertoire.replace("~/", "$HOME/", 1)
prompt = dev_tools.STARSHIP_LINE["bash"]
path_line = f'export PATH="{repertoire}:$PATH"'
# rtk se pose dans ~/.local/bin, que le PATH d'un shell ne porte pas
# partout. La ligne de l'agent ne couvre ce répertoire que pour Claude
# Code ; avec opencode, qui s'installe ailleurs, rtk resterait
# introuvable. Les deux lignes se dédoublonnent d'elles-mêmes, le grep
# portant sur le répertoire.
local_bin = "$HOME/.local/bin"
local_line = f'export PATH="{local_bin}:$PATH"'
def pose(cmd, secondes):
return (
@ -2081,19 +2196,142 @@ class QemuInstallMixin:
" </dev/null || true; "
)
def une_fois(ligne, motif):
def une_fois(ligne, motif, fichier="~/.bashrc"):
return (
f"grep -qF {shlex.quote(motif)} ~/.bashrc 2>/dev/null"
f" || echo {shlex.quote(ligne)} >> ~/.bashrc; "
f"grep -qF {shlex.quote(motif)} {fichier} 2>/dev/null"
f" || echo {shlex.quote(ligne)} >> {fichier}; "
)
return (
f'echo "== {t("AI coding tools")} =="; '
+ self._qemu_pkg_install_cmd(self._QEMU_AIDEV_PKGS)
+ pose(dev_tools.RTK_UPSTREAM, 300)
# Par son chemin absolu, et non par « rtk » nu : le PATH de cette
# commande distante a été figé au démarrage du shell SSH, avant que
# l'installateur ne pose le binaire. Le code 127 qu'on obtiendrait
# sinon ne dirait pas que le hook n'a pas été écrit.
+ f'RTK="$(command -v rtk || echo "{local_bin}/rtk")"; '
+ '[ -x "$RTK" ] && timeout 60 "$RTK" init --global'
" </dev/null >/dev/null 2>&1 || true; "
+ pose(dev_tools.STARSHIP_UPSTREAM_YES, 300)
+ une_fois(prompt, "starship init bash")
+ pose(commande, 600)
+ une_fois(path_line, repertoire)
+ une_fois(local_line, local_bin)
# Claude Code s'installe DANS ~/.local/bin : la ligne serait la
# même, écrite deux fois dans le journal pour un seul effet.
+ (
une_fois(path_line, repertoire)
if repertoire != local_bin
else ""
)
+ self._qemu_aidev_git_cmd()
+ une_fois(
self._QEMU_AIDEV_HISTORY,
self._QEMU_AIDEV_HISTORY,
"~/.bash_history",
)
# bash crée son historique en 600 ; une redirection le crée selon
# l'umask, soit lisible par tous sur les images visées.
+ "chmod 600 ~/.bash_history 2>/dev/null || true; "
)
def _qemu_aidev_git_cmd(self):
"""Les deux réglages git globaux de la pré-configuration.
zdiff3 ajoute la base commune aux marqueurs de conflit et sort de la
zone contestée les lignes que les deux côtés ont en commun : il reste
moins à arbitrer à la main. Le style demande git 2.35, que toutes les
plateformes supportées dépassent.
L'éditeur, lui, n'est posé QUE s'il n'y en a pas : deploy_qemu.py
transmet celui de l'hôte dans le ~/.gitconfig de la VM et l'annonce
dans le guide de connexion, et l'écraser ici ferait deux autorités sur
un même réglage. vim est le repli, et son paquet vient d'être posé.
Le tout dans un « if » : la phase « before » d'une VM sans installation
ERPLibre n'a pas vu l'amorçage qui pose git, et « set -e » ferait
tomber le déploiement sur une commande introuvable.
"""
return (
"if command -v git >/dev/null 2>&1; then "
"git config --global merge.conflictStyle zdiff3 || true; "
"git config --global --get core.editor >/dev/null 2>&1 "
"|| git config --global core.editor vim || true; fi; "
)
def _qemu_aidev_after_cmd(self, prod=False):
"""La part de la pré-configuration qui a besoin du dépôt cloné.
Les hooks vivent dans le dépôt (`script/git/hooks`) et les gabarits des
commandes Claude dans `conf/` : avant le clone, ni l'un ni l'autre
n'existe. Le bit d'exécution fait partie de l'installation des hooks —
sans lui git les ignore SANS RIEN DIRE, et le garde-fou du message de
commit passe inaperçu.
Rendu SANS point-virgule final, comme les autres étapes de la phase
« après » : l'appelant enveloppe le bloc dans des accolades, et un
« ; ; » y est une erreur de syntaxe qui emporte tout le groupe.
Le bloc rend toujours 0. Une pré-configuration est un confort : c'est
l'installation qui porte le verdict de la VM, pas elle.
"""
el_dir = self._qemu_install_dir(prod)
hooks = self._GIT_HOOKS_PATH
cibles = " ".join(f"{el_dir}/{hooks}/{h}" for h in self._GIT_HOOKS)
etapes = [
f'echo "== {t("AI coding tools, pre-configuration")} =="',
# « git -C » et non le cwd : le dépôt porte des dépôts imbriqués
# (odoo18.0/addons/…), et core.hooksPath écrit dans l'un d'eux
# laisserait la racine sans garde-fou, sans le moindre message.
f"git -C {el_dir} config core.hooksPath {hooks} || true",
f"chmod +x {cibles} 2>/dev/null || true",
"mkdir -p ~/.claude/commands || true",
]
etapes += [
f"cp -f {el_dir}/conf/{gabarit}"
f" ~/.claude/commands/{nom}.md 2>/dev/null || true"
for nom, gabarit in self._QEMU_AIDEV_CLAUDE_CMDS
]
etapes.append(self._qemu_aidev_identity_cmd())
etapes.append(
'echo " ~/.claude/commands: '
"$(ls ~/.claude/commands/*.md 2>/dev/null | wc -l)"
f'/{len(self._QEMU_AIDEV_CLAUDE_CMDS)}"'
)
return "; ".join(etapes)
# Ce que le gabarit de /commit porte en exemple, et que l'identité de la
# VM remplace : une ligne « git -c user.name=… » qu'on recopie.
_QEMU_AIDEV_PLACEHOLDERS = (
("Your Name", "user.name"),
("your@email.com", "user.email"),
)
def _qemu_aidev_identity_cmd(self):
"""Substitue l'identité git de la VM dans la commande /commit.
En python3 et non en sed : le remplacement se fait alors sur du texte
littéral, là où un nom qui porterait « & » ou le séparateur choisi
changerait de sens dans un « s/// ». python3 est présent dans toutes
les images visées — cloud-init, qui les amorce, en dépend lui-même.
Une identité absente laisse le gabarit tel quel : l'exemple reste
lisible, et rien ne prétend une identité qu'on n'a pas.
"""
remplacements = "".join(
f'.replace("{marque}", g("{cle}") or "{marque}")'
for marque, cle in self._QEMU_AIDEV_PLACEHOLDERS
)
code = (
"import pathlib,subprocess as s;"
'g=lambda k:s.run(["git","config","--global","--get",k],'
"capture_output=True,text=True).stdout.strip();"
'p=pathlib.Path("~/.claude/commands/commit.md").expanduser();'
f"p.write_text(p.read_text(){remplacements})"
)
return (
"command -v python3 >/dev/null 2>&1 && "
f"python3 -c {shlex.quote(code)} 2>/dev/null || true"
)
def _qemu_tools_remote_cmd(
@ -2108,7 +2346,9 @@ class QemuInstallMixin:
elle dépend, et qui elle NE se garde PAS. C'est le contrat demandé : une
VM dont l'application ne compile pas doit être rouge."""
if phase == "after":
# Un seul bloc pour les deux options : voir _qemu_after_remote_cmd.
# Un seul bloc pour toutes les options : voir
# _qemu_after_remote_cmd. « aidev » y entre bien qu'il soit déclaré
# « before » — ses installations le sont, son complément non.
return self._qemu_after_remote_cmd(tools, prod)
blocks = {
# En tête : quelques secondes de curl, contre des minutes pour un

View file

@ -326,8 +326,9 @@ class QemuMenuMixin:
)
},
# 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.
# à la ligne près. Quatre intentions les séparent — vivre avec ses
# VM, y entrer, régler le réseau qui les porte, 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")},
@ -350,6 +351,13 @@ class QemuMenuMixin:
"Android emulator (start, tunnel, scrcpy)"
)
},
{"section": t("VM network")},
{"prompt_description": t("Show the libvirt network state")},
{
"prompt_description": t(
"Recreate the VM subnet (stop, redefine, restart)"
)
},
{"section": t("Troubleshoot")},
{"prompt_description": t("Clean up QEMU (orphan files)")},
{
@ -402,16 +410,20 @@ class QemuMenuMixin:
elif status == "12":
self._qemu_emulator_menu()
elif status == "13":
self._qemu_cleanup()
self._qemu_network_status()
elif status == "14":
self._qemu_recover_files()
self._qemu_network_recreate()
elif status == "15":
self._qemu_test_vm()
self._qemu_cleanup()
elif status == "16":
self._qemu_diagnostics()
self._qemu_recover_files()
elif status == "17":
self._qemu_stats()
self._qemu_test_vm()
elif status == "18":
self._qemu_diagnostics()
elif status == "19":
self._qemu_stats()
elif status == "20":
self._qemu_list_images()
else:
cmd_no_found = True

View file

@ -0,0 +1,89 @@
#!/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 : le sous-réseau où vivent les VM.
Deux entrées pour une seule question — quel /24 le réseau libvirt sert-il, et
que faire quand ce n'est plus celui que les VM attendent. Voir l'état ne
modifie rien ; le recréer arrête les VM attachées, redéfinit le réseau, puis
redémarre ces VM.
Le travail est fait par script/qemu/network_qemu.py, lancé comme un programme
et non importé : c'est lui qui porte l'ordre des trois gestes, et il reste
utilisable seul sur un hôte où le menu ne tourne pas — celui, justement, qui
vient de perdre son réseau.
Mixin de la classe TODO : ses méthodes vivent sur la même instance que celles
des autres fichiers, elles s'appellent donc par « self. » sans rien importer.
"""
import os
from script.todo.qemu_privilege import needs_sudo
from script.todo.todo_i18n import t
# Le préfixe d'origine du « default » de libvirt, proposé par défaut. Il fait
# autorité dans network_qemu.py ; répété ici pour que l'invite ait une valeur à
# afficher sans charger le script.
PREFIXE_LIBVIRT = "192.168.122"
class QemuNetworkMixin:
"""Menu QEMU/KVM : le sous-réseau où vivent les VM."""
def _qemu_network_script_path(self):
"""Chemin absolu vers script/qemu/network_qemu.py."""
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)),
"..",
"qemu",
"network_qemu.py",
)
return os.path.realpath(path)
def _qemu_network_cmd(self, *args):
"""La commande du script, sudo laissé à qui en a besoin.
« --no-sudo » quand le groupe libvirt suffit : préfixer sudo ne donne
alors aucun droit de plus et réclame un mot de passe pour rien.
"""
parts = [self._qemu_network_script_path(), *args]
if not needs_sudo():
parts.append("--no-sudo")
return " ".join(parts)
def _qemu_network_status(self):
"""Affiche le sous-réseau servi, les VM attachées et leurs baux."""
cmd = self._qemu_network_cmd("--status")
print(f"{t('Will execute:')} {cmd}")
self.execute.exec_command_live(cmd, source_erplibre=False)
def _qemu_network_recreate(self):
"""Recrée le sous-réseau : arrête les VM, redéfinit, redémarre.
L'état est montré AVANT la question : le préfixe à viser se lit sur
celui que le réseau sert aujourd'hui, et personne ne devrait avoir à
deviner ce qu'il va changer.
"""
self._qemu_network_status()
print(f"\n{t('Recreating the subnet does three things:')}")
print(f" 0. {t('shut down the VMs attached to the network')}")
print(f" 1. {t('redefine the network on the wanted prefix')}")
print(f" 2. {t('start those same VMs again')}")
prefixe = input(
f"\n{t('Target prefix')} [{PREFIXE_LIBVIRT}] : "
).strip()
prefixe = prefixe or PREFIXE_LIBVIRT
reseau = input(f"{t('libvirt network')} [default] : ").strip()
reseau = reseau or "default"
forcer = self._is_yes(
input(t("Power off VMs that ignore the shutdown? (y/N): "))
)
args = ["--recreate", "--network", reseau, "--prefix", prefixe]
if forcer:
args.append("--force-off")
cmd = self._qemu_network_cmd(*args)
print(f"\n{t('Will execute:')} {cmd}")
# La confirmation est posée par le script lui-même : il la pose sur
# /dev/tty, donc elle fonctionne aussi quand il est lancé hors du menu.
self.execute.exec_command_live(cmd, source_erplibre=False)

View file

@ -36,6 +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.qemu_network import QemuNetworkMixin
from script.todo.qemu_recover import QemuRecoverMixin
from script.todo.vpn_menu import VpnMenuMixin
from script.todo.kdbx_manager import KdbxManager
@ -98,6 +99,7 @@ class TODO(
QemuDeployMixin,
QemuInstallMixin,
QemuManageMixin,
QemuNetworkMixin,
QemuRecoverMixin,
QemuAccessMixin,
ProxmoxMenuMixin,

View file

@ -336,28 +336,28 @@ TRANSLATIONS = {
"en": "Manage RTK (Rust Token Killer) for token optimization!",
},
"Install RTK": {
"fr": "Installer RTK",
"en": "Install RTK",
"fr": "📦 Installer RTK",
"en": "📦 Install RTK",
},
"Check RTK version": {
"fr": "Vérifier la version de RTK",
"en": "Check RTK version",
"fr": "🔢 Vérifier la version de RTK",
"en": "🔢 Check RTK version",
},
"Show cumulative token savings": {
"fr": "Afficher les économies de tokens cumulées",
"en": "Show cumulative token savings",
"fr": "📊 Afficher les économies de tokens cumulées",
"en": "📊 Show cumulative token savings",
},
"Discover optimization opportunities": {
"fr": "Identifier les opportunités d'optimisation",
"en": "Discover optimization opportunities",
"fr": "🔍 Identifier les opportunités d'optimisation",
"en": "🔍 Discover optimization opportunities",
},
"Initialize global auto-rewrite hook": {
"fr": "Initialiser le hook auto-rewrite global",
"en": "Initialize global auto-rewrite hook",
"fr": "🪝 Initialiser le hook auto-rewrite global",
"en": "🪝 Initialize global auto-rewrite hook",
},
"Check RTK status": {
"fr": "Vérifier le statut de RTK",
"en": "Check RTK status",
"fr": "🔬 Vérifier le statut de RTK",
"en": "🔬 Check RTK status",
},
"RTK is not installed. Use option 1 to install it.": {
"fr": "RTK n'est pas installé. Utilisez l'option 1 pour l'installer.",
@ -5015,9 +5015,239 @@ TRANSLATIONS = {
"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",
# Pourquoi le déploiement demande un mot de passe, dit AVANT l'invite.
"sudo password: asked when the deployment starts": {
"fr": "Mot de passe sudo : demandé au lancement du déploiement, pour",
"en": "sudo password: asked when the deployment starts, for",
},
"write into %s — checked: %s %s, writing refused here": {
"fr": "écrire dans %s — vérifié : %s %s, écriture refusée ici",
"en": "write into %s — checked: %s %s, writing refused here",
},
"the libvirt group opens the qemu:///system socket, not this directory": {
"fr": (
"le groupe libvirt ouvre la socket qemu:///system, pas ce"
" répertoire"
),
"en": (
"the libvirt group opens the qemu:///system socket, not this"
" directory"
),
},
"the system steps of the script (service, group)": {
"fr": "les gestes système du script (service, groupe)",
"en": "the system steps of the script (service, group)",
},
"the libvirt socket does not answer without sudo either: group absent"
" from this session, or libvirt not started": {
"fr": (
"la socket libvirt ne répond pas non plus sans sudo : groupe"
" absent de cette session, ou libvirt pas démarré"
),
"en": (
"the libvirt socket does not answer without sudo either: group"
" absent from this session, or libvirt not started"
),
},
"Python IDE, opens the ERPLibre checkout": {
"fr": "IDE Python, ouvre le dépôt ERPLibre",
"en": "Python IDE, opens the ERPLibre checkout",
},
"rtk, starship, one agent, git and Claude ready": {
"fr": "rtk, starship, un agent, git et Claude prêts",
"en": "rtk, starship, one agent, git and Claude ready",
},
"Help": {
"fr": "Aide",
"en": "Help",
},
"What each option installs": {
"fr": "Ce que chaque option installe",
"en": "What each option installs",
},
"(F1 or ? : details)": {
"fr": "(F1 ou ? : le détail)",
"en": "(F1 or ? : details)",
},
"VM type": {
"fr": "Type de VM",
"en": "VM type",
},
"Application store": {
"fr": "Magasin d'applications",
"en": "Application store",
},
"Python interpreter": {
"fr": "Interpréteur Python",
"en": "Python interpreter",
},
"clone of the checkout, then the make target of the profile": {
"fr": "clone du dépôt, puis la cible make du profil",
"en": "clone of the checkout, then the make target of the profile",
},
"in ~/git/erplibre, or /opt/erplibre in production": {
"fr": "dans ~/git/erplibre, ou /opt/erplibre en production",
"en": "in ~/git/erplibre, or /opt/erplibre in production",
},
"unchecked: the VM stays a bare distribution": {
"fr": "décochée : la VM reste une distribution nue",
"en": "unchecked: the VM stays a bare distribution",
},
"about +5 Go of disk": {
"fr": "environ +5 Go de disque",
"en": "about +5 Go of disk",
},
"/opt/erplibre instead of ~/git/erplibre": {
"fr": "/opt/erplibre au lieu de ~/git/erplibre",
"en": "/opt/erplibre instead of ~/git/erplibre",
},
"Odoo registered as a systemd service": {
"fr": "Odoo enregistré en service systemd",
"en": "Odoo registered as a systemd service",
},
"automatic updates left ON, unlike a dev VM": {
"fr": "mises à jour automatiques laissées ACTIVES, contrairement au dev",
"en": "automatic updates left ON, unlike a dev VM",
},
"server: SSH only, no graphical session": {
"fr": "serveur : SSH seul, sans session graphique",
"en": "server: SSH only, no graphical session",
},
"graphical: the chosen desktop plus its remote access": {
"fr": "graphique : le bureau choisi et son accès distant",
"en": "graphical: the chosen desktop plus its remote access",
},
"an IDE needs a desktop, the build tools do not": {
"fr": "un IDE demande un bureau, les outils de compilation non",
"en": "an IDE needs a desktop, the build tools do not",
},
"deb/rpm: packages of the distribution only": {
"fr": "deb/rpm : les paquets de la distribution seulement",
"en": "deb/rpm: packages of the distribution only",
},
"snap: snapd kept active where the distribution has it": {
"fr": "snap : snapd laissé actif là où la distribution l'a",
"en": "snap: snapd kept active where the distribution has it",
},
"IANA name written by cloud-init at first boot": {
"fr": "nom IANA écrit par cloud-init au premier démarrage",
"en": "IANA name written by cloud-init at first boot",
},
"a misspelled name is IGNORED: the VM stays in UTC": {
"fr": "un nom mal orthographié est IGNORÉ : la VM reste en UTC",
"en": "a misspelled name is IGNORED: the VM stays in UTC",
},
"mise poses a precompiled CPython, in minutes": {
"fr": "mise pose un CPython précompilé, en quelques minutes",
"en": "mise poses a precompiled CPython, in minutes",
},
"pyenv compiles it, which is much longer": {
"fr": "pyenv le compile, ce qui est bien plus long",
"en": "pyenv compiles it, which is much longer",
},
"mise publishes no binary for every architecture": {
"fr": "mise ne publie pas de binaire pour toutes les architectures",
"en": "mise publishes no binary for every architecture",
},
"JetBrains archive in /opt, launcher /usr/local/bin/pycharm": {
"fr": "archive JetBrains dans /opt, lanceur /usr/local/bin/pycharm",
"en": "JetBrains archive in /opt, launcher /usr/local/bin/pycharm",
},
"the checkout as project, .idea written like pycharm_configure": {
"fr": "le dépôt comme projet, .idea écrit comme pycharm_configure",
"en": "the checkout as project, .idea written like pycharm_configure",
},
"Android Studio in /opt, desktop launcher and studio command": {
"fr": "Android Studio dans /opt, lanceur de bureau et commande studio",
"en": "Android Studio in /opt, desktop launcher and studio command",
},
"x86_64 only: Google publishes no other build": {
"fr": "x86_64 seulement : Google n'en publie pas d'autre",
"en": "x86_64 only: Google publishes no other build",
},
"the extension manager, plus the suggested extensions": {
"fr": "le gestionnaire d'extensions, et les extensions suggérées",
"en": "the extension manager, plus the suggested extensions",
},
"distribution packages installed without being enabled": {
"fr": "les paquets de la distribution posés sans être activés",
"en": "distribution packages installed without being enabled",
},
"extensions named by UUID are enabled": {
"fr": "les extensions nommées par leur UUID sont activées",
"en": "extensions named by UUID are enabled",
},
"Android SDK, then the debug APK and the Vitest run": {
"fr": "le SDK Android, puis l'APK debug et la passe Vitest",
"en": "Android SDK, then the debug APK and the Vitest run",
},
"a failed build marks the VM as failed": {
"fr": "une compilation en échec fait échouer la VM",
"en": "a failed build marks the VM as failed",
},
"git forge on port 3000, SQLite database": {
"fr": "forge git sur le port 3000, base SQLite",
"en": "git forge on port 3000, SQLite database",
},
"system account, systemd service, admin account": {
"fr": "compte système, service systemd, compte administrateur",
"en": "system account, systemd service, admin account",
},
"posed by script/forgejo/install_forgejo.sh": {
"fr": "posé par script/forgejo/install_forgejo.sh",
"en": "posed by script/forgejo/install_forgejo.sh",
},
"tig, htop and vim": {
"fr": "tig, htop et vim",
"en": "tig, htop and vim",
},
"rtk, plus its global auto-rewrite hook": {
"fr": "rtk, et son hook global de réécriture",
"en": "rtk, plus its global auto-rewrite hook",
},
"starship, hooked into ~/.bashrc": {
"fr": "starship, accroché au ~/.bashrc",
"en": "starship, hooked into ~/.bashrc",
},
"the chosen agent: Claude Code or opencode": {
"fr": "l'agent choisi : Claude Code ou opencode",
"en": "the chosen agent: Claude Code or opencode",
},
"git: merge.conflictStyle zdiff3, core.editor vim if unset": {
"fr": "git : merge.conflictStyle zdiff3, core.editor vim s'il est vide",
"en": "git: merge.conflictStyle zdiff3, core.editor vim if unset",
},
"git hooks of the checkout: commit-msg, pre-commit": {
"fr": "les hooks git du dépôt : commit-msg, pre-commit",
"en": "git hooks of the checkout: commit-msg, pre-commit",
},
"the Claude commands: /commit, /git_prepare_merge, /todo_*": {
"fr": "les commandes Claude : /commit, /git_prepare_merge, /todo_*",
"en": "the Claude commands: /commit, /git_prepare_merge, /todo_*",
},
"source .venv.erplibre/bin/activate in the shell history": {
"fr": "source .venv.erplibre/bin/activate dans l'historique du shell",
"en": "source .venv.erplibre/bin/activate in the shell history",
},
"Android SDK, then a Pixel AVD named erplibre": {
"fr": "le SDK Android, puis un AVD Pixel nommé erplibre",
"en": "Android SDK, then a Pixel AVD named erplibre",
},
"software rendering: it opens over ssh -X": {
"fr": "rendu logiciel : il s'ouvre par ssh -X",
"en": "software rendering: it opens over ssh -X",
},
"needs nested virtualization on the host": {
"fr": "demande la virtualisation imbriquée sur l'hôte",
"en": "needs nested virtualization on the host",
},
"AI coding tools, pre-configuration": {
"fr": "Outils d'assistance IA, pré-configuration",
"en": "AI coding tools, pre-configuration",
},
"no checkout: git hooks and Claude commands skipped": {
"fr": "sans dépôt : hooks git et commandes Claude sautés",
"en": "no checkout: git hooks and Claude commands skipped",
},
"Name for git": {
"fr": "Nom pour git",
@ -5192,6 +5422,47 @@ TRANSLATIONS = {
"fr": "🔌 Accès aux VM",
"en": "🔌 VM access",
},
# Menu QEMU — section « Réseau »
"VM network": {
"fr": "🌐 Réseau",
"en": "🌐 Network",
},
"Show the libvirt network state": {
"fr": "🌐 Voir l'état du réseau libvirt (sous-réseau, VM, baux)",
"en": "🌐 Show the libvirt network state (subnet, VMs, leases)",
},
"Recreate the VM subnet (stop, redefine, restart)": {
"fr": "♻️ Recréer le sous-réseau des VM (arrêter, redéfinir, relancer)",
"en": "♻️ Recreate the VM subnet (stop, redefine, restart)",
},
"Recreating the subnet does three things:": {
"fr": "Recréer le sous-réseau fait trois choses :",
"en": "Recreating the subnet does three things:",
},
"shut down the VMs attached to the network": {
"fr": "arrêter les VM attachées au réseau",
"en": "shut down the VMs attached to the network",
},
"redefine the network on the wanted prefix": {
"fr": "redéfinir le réseau sur le préfixe voulu",
"en": "redefine the network on the wanted prefix",
},
"start those same VMs again": {
"fr": "redémarrer ces mêmes VM",
"en": "start those same VMs again",
},
"Target prefix": {
"fr": "Préfixe visé",
"en": "Target prefix",
},
"libvirt network": {
"fr": "Réseau libvirt",
"en": "libvirt network",
},
"Power off VMs that ignore the shutdown? (y/N): ": {
"fr": "Couper le courant des VM qui n'obéissent pas ? (o/N) : ",
"en": "Power off VMs that ignore the shutdown? (y/N): ",
},
"Troubleshoot": {
"fr": "🩺 Dépanner",
"en": "🩺 Troubleshoot",

View file

@ -0,0 +1,230 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""L'aide « ? » du formulaire de déploiement.
Une case cochée engage parfois huit poses, et son libellé n'en nomme que
trois : « Outils d'assistance IA » installe aussi les hooks git du dépôt, les
commandes Claude et une entrée d'historique. Ce que le formulaire fait ne
peut pas se lire seulement dans le code de la commande distante — on décide
devant l'écran, une heure avant que le journal ne le montre.
Ce que ces tests gardent :
- chaque outil du catalogue a une aide, et un outil ajouté sans texte fait
tomber le test plutôt que d'afficher un bloc vide ;
- l'aide TRAVERSE le contexte : c'est le seul chemin jusqu'aux deux écrans,
et un réglage qui n'y entre pas n'existe pas ;
- les DEUX formulaires l'ouvrent — c'est en n'en servant qu'un que l'écran
Proxmox avait perdu la moitié des réglages ;
- la fenêtre se ferme sur Esc sans rien changer, et sans fermer l'écran qui
la porte.
"""
import asyncio
import importlib.util
import sys
import unittest
from pathlib import Path
sys.argv = ["todo.py"]
from script.todo.deploy_form_extras import ( # noqa: E402
INSTALL_HELP,
ExtrasMixin,
extras_tables,
)
from script.todo.todo import TODO # noqa: E402
try:
import textual # noqa: F401
TEXTUAL = True
except Exception: # pragma: no cover - dépend de l'environnement
TEXTUAL = False
def contexte():
todo = TODO.__new__(TODO)
mod = todo._qemu_import_module()
todo._qemu_list_domains = lambda: []
todo._qemu_branch_list = lambda: ["develop", "master"]
return todo._qemu_form_context(mod)
def contexte_proxmox():
"""Le contexte synthétique de l'écran Proxmox, plus le système invité.
Chargé PAR SON CHEMIN, comme le fait test_qemu_ai_tools de deploy_qemu :
« test » est aussi un paquet de la bibliothèque standard, et un import par
son nom y mènerait. Le contexte lui-même n'est pas recopié — une seconde
copie dériverait de celle que garde test_proxmox_form.
"""
chemin = Path(__file__).resolve().parent / "test_proxmox_form.py"
spec = importlib.util.spec_from_file_location("tpf_ctx", chemin)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
invite = TODO.__new__(TODO)._qemu_guest_context()
return {**invite, **mod.contexte()}
class Ecran(ExtrasMixin):
"""Le mixin seul, sans Textual : les blocs sont du texte, pas des widgets."""
def __init__(self, ctx):
self._extras = extras_tables(ctx)
class LeCatalogue(unittest.TestCase):
def test_every_tool_says_what_it_installs(self):
"""Un outil sans aide afficherait un bloc vide, ce qui se lit comme
« il ne pose rien »."""
for key, spec in TODO._QEMU_VM_TOOLS.items():
with self.subTest(outil=key):
self.assertTrue(spec.get("help"), key)
for ligne in spec["help"]:
self.assertTrue(ligne.strip())
def test_the_ai_tool_names_all_its_halves(self):
"""La case engage des installations ET une pré-configuration : celle
qui n'est pas nommée est celle qu'on croit absente."""
aide = " ".join(TODO._QEMU_VM_TOOLS["aidev"]["help"])
for morceau in (
"tig",
"rtk",
"starship",
"zdiff3",
"hooks",
"commit",
".venv.erplibre/bin/activate",
):
self.assertIn(morceau, aide)
def test_the_help_crosses_the_context(self):
"""Le contexte est le seul chemin jusqu'aux deux écrans."""
ctx = contexte()
aides = ctx["vm_tool_help"]
self.assertEqual(sorted(TODO._QEMU_VM_TOOLS), sorted(aides))
for key, lignes in aides.items():
self.assertTrue(lignes, key)
def test_the_common_options_point_at_real_tables(self):
"""Une condition sur une clé inexistante lèverait au premier appel de
l'aide, sur l'écran, devant l'utilisateur."""
tab = extras_tables(contexte())
for cle, titre, lignes in INSTALL_HELP:
with self.subTest(titre=titre):
if cle:
self.assertIn(cle, tab)
self.assertTrue(titre.strip())
self.assertTrue(lignes)
class LesBlocs(unittest.TestCase):
def test_it_covers_the_common_settings_and_every_offered_tool(self):
blocs = Ecran(contexte()).extras_help_blocks()
titres = [titre for titre, _lignes in blocs]
self.assertEqual(len(blocs), len(titres))
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
self.assertIn(label, titres)
# Les réglages communs viennent d'abord : on lit l'écran de haut en
# bas, et l'aide suit le même ordre.
self.assertLess(len(INSTALL_HELP), len(blocs))
def test_each_tool_block_ends_with_its_disk_cost(self):
"""Le disque est ce qui décide de cocher ou non sur une machine
étroite : il est dans le bloc, pas seulement dans le libellé."""
blocs = dict(Ecran(contexte()).extras_help_blocks())
for _key, label, _hint in extras_tables(contexte())["vm_tools"]:
self.assertRegex(blocs[label][-1], r"^\+\d+ Go$")
def test_a_screen_without_tools_says_nothing_about_them(self):
"""Chaque accès aux tables est gardé : un formulaire n'est pas tenu
d'offrir tous les réglages."""
blocs = Ecran({}).extras_help_blocks()
self.assertTrue(blocs)
self.assertEqual(len(INSTALL_HELP) - 4, len(blocs))
def ouvre_l_aide(forme="qemu", touche="f1", saisie=False):
"""Ce que l'écran montre : {ouvert, texte, ferme, depart, piles}.
Chaque forme apporte SON contexte : celui de l'autre écran lui manquerait
la moitié de ses clés, et l'écran ne se monterait pas.
"""
from script.todo.proxmox_deploy_form import run_proxmox_form
from script.todo.qemu_deploy_form import run_deploy_form
vu = {}
async def scenario():
app = (
run_deploy_form(contexte(), run_app=False)
if forme == "qemu"
else run_proxmox_form(contexte_proxmox(), run_app=False)
)
async with app.run_test(size=(200, 60)) as pilote:
await pilote.pause()
if saisie:
# Un champ de saisie avale les touches imprimables : c'est la
# situation où « ? » ne peut pas servir, et F1 doit.
from textual.widgets import Input
app.set_focus(app.query_one("#f_key", Input))
await pilote.pause()
depart = app.screen.__class__.__name__
await pilote.press(touche)
await pilote.pause()
vu["ouvert"] = app.screen.__class__.__name__
vu["texte"] = " ".join(
str(w.render()) for w in app.screen.query("Static")
)
await pilote.press("escape")
await pilote.pause()
vu["ferme"] = app.screen.__class__.__name__
vu["depart"] = depart
vu["piles"] = len(app.screen_stack)
asyncio.run(scenario())
return vu
@unittest.skipUnless(TEXTUAL, "Textual absent")
class LEcran(unittest.TestCase):
def test_f1_opens_it_on_both_forms(self):
"""C'est en ne servant qu'un des deux écrans que la moitié des
réglages avait dérivé."""
for forme in ("qemu", "proxmox"):
with self.subTest(forme=forme):
vu = ouvre_l_aide(forme)
self.assertEqual("HelpScreen", vu["ouvert"])
def test_the_question_mark_opens_it_too(self):
"""C'est la touche qu'on essaie d'abord, et le panneau l'a au montage
— aucun champ de saisie ne prend le focus en arrivant."""
vu = ouvre_l_aide("qemu", "?")
self.assertEqual("HelpScreen", vu["ouvert"])
def test_f1_is_the_one_that_works_from_a_text_field(self):
"""Un champ de saisie avale les touches imprimables : « ? » s'y écrit
au lieu d'ouvrir, et c'est pourquoi F1 existe à côté."""
self.assertEqual(
"HelpScreen", ouvre_l_aide("qemu", "f1", saisie=True)["ouvert"]
)
self.assertNotEqual(
"HelpScreen", ouvre_l_aide("qemu", "?", saisie=True)["ouvert"]
)
def test_escape_closes_it_and_leaves_the_form_standing(self):
"""Une aide qui fermerait le formulaire ferait perdre la saisie."""
vu = ouvre_l_aide("qemu")
self.assertEqual(vu["depart"], vu["ferme"])
self.assertEqual(1, vu["piles"])
def test_what_it_shows_is_what_the_option_installs(self):
vu = ouvre_l_aide("qemu")
for morceau in ("tig", "zdiff3", "hooks"):
self.assertIn(morceau, vu["texte"])
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,120 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Un dépôt fautif n'arrête pas le lot, et le bilan le nomme à la fin.
Un checkout de développement porte des répertoires vidés à la main, des
clones interrompus, des `.git` effacés. Ils n'ont rien à voir avec les cent
quarante autres dépôts qui attendent leur nouveau remote, et une trace de
cette longueur noie l'avertissement qui passe au moment où il survient.
"""
import io
import os
import subprocess
import unittest
from contextlib import redirect_stdout
from tempfile import TemporaryDirectory
from script.git.git_change_remote_https_to_git import (
change_all_remotes,
print_report,
)
from script.git.git_tool import GitTool
HTTPS = "https://github.com/OCA/web.git"
GIT = "git@github.com:OCA/web.git"
def _depot(chemin, url=HTTPS):
"""Un vrai dépôt git. Rien n'est bouchonné : c'est la lecture et
l'écriture des remotes par git qu'on vérifie."""
os.makedirs(chemin, exist_ok=True)
subprocess.run(["git", "init", "-q", chemin], check=True)
subprocess.run(
["git", "-C", chemin, "remote", "add", "origin", url], check=True
)
return chemin
def _url(chemin):
return subprocess.run(
["git", "-C", chemin, "remote", "get-url", "origin"],
capture_output=True,
text=True,
check=True,
).stdout.strip()
class UnDepotFautifNArretePasLeLot(unittest.TestCase):
def _lot(self, racine, noms):
with redirect_stdout(io.StringIO()) as sortie:
changed, skipped = change_all_remotes(
GitTool(), [{"name": n} for n in noms], racine
)
return changed, skipped, sortie.getvalue()
def test_un_repertoire_sans_depot_git_est_ignore(self):
"""Le répertoire existe et ne porte plus de dépôt git :
`os.path.isdir` passe, l'ouverture du dépôt lève."""
with TemporaryDirectory() as racine:
_depot(os.path.join(racine, "addons/Bon"))
os.makedirs(os.path.join(racine, "addons/Vide"))
changed, skipped, _ = self._lot(
racine, ["addons/Bon", "addons/Vide"]
)
self.assertEqual(_url(os.path.join(racine, "addons/Bon")), GIT)
self.assertEqual(changed, 1)
self.assertEqual(len(skipped), 1)
self.assertIn("addons/Vide", skipped[0][0])
self.assertIn("no git repository", skipped[0][1])
def test_le_lot_continue_apres_le_fautif(self):
"""Le fautif est au MILIEU : ce qui le suit doit être servi."""
with TemporaryDirectory() as racine:
os.makedirs(os.path.join(racine, "addons/Vide"))
_depot(os.path.join(racine, "addons/Apres"))
changed, skipped, _ = self._lot(
racine, ["addons/Vide", "addons/Apres"]
)
self.assertEqual(_url(os.path.join(racine, "addons/Apres")), GIT)
self.assertEqual(changed, 1)
self.assertEqual(len(skipped), 1)
def test_un_repertoire_absent_est_ignore_aussi(self):
with TemporaryDirectory() as racine:
changed, skipped, _ = self._lot(racine, ["addons/Absent"])
self.assertEqual(changed, 0)
self.assertEqual(skipped[0][1], "directory is missing")
def test_rien_a_signaler_ne_signale_rien(self):
with TemporaryDirectory() as racine:
_depot(os.path.join(racine, "addons/Bon"))
changed, skipped, _ = self._lot(racine, ["addons/Bon"])
self.assertEqual((changed, skipped), (1, []))
class LeBilanSeLitALaFin(unittest.TestCase):
"""Ce que l'humain lit d'un lot long, c'est sa fin."""
def _bilan(self, total, changed, skipped):
with redirect_stdout(io.StringIO()) as sortie:
print_report(total, changed, skipped)
return sortie.getvalue()
def test_il_nomme_chaque_depot_ignore_et_sa_raison(self):
texte = self._bilan(
3, 1, [("addons/Vide", "no git repository"), ("addons/X", "gone")]
)
self.assertIn("3 repo, 1 remote updated, 2 skipped", texte)
self.assertIn("addons/Vide — no git repository", texte)
self.assertIn("addons/X — gone", texte)
def test_sans_incident_il_ne_liste_rien(self):
texte = self._bilan(2, 2, [])
self.assertIn("2 repo, 2 remote updated, 0 skipped", texte)
self.assertNotIn("Skipped", texte)
if __name__ == "__main__":
unittest.main()

View file

@ -2,10 +2,13 @@
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import io
import os
import subprocess
import tempfile
import unittest
from collections import OrderedDict
from contextlib import redirect_stdout
from unittest.mock import MagicMock, mock_open, patch
from script.git.git_tool import (
@ -265,6 +268,80 @@ class TestGetRepoInfoSubmodule(unittest.TestCase):
self.assertIn("git@", result[0]["url_git"])
class TestTheRootRepoSurvivesAMissingManifest(unittest.TestCase):
"""Le dépôt racine ne dépend ni du manifeste ni des sous-modules.
Une liste vide là où la racine était demandée fait conclure à
l'appelant qu'il n'a rien à faire : sur un checkout sans `.repo`, le
remote du dépôt principal n'était jamais réécrit, sans message.
"""
def _depot(self, chemin, origine="https://github.com/ERPLibre/ERPLibre"):
"""Un vrai dépôt git, sans manifeste. Rien n'est bouchonné : la
lecture de l'origine passe par git, et c'est elle qu'on vérifie."""
subprocess.run(["git", "init", "-q", chemin], check=True)
if origine:
subprocess.run(
["git", "-C", chemin, "remote", "add", "origin", origine],
check=True,
)
return chemin
def test_without_a_manifest_the_root_is_still_returned(self):
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual(len(result), 1)
self.assertEqual(result[0]["name"], "")
self.assertEqual(
result[0]["url_git"], "git@github.com:ERPLibre/ERPLibre"
)
def test_without_the_root_asked_nothing_is_returned(self):
"""Deux appelants internes lisent le manifeste SANS la racine : le
correctif ne doit rien rendre à qui n'en veut pas."""
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
self.assertEqual(gt.get_repo_info(tmpdir), [])
def test_a_root_without_origin_falls_back_instead_of_raising(self):
"""La liste sert à RÉÉCRIRE les remotes : un dépôt sans origine est
précisément un de ceux qu'on vient corriger."""
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir, origine="")
with redirect_stdout(io.StringIO()):
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual(len(result), 1)
self.assertTrue(result[0]["url_git"].startswith("git@"))
def test_the_root_comes_first_alongside_the_manifest_projects(self):
"""La racine porte un nom vide : le tri la met en tête, et le
script qui réécrit les remotes commence donc par elle."""
xml_content = (
'<?xml version="1.0" encoding="UTF-8"?>\n'
"<manifest>\n"
' <remote name="OCA" fetch="https://github.com/OCA/"/>\n'
' <default remote="OCA" revision="16.0"/>\n'
' <project name="web.git" path="addons/OCA_web"'
' remote="OCA" groups="odoo16.0"/>\n'
"</manifest>\n"
)
gt = GitTool()
with tempfile.TemporaryDirectory() as tmpdir:
self._depot(tmpdir)
manifeste = os.path.join(tmpdir, "manifest_test.xml")
with open(manifeste, "w") as fh:
fh.write(xml_content)
with patch.object(
GitTool, "get_manifest_file", return_value=manifeste
):
result = gt.get_repo_info(tmpdir, add_root=True)
self.assertEqual([r["name"] for r in result], ["", "addons/OCA_web"])
class TestGetManifestXmlInfo(unittest.TestCase):
def test_parses_manifest(self):
xml_content = """<?xml version="1.0" encoding="UTF-8"?>

View file

@ -1,11 +1,15 @@
#!/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.
"""Les outils d'assistance et leur pré-configuration, dans une VM.
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.
rtk, starship et UN agent — Claude Code ou opencode — sont des installateurs
amont, donc autant de curl vers l'extérieur lancés sur un SSH sans terminal.
Autour d'eux : trois paquets de terminal, les réglages git, les hooks du
dépôt, les commandes Claude et une entrée d'historique.
L'outil travaille en DEUX temps, et c'est le partage que ces tests gardent en
premier : ce qui s'installe avant le clone, puis ce qui a besoin du dépôt.
Ce que ces tests gardent :
@ -13,8 +17,11 @@ Ce que ces tests gardent :
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
- toute ligne ajoutée à un fichier du HOME l'est UNE fois, sinon chaque
redéploiement d'une même VM le rallonge ;
- la pré-configuration rend TOUJOURS 0 : c'est l'installation qui porte le
verdict de la VM, pas un confort ;
- ce qui manque sans clone est NOMMÉ, jamais tu ;
- l'identité git saisie prime sur celle de l'hôte, champ par champ.
"""
@ -105,11 +112,17 @@ class LaCommandeDistante(unittest.TestCase):
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."""
déploiement avec lui.
L'invariant est vérifié pose par pose plutôt que par un compte : un
compte figé se contente d'être mis à jour quand une pose s'ajoute,
sans rien dire de la nouvelle."""
cmd = self._cmd("claude")
self.assertEqual(3, cmd.count("</dev/null"))
self.assertEqual(3, cmd.count("timeout "))
self.assertEqual(3, cmd.count("|| true"))
bornees = [x for x in cmd.split("; ") if "timeout " in x]
self.assertGreaterEqual(len(bornees), 4)
for morceau in bornees:
self.assertIn("</dev/null", morceau)
self.assertIn("|| true", morceau)
def test_starship_is_told_not_to_ask(self):
"""Sans « -y », son installateur attend une confirmation."""
@ -117,12 +130,81 @@ class LaCommandeDistante(unittest.TestCase):
def test_the_rc_lines_are_written_once(self):
"""Sans le « grep » qui précède, chaque redéploiement d'une même VM
rallonge son ~/.bashrc d'une ligne identique."""
rallonge son ~/.bashrc — ou son historique — d'une ligne identique.
Aucun ajout n'échappe à la règle : on compte les « >> » et non les
greps, pour qu'une ligne ajoutée sans garde fasse tomber le test."""
for agent in ("claude", "opencode"):
cmd = self._cmd(agent)
with self.subTest(agent=agent):
self.assertEqual(cmd.count(">> "), cmd.count("grep -qF"))
for morceau in cmd.split("; "):
if ">> " in morceau:
self.assertIn("grep -qF", morceau)
def test_the_local_bin_is_on_the_path_for_every_agent(self):
"""rtk se pose dans ~/.local/bin. La ligne de l'agent ne couvre ce
répertoire que pour Claude Code : avec opencode, qui s'installe
ailleurs, rtk resterait introuvable dans la VM."""
for agent in ("claude", "opencode"):
with self.subTest(agent=agent):
self.assertIn(
'export PATH="$HOME/.local/bin:$PATH"', self._cmd(agent)
)
def test_an_identical_path_line_is_not_written_twice(self):
"""Claude Code S'INSTALLE dans ~/.local/bin : deux lignes identiques
n'auraient qu'un effet, et deux fois la place dans le journal."""
cmd = self._cmd("claude")
self.assertEqual(2, cmd.count("grep -qF"))
for morceau in cmd.split(";"):
if ">> ~/.bashrc" in morceau:
self.assertIn("||", morceau)
self.assertEqual(1, cmd.count("$HOME/.local/bin:$PATH"))
def test_the_terminal_tools_are_posed(self):
"""tig, htop et vim portent le même nom dans les quatre familles :
aucune n'a de raison de les rater."""
cmd = self._cmd("claude")
for gestionnaire in ("apt-get", "dnf", "zypper", "pacman"):
self.assertIn(gestionnaire, cmd)
self.assertEqual(4, cmd.count("tig htop vim"))
def test_the_venv_activation_reaches_the_history(self):
"""La commande qu'on veut retrouver à la flèche du haut, dans le
fichier que bash relit, et une seule fois."""
cmd = self._cmd("claude")
ligne = "source .venv.erplibre/bin/activate"
self.assertIn(f"echo '{ligne}' >> ~/.bash_history", cmd)
self.assertIn(f"grep -qF '{ligne}' ~/.bash_history", cmd)
def test_the_history_keeps_the_rights_bash_gives_it(self):
"""Créé par une redirection, le fichier suit l'umask — lisible par
tous sur les images visées, là où bash le crée en 600."""
self.assertIn("chmod 600 ~/.bash_history", self._cmd("claude"))
def test_the_rtk_hook_is_called_by_absolute_path(self):
"""Le PATH de cette commande distante a été figé au démarrage du
shell SSH, avant que l'installateur ne pose le binaire : « rtk » nu
rendrait 127 sans dire que le hook n'a pas été écrit."""
cmd = self._cmd("claude")
self.assertIn('RTK="$(command -v rtk || echo "$HOME', cmd)
self.assertIn('"$RTK" init --global', cmd)
def test_git_is_configured_without_taking_the_editor_over(self):
"""zdiff3 est posé sans condition. L'éditeur, lui, ne l'est que s'il
n'y en a pas : deploy_qemu transmet celui de l'hôte, et deux
autorités sur un même réglage en font une de trop."""
cmd = self._cmd("claude")
self.assertIn("git config --global merge.conflictStyle zdiff3", cmd)
self.assertIn(
"git config --global --get core.editor >/dev/null 2>&1"
" || git config --global core.editor vim",
cmd,
)
def test_the_git_settings_survive_a_vm_without_git(self):
"""La phase « before » d'une VM sans installation ERPLibre n'a pas vu
l'amorçage qui pose git : hors d'un « if », « set -e » ferait tomber
le déploiement sur une commande introuvable."""
cmd = self._cmd("claude")
self.assertIn("if command -v git >/dev/null 2>&1; then", cmd)
def test_the_path_uses_home_not_a_tilde(self):
"""Entre guillemets, le tilde n'est pas étendu : le PATH porterait
@ -159,6 +241,111 @@ class LaCommandeDistante(unittest.TestCase):
self.assertIn("opencode.ai", cmd)
class LeComplementApresClone(unittest.TestCase):
"""Ce que la pré-configuration ne peut poser qu'une fois le dépôt là."""
def _cmd(self, prod=False):
return TODO.__new__(TODO)._qemu_aidev_after_cmd(prod)
def test_the_hooks_point_at_the_checkout(self):
"""« git -C » et non le cwd : le dépôt porte des dépôts imbriqués, et
core.hooksPath écrit dans l'un d'eux laisserait la racine sans
garde-fou, sans le moindre message."""
cmd = self._cmd()
self.assertIn(
"git -C $HOME/git/erplibre config core.hooksPath"
" script/git/hooks",
cmd,
)
def test_the_hooks_get_their_execution_bit(self):
"""git saute SANS RIEN DIRE un hook qui ne l'a pas, et le garde-fou du
message de commit passe alors inaperçu."""
cmd = self._cmd()
for hook in TODO._GIT_HOOKS:
self.assertIn(f"hooks/{hook}", cmd)
self.assertIn("chmod +x", cmd)
def test_every_claude_command_is_deployed(self):
"""Une commande absente de la liste est une commande que la VM n'aura
pas, et personne ne s'en aperçoit avant d'en avoir besoin."""
cmd = self._cmd()
for nom, gabarit in TODO._QEMU_AIDEV_CLAUDE_CMDS:
self.assertIn(f"conf/{gabarit}", cmd)
self.assertIn(f"~/.claude/commands/{nom}.md", cmd)
def test_the_two_todo_commands_travel_together(self):
"""/todo_plan_max produit la spécification que /todo_add_command
implémente : l'une sans l'autre laisse la moitié de la chaîne."""
noms = [nom for nom, _g in TODO._QEMU_AIDEV_CLAUDE_CMDS]
self.assertIn("todo_plan_max", noms)
self.assertIn("todo_add_command", noms)
def test_the_identity_is_substituted_literally(self):
"""En python3 et non en sed : un nom qui porterait « & » ou le
séparateur choisi changerait de sens dans un « s/// »."""
cmd = self._cmd()
self.assertIn("python3 -c", cmd)
for marque, cle in TODO._QEMU_AIDEV_PLACEHOLDERS:
self.assertIn(marque, cmd)
self.assertIn(cle, cmd)
def test_it_follows_production_to_opt(self):
cmd = self._cmd(True)
self.assertIn("/opt/erplibre/conf/", cmd)
self.assertNotIn("$HOME/git/erplibre", cmd)
def test_nothing_in_it_can_fail_the_vm(self):
"""Une pré-configuration est un confort. Chaque commande se garde, et
la dernière — un compte-rendu — rend 0 par construction."""
for prod in (False, True):
with self.subTest(prod=prod):
cmd = self._cmd(prod)
for morceau in cmd.split("; "):
if not morceau.strip() or morceau.startswith("echo "):
continue
self.assertIn("|| true", morceau)
def test_it_is_valid_shell(self):
for prod in (False, True):
with self.subTest(prod=prod):
fini = subprocess.run(
["bash", "-n"],
input=self._cmd(prod),
text=True,
capture_output=True,
)
self.assertEqual(0, fini.returncode, fini.stderr)
def test_it_leads_the_after_phase(self):
"""En tête : quelques secondes de copies, contre une minute pour
Forgejo et une heure pour le SDK Android."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_after_remote_cmd(("aidev", "forgejo"), False)
self.assertLess(
cmd.index("core.hooksPath"), cmd.index("install_forgejo.sh")
)
def test_it_reaches_the_full_remote_command(self):
"""L'épreuve du bout en bout : sans cela, la moitié « dépôt » de la
case resterait un réglage sans effet."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_erplibre_remote_cmd("develop", tools=("aidev",))
self.assertIn("core.hooksPath", cmd)
self.assertIn("~/.claude/commands/commit.md", cmd)
def test_a_vm_without_a_checkout_says_what_it_skips(self):
"""Écarter en silence laisse croire qu'une case cochée a été
honorée. Les installations, elles, ont bien lieu."""
todo = TODO.__new__(TODO)
cmd = todo._qemu_erplibre_remote_cmd(
"", tools=("aidev",), desktop="gnome"
)
self.assertIn("starship", cmd)
self.assertNotIn("core.hooksPath", cmd)
self.assertIn("⚠", cmd)
class LIdentiteGit(unittest.TestCase):
"""Le formulaire montre l'identité de l'hôte et permet de la changer."""

View file

@ -0,0 +1,444 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le réseau libvirt de l'hôte, et la collision qui lui coûtait sa passerelle.
Le réseau « default » de libvirt sert 192.168.122.0/24. Toute VM déployée par
ce dépôt vit DANS ce réseau : son pont y prendrait l'adresse .1, celle de sa
propre passerelle. virsh refuse ce démarrage — mais seulement quand la route
est là. Au démarrage de la machine, libvirtd monte ses réseaux AVANT que le
bail DHCP ne soit arrivé : plus rien ne signale la collision, virbr0 prend
l'adresse de la passerelle, et l'hôte n'a plus de réseau. Une installation
suivie d'un redémarrage rendait donc la machine inutilisable.
Ce que ces tests gardent :
- un réseau en collision est DÉPLACÉ sur un /24 libre, par redéfinition —
laquelle ne demande ni pont ni module du noyau, et passe donc là où le
démarrage ne passe pas ;
- l'autostart ne s'arme JAMAIS sur un réseau en collision, et se RETIRE si on
le trouve armé : c'est le seul geste qui protège le démarrage suivant ;
- l'autostart s'arme quand le seul obstacle est le noyau remplacé depuis le
démarrage — c'est ce qui rend l'hôte utilisable en UN redémarrage ;
- l'état d'un réseau est lu en ANGLAIS : virsh traduit ses étiquettes, et un
hôte en français lisait tout réseau comme éteint et jamais prêt ;
- le XML déplacé garde l'identité du réseau — UUID, pont, MAC ;
- un réseau démarré ne se compte PAS comme sa propre collision : il porte et
route son /24 sur son pont, et ce pont est écarté de ce que « l'hôte occupe
déjà ». Sans cette exclusion, le verdict était « collision » sur toute
machine où le réseau tournait, quel que soit son sous-réseau, et le
déplacement qui suivait retirait leur passerelle aux VM attachées.
"""
import importlib.util
import io
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
def _deploy_qemu():
"""deploy_qemu.py chargé comme module, comme le fait todo.py."""
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()
# Le XML que virsh rend pour le réseau « default » d'une installation neuve.
XML_DEFAUT = """<network>
<name>default</name>
<uuid>2cf1310d-8812-456e-b761-9867efe4af8d</uuid>
<forward mode='nat'/>
<bridge name='virbr0' stp='on' delay='0'/>
<mac address='52:54:00:6b:6d:2f'/>
<ip address='192.168.122.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.122.2' end='192.168.122.254'/>
</dhcp>
</ip>
</network>
"""
# « virsh net-info » sous locale française : les étiquettes sont traduites,
# les valeurs aussi.
INFO_FR = """Nom : default
Actif : oui
Persistant : oui
Démarrage automatique : oui
"""
INFO_EN = """Name: default
Active: yes
Persistent: yes
Autostart: yes
"""
def reseaux(*cidrs):
import ipaddress
return [ipaddress.ip_network(c, strict=False) for c in cidrs]
class LaLecture(unittest.TestCase):
"""Ce que le script lit de virsh, et dans quelle langue."""
def test_the_state_is_read_in_english(self):
"""virsh TRADUIT ses étiquettes. Le script forçait la locale nulle
part : sur un hôte français, « Active: yes » ne se trouvait jamais,
tout réseau passait pour éteint, et --setup-host déclarait l'hôte pas
prêt quel que soit son état."""
with mock.patch.object(DQ, "virsh_out", return_value=INFO_EN):
self.assertEqual((True, True), DQ.network_state("default", False))
def test_the_french_output_is_what_the_old_pattern_missed(self):
"""La preuve du défaut, figée : la sortie traduite ne contient AUCUN
des deux motifs anglais. Si un jour on retire LC_ALL=C, ce test
rappelle ce qu'on relit."""
with mock.patch.object(DQ, "virsh_out", return_value=INFO_FR):
self.assertEqual(
(False, False), DQ.network_state("default", False)
)
def test_the_c_locale_is_forced(self):
env = DQ.c_locale_env()
self.assertEqual("C", env["LC_ALL"])
self.assertEqual("C", env["LANG"])
def test_the_subnet_comes_from_the_xml(self):
self.assertEqual(
"192.168.122.0/24", DQ.cidr_from_network_xml(XML_DEFAUT)
)
def test_an_unreadable_xml_says_nothing(self):
"""Rien plutôt qu'un sous-réseau deviné : c'est sur lui qu'on décide
de déplacer un réseau."""
self.assertEqual("", DQ.cidr_from_network_xml(""))
self.assertEqual("", DQ.cidr_from_network_xml("<network/>"))
class LaCollision(unittest.TestCase):
def test_the_default_network_collides_inside_a_deployed_vm(self):
"""Une VM de ce dépôt reçoit 192.168.122.x de son hôte, et son propre
« default » sert le même /24 : la collision est la règle, pas le cas
limite."""
self.assertEqual(
"192.168.122.0/24",
DQ.network_collision(
"192.168.122.0/24", reseaux("192.168.122.0/24")
),
)
def test_a_wider_host_route_still_collides(self):
"""Le recouvrement se CALCULE : un /16 de l'hôte contient le /24 du
réseau, ce qu'une comparaison de préfixes texte ne verrait pas."""
self.assertTrue(
DQ.network_collision("192.168.131.0/24", reseaux("192.168.0.0/16"))
)
def test_a_free_subnet_does_not_collide(self):
self.assertEqual(
"",
DQ.network_collision(
"192.168.131.0/24", reseaux("192.168.122.0/24", "127.0.0.0/8")
),
)
def test_no_subnet_means_no_verdict(self):
self.assertEqual("", DQ.network_collision("", reseaux("10.0.0.0/8")))
class CeQueLHoteOccupe(unittest.TestCase):
"""host_networks : ce qui compte comme « déjà pris », et ce qui non."""
# Ce que rendent « ip -4 route show » et « ip -o -4 addr show » sur un hôte
# dont le réseau local est ailleurs et dont le « default » de libvirt
# tourne. Les deux dernières lignes de chaque sortie sont celles du pont.
ROUTES = (
"default via 192.168.2.1 dev enp6s0 proto dhcp src 192.168.2.11"
" metric 100\n"
"192.168.2.0/24 dev enp6s0 proto kernel scope link src 192.168.2.11\n"
"192.168.122.0/24 dev virbr0 proto kernel scope link"
" src 192.168.122.1\n"
)
ADRESSES = (
"1: lo inet 127.0.0.1/8 scope host lo\\ valid_lft forever\n"
"2: enp6s0 inet 192.168.2.11/24 brd 192.168.2.255 scope global"
" enp6s0\\ valid_lft forever\n"
"3: virbr0 inet 192.168.122.1/24 brd 192.168.122.255 scope global"
" virbr0\\ valid_lft forever\n"
)
def _host_networks(self, exclure=()):
sorties = {"route": self.ROUTES, "addr": self.ADRESSES}
def faux_run(args, **kwargs):
quoi = "route" if "route" in args else "addr"
return mock.Mock(stdout=sorties[quoi])
with mock.patch.object(DQ.subprocess, "run", side_effect=faux_run):
return {str(r) for r in DQ.host_networks(exclure_ponts=exclure)}
def test_the_network_no_longer_collides_with_itself(self):
"""Le défaut qui déplaçait un réseau sain. Un « default » démarré
route son propre /24 : compté, il est en collision avec lui-même sur
TOUTE machine, et le déplacement qui suit retire aux VM attachées la
passerelle qu'elles ont dans leur bail."""
vus = self._host_networks(exclure=["virbr0"])
self.assertNotIn("192.168.122.0/24", vus)
self.assertEqual(
"",
DQ.network_collision("192.168.122.0/24", reseaux(*vus)),
)
def test_without_the_exclusion_the_verdict_was_always_collision(self):
"""La preuve du défaut, figée : sans exclusion, le même hôte — dont le
réseau local est pourtant ailleurs — voit une collision."""
vus = self._host_networks()
self.assertIn("192.168.122.0/24", vus)
def test_the_other_interfaces_still_count(self):
"""Écarter le pont n'aveugle pas sur le reste : c'est le réseau local
de l'hôte qui rend une collision RÉELLE, et il doit rester vu."""
vus = self._host_networks(exclure=["virbr0"])
self.assertIn("192.168.2.0/24", vus)
self.assertIn("127.0.0.0/8", vus)
def test_a_line_without_a_readable_device_is_kept(self):
"""Sur « ce /24 est-il libre », le silence pèse du côté prudent : une
ligne dont l'interface ne se lit pas compte comme occupée. Un pont
inconnu ('' rendu par network_bridge) n'écarte donc rien."""
self.assertEqual("", DQ.interface_de_ligne("192.168.9.0/24 proto ra"))
vus = self._host_networks(exclure=[""])
self.assertIn("192.168.122.0/24", vus)
self.assertIn("192.168.2.0/24", vus)
def test_the_interface_is_read_from_both_grammars(self):
self.assertEqual(
"enp6s0", DQ.interface_de_ligne("192.168.2.0/24 dev enp6s0 proto")
)
self.assertEqual(
"virbr0",
DQ.interface_de_ligne("3: virbr0 inet 192.168.122.1/24 scope"),
)
# Une interface appairée porte le nom de son pair : « eth0@if12 ».
self.assertEqual(
"eth0", DQ.interface_de_ligne("5: eth0@if12 inet 10.0.0.2/24")
)
class LePontDuReseau(unittest.TestCase):
def test_the_bridge_comes_from_the_xml(self):
self.assertEqual("virbr0", DQ.bridge_from_network_xml(XML_DEFAUT))
def test_no_bridge_says_nothing(self):
"""'' plutôt qu'un nom deviné : c'est sur lui qu'on écarte une
interface de la recherche de collision."""
self.assertEqual("", DQ.bridge_from_network_xml("<network/>"))
self.assertEqual("", DQ.bridge_from_network_xml(""))
class LeFichierTemporaire(unittest.TestCase):
"""Le XML posé pour « net-define » : imprévisible, et retiré."""
def test_it_is_removed_even_when_virsh_fails(self):
with self.assertRaises(RuntimeError):
with DQ.fichier_xml_temporaire("<network/>") as chemin:
garde = chemin
raise RuntimeError("virsh a échoué")
self.assertFalse(Path(garde).exists())
def test_the_name_is_not_predictable(self):
"""Un nom composé est un chemin devinable dans un répertoire où tout
le monde écrit : qui l'occupe d'avance choisit ce que root définit."""
with DQ.fichier_xml_temporaire("<network/>") as un:
with DQ.fichier_xml_temporaire("<network/>") as deux:
self.assertNotEqual(un, deux)
def test_root_can_read_it(self):
"""virsh lit le fichier sous root, et le fichier temporaire d'un
utilisateur non privilégié ne l'est pas toujours."""
with DQ.fichier_xml_temporaire("<network/>") as chemin:
self.assertTrue(Path(chemin).stat().st_mode & 0o044)
class LeSousReseauLibre(unittest.TestCase):
def test_it_starts_above_the_two_usual_ones(self):
"""122 est celui de libvirt, 123 celui de beaucoup d'installations
toutes faites : partir au-dessus des deux coûte un octet."""
self.assertEqual(131, DQ.LIBVIRT_NET_BASE)
self.assertEqual("192.168.131", DQ.free_subnet(reseaux()))
def test_it_skips_what_is_taken(self):
pris = reseaux("192.168.131.0/24", "192.168.132.0/24")
self.assertEqual("192.168.133", DQ.free_subnet(pris))
def test_a_saturated_range_says_so(self):
"""'' et non un sous-réseau au hasard : l'appelant doit pouvoir dire
qu'il ne déplace rien."""
pris = reseaux("192.168.0.0/16")
self.assertEqual("", DQ.free_subnet(pris))
class LeDeplacement(unittest.TestCase):
def test_the_network_keeps_its_identity(self):
"""Réécrit plutôt que reconstruit : l'UUID, le pont et le MAC restent,
donc les domaines qui nomment ce réseau le retrouvent."""
bouge = DQ.moved_network_xml(XML_DEFAUT, "192.168.122", "192.168.131")
self.assertIn("2cf1310d-8812-456e-b761-9867efe4af8d", bouge)
self.assertIn("<bridge name='virbr0'", bouge)
self.assertIn("52:54:00:6b:6d:2f", bouge)
def test_the_whole_subnet_moves(self):
bouge = DQ.moved_network_xml(XML_DEFAUT, "192.168.122", "192.168.131")
self.assertNotIn("192.168.122.", bouge)
self.assertIn("<ip address='192.168.131.1'", bouge)
self.assertIn("start='192.168.131.2'", bouge)
self.assertIn("end='192.168.131.254'", bouge)
self.assertEqual("192.168.131.0/24", DQ.cidr_from_network_xml(bouge))
class LOrdreDesGestes(unittest.TestCase):
"""ensure_network : déplacer, démarrer, puis armer — et pas autrement."""
def _lancer(self, cidrs, hote, actif_apres, etat_initial):
"""Rend les commandes virsh lancées, dans l'ordre.
`cidrs` : ce que « default » sert, avant puis après un déplacement.
`hote` : ce que la machine route. `actif_apres` : le réseau démarre-t-il ?
"""
runner = mock.MagicMock()
runner.dry_run = False
runner.use_sudo = False
lancees = []
runner.run.side_effect = lambda cmd, **kw: lancees.append(
[x for x in cmd if x not in ("virsh", "-c", DQ.LIBVIRT_URI)]
)
suite_cidr = list(cidrs)
etats = [etat_initial, (actif_apres, etat_initial[1])]
with mock.patch.object(
DQ,
"network_cidr",
side_effect=lambda *a: (
suite_cidr.pop(0) if len(suite_cidr) > 1 else suite_cidr[0]
),
), mock.patch.object(
DQ, "host_networks", return_value=reseaux(*hote)
), mock.patch.object(
DQ, "libvirt_networks_cidrs", return_value=[]
), mock.patch.object(
DQ, "virsh_out", return_value=XML_DEFAUT
), mock.patch.object(
DQ,
"network_state",
side_effect=lambda *a: (
etats.pop(0) if len(etats) > 1 else etats[0]
),
):
with redirect_stdout(io.StringIO()) as sortie:
DQ.ensure_network("default", runner)
return lancees, sortie.getvalue()
def test_a_colliding_network_is_moved_before_being_started(self):
"""La redéfinition ne demande ni pont ni module du noyau : elle passe
là où le démarrage ne passe pas."""
lancees, texte = self._lancer(
cidrs=["192.168.122.0/24", "192.168.131.0/24"],
hote=["192.168.122.0/24"],
actif_apres=True,
etat_initial=(False, False),
)
verbes = [c[0] for c in lancees]
self.assertEqual(["net-define", "net-start", "net-autostart"], verbes)
self.assertIn("192.168.131.0/24", texte)
def test_autostart_is_never_armed_on_a_collision(self):
"""Le geste qui cassait la machine. Sans /24 libre, rien n'est
déplacé — et alors rien n'est armé."""
lancees, texte = self._lancer(
cidrs=["192.168.122.0/24"],
hote=["192.168.0.0/16"],
actif_apres=False,
etat_initial=(False, False),
)
self.assertNotIn("net-autostart", [c[0] for c in lancees])
def test_an_armed_collision_is_disarmed(self):
"""L'état dans lequel --setup-host laissait la machine : un réseau
inactif, en collision, et armé pour le prochain démarrage."""
lancees, texte = self._lancer(
cidrs=["192.168.122.0/24"],
hote=["192.168.0.0/16"],
actif_apres=False,
etat_initial=(False, True),
)
self.assertIn(["net-autostart", "--disable", "default"], lancees)
self.assertIn("autostart RETIRÉ", texte)
def test_a_stale_kernel_keeps_the_one_reboot_shortcut(self):
"""Sans collision, un réseau qui ne démarre pas faute de modules du
noyau reste armé : au retour du redémarrage, libvirt le monte seul et
l'hôte est utilisable sans repasser par --setup-host."""
lancees, _ = self._lancer(
cidrs=["192.168.131.0/24"],
hote=["192.168.122.0/24"],
actif_apres=False,
etat_initial=(False, False),
)
verbes = [c[0] for c in lancees]
self.assertEqual(["net-start", "net-autostart"], verbes)
def test_a_healthy_network_is_left_alone(self):
"""Idempotence : relancer --setup-host sur un hôte prêt ne doit rien
lancer, et surtout pas redéfinir le réseau sous les VM qui tournent."""
lancees, _ = self._lancer(
cidrs=["192.168.131.0/24"],
hote=["192.168.122.0/24"],
actif_apres=True,
etat_initial=(True, True),
)
self.assertEqual([], lancees)
def test_an_active_network_without_collision_is_never_moved(self):
"""Le déplacer casserait les VM qui y sont attachées pour un danger
qui n'existe pas."""
lancees, _ = self._lancer(
cidrs=["192.168.131.0/24"],
hote=["192.168.122.0/24"],
actif_apres=True,
etat_initial=(True, True),
)
self.assertNotIn("net-define", [c[0] for c in lancees])
def test_an_active_collision_is_torn_down_first(self):
"""La machine DÉJÀ cassée, celle qu'on retrouve après le démarrage
fautif : le pont porte l'adresse de la passerelle. L'abattre rend
l'accès au réseau avant toute autre chose — sans quoi il n'y a même
pas de quoi télécharger un correctif."""
lancees, texte = self._lancer(
cidrs=["192.168.122.0/24", "192.168.131.0/24"],
hote=["192.168.122.0/24"],
actif_apres=True,
etat_initial=(True, True),
)
verbes = [c[0] for c in lancees]
self.assertEqual("net-destroy", verbes[0])
self.assertLess(
verbes.index("net-destroy"), verbes.index("net-define")
)
self.assertIn("rendre l'accès au réseau", texte)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,288 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Recréer le sous-réseau d'un réseau libvirt sous les VM qui y vivent.
Une VM prend son adresse par DHCP dans le /24 de son réseau et en sort par le
.1, porté par le pont. Changer ce /24 sous une VM allumée la laisse avec un
bail qui ne mène nulle part ; abattre le réseau détache en plus son tap du
pont, et rien ne l'y rebranche tant qu'elle n'a pas redémarré.
D'où l'ordre que ces tests gardent :
- les VM attachées sont ARRÊTÉES avant que le réseau ne soit touché, et ce
sont exactement celles-là qui redémarrent ensuite ;
- une VM qui n'obéit pas au shutdown ANNULE la redéfinition : la faire sous
elle lui retirerait pont et passerelle ;
- un préfixe visé qui recouvre ce que l'hôte route déjà est REFUSÉ — c'est la
panne d'origine, l'hôte y perdant sa propre passerelle au profit du pont ;
- un réseau qui sert déjà le préfixe visé n'est PAS redéfini : relancer
l'opération ne doit rien casser.
"""
import importlib.util
import io
import sys
import unittest
from contextlib import redirect_stdout
from pathlib import Path
from unittest import mock
sys.argv = ["todo.py"]
RACINE = Path(__file__).resolve().parents[1]
def _network_qemu():
"""network_qemu.py chargé comme module, comme le fait le menu TODO."""
path = RACINE / "script/qemu/network_qemu.py"
spec = importlib.util.spec_from_file_location("network_qemu", path)
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
NQ = _network_qemu()
XML_DEFAUT = """<network>
<name>default</name>
<uuid>2cf1310d-8812-456e-b761-9867efe4af8d</uuid>
<forward mode='nat'/>
<bridge name='virbr0' stp='on' delay='0'/>
<mac address='52:54:00:6b:6d:2f'/>
<ip address='192.168.131.1' netmask='255.255.255.0'>
<dhcp>
<range start='192.168.131.2' end='192.168.131.254'/>
</dhcp>
</ip>
</network>
"""
# Une définition de domaine, réduite à ce que le script y cherche.
XML_VM = """<domain type='kvm'>
<name>{nom}</name>
<devices>
<interface type='network'>
<source network='{reseau}'/>
</interface>
</devices>
</domain>
"""
def reseaux(*cidrs):
import ipaddress
return [ipaddress.ip_network(c, strict=False) for c in cidrs]
class Args:
"""Les options de la ligne de commande, réduites à ce que recreer lit."""
def __init__(self, **kw):
self.network = kw.get("network", "default")
self.prefix = kw.get("prefix", "192.168.122")
self.timeout = kw.get("timeout", 1)
self.force_off = kw.get("force_off", False)
self.assume_yes = kw.get("assume_yes", True)
self.dry_run = kw.get("dry_run", False)
class Parc:
"""Un hôte bouchonné : ses VM, leur état, et ce que virsh en dit.
Les états sont MUTABLES : « shutdown » éteint la VM dans ce parc, si bien
que l'attente d'extinction du script s'y termine comme sur une vraie
machine, sans dormir.
"""
def __init__(self, vms, reseau="default"):
# vms : {nom: "running" | "shut off"}
self.etats = dict(vms)
self.reseau = reseau
self.sourdes = set()
self.lancees = []
def virsh_out(self, args, use_sudo, timeout=20):
verbe = args[0]
if verbe == "list":
return " ".join(self.etats)
if verbe == "domstate":
return self.etats[args[1]]
if verbe == "dumpxml":
return XML_VM.format(nom=args[-1], reseau=self.reseau)
if verbe == "net-dumpxml":
return XML_DEFAUT
if verbe == "net-dhcp-leases":
return ""
return ""
def run(self, cmd, **kwargs):
propre = [
x for x in cmd if x not in ("virsh", "-c", NQ.DQ.LIBVIRT_URI)
]
self.lancees.append(propre)
verbe, cible = propre[0], propre[-1]
if verbe == "shutdown" and cible not in self.sourdes:
self.etats[cible] = "shut off"
elif verbe == "destroy":
self.etats[cible] = "shut off"
elif verbe == "start":
self.etats[cible] = "running"
def verbes(self):
return [c[0] for c in self.lancees]
def lancer(
parc, args=None, hote=("192.168.2.0/24",), etat_reseau=(True, True)
):
"""Lance recreer() sur un parc bouchonné. Rend (code, sortie)."""
runner = mock.MagicMock()
runner.dry_run = False
runner.use_sudo = False
runner.run.side_effect = parc.run
with mock.patch.object(
NQ.DQ, "virsh_out", side_effect=parc.virsh_out
), mock.patch.object(
NQ.DQ, "host_networks", return_value=reseaux(*hote)
), mock.patch.object(
NQ.DQ, "libvirt_networks_cidrs", return_value=[]
), mock.patch.object(
NQ.DQ, "network_state", return_value=etat_reseau
):
with redirect_stdout(io.StringIO()) as sortie:
code = NQ.recreer(args or Args(), runner)
return code, sortie.getvalue()
class LOrdreDesTroisGestes(unittest.TestCase):
def test_the_vms_stop_before_the_network_moves_and_start_after(self):
"""L'ordre EST la fonctionnalité : redéfinir sous une VM allumée lui
retire son pont, la redémarrer avant la redéfinition lui redonne un
bail dans l'ancien /24."""
parc = Parc({"vm-a": "running", "vm-b": "running"})
code, texte = lancer(parc)
self.assertEqual(0, code)
verbes = parc.verbes()
self.assertEqual(
["shutdown", "shutdown", "net-destroy", "net-define"], verbes[:4]
)
self.assertLess(verbes.index("net-define"), verbes.index("start"))
self.assertEqual(["net-start", "net-autostart"], verbes[4:6])
self.assertEqual(["start", "start"], verbes[6:])
def test_only_the_vms_it_stopped_are_started_again(self):
"""Une VM éteinte AVANT l'opération le reste : ce script recrée un
sous-réseau, il ne décide pas de ce qui tourne sur l'hôte."""
parc = Parc({"vm-a": "running", "vm-b": "shut off"})
lancer(parc)
demarrees = [c[-1] for c in parc.lancees if c[0] == "start"]
self.assertEqual(["vm-a"], demarrees)
def test_only_the_vms_of_that_network_are_touched(self):
"""Une VM sur un autre réseau n'a rien à faire dans l'opération."""
parc = Parc({"vm-a": "running"}, reseau="autre-reseau")
lancer(parc)
self.assertNotIn("shutdown", parc.verbes())
self.assertNotIn("start", parc.verbes())
def test_a_network_without_vm_is_still_recreated(self):
parc = Parc({})
code, _ = lancer(parc)
self.assertEqual(0, code)
self.assertIn("net-define", parc.verbes())
class LaVmQuiNObeitPas(unittest.TestCase):
def test_a_vm_that_stays_up_cancels_the_redefinition(self):
"""Rien n'est touché : la redéfinition sous une VM vivante lui
retirerait son pont ET sa passerelle, et elle n'a même plus de quoi
le dire."""
parc = Parc({"vm-a": "running"})
parc.sourdes.add("vm-a")
code, texte = lancer(parc)
self.assertEqual(1, code)
self.assertNotIn("net-define", parc.verbes())
self.assertNotIn("net-destroy", parc.verbes())
self.assertIn("--force-off", texte)
def test_force_off_cuts_the_power_and_the_work_goes_on(self):
parc = Parc({"vm-a": "running"})
parc.sourdes.add("vm-a")
code, _ = lancer(parc, Args(force_off=True))
self.assertEqual(0, code)
self.assertIn("destroy", parc.verbes())
self.assertLess(
parc.verbes().index("destroy"), parc.verbes().index("net-define")
)
class LePrefixeVise(unittest.TestCase):
def test_a_prefix_the_host_already_routes_is_refused(self):
"""La panne d'origine, dans l'autre sens : poser le réseau sur le /24
de l'hôte donne au pont l'adresse de la passerelle, et la machine
perd son accès au réseau au démarrage suivant."""
parc = Parc({"vm-a": "running"})
code, texte = lancer(
parc, Args(prefix="192.168.2"), hote=("192.168.2.0/24",)
)
self.assertEqual(1, code)
self.assertNotIn("net-define", parc.verbes())
self.assertIn("passerelle", texte)
def test_the_stopped_vms_start_again_even_when_nothing_moved(self):
"""Un refus ne doit pas laisser le parc éteint : les VM arrêtées pour
l'opération sont rendues à l'état où on les a prises."""
parc = Parc({"vm-a": "running"})
lancer(parc, Args(prefix="192.168.2"), hote=("192.168.2.0/24",))
self.assertEqual("running", parc.etats["vm-a"])
def test_a_prefix_already_served_is_not_redefined(self):
"""Idempotence : relancer l'opération sur un réseau déjà bon ne le
redéfinit pas, et n'abat donc rien sous les VM.
Le cycle d'extinction et de rallumage, lui, a lieu : c'est ce qui
REBRANCHE le tap d'une VM sur le pont. Un réseau abattu laisse ses VM
détachées, et libvirt ne les y remet pas de lui-même — sur un parc
déjà déplacé, c'est la seule chose qui reste à faire."""
parc = Parc({"vm-a": "running"})
code, texte = lancer(parc, Args(prefix="192.168.131"))
self.assertEqual(0, code)
self.assertNotIn("net-define", parc.verbes())
self.assertNotIn("net-destroy", parc.verbes())
self.assertEqual(["shutdown", "net-autostart", "start"], parc.verbes())
def test_an_invalid_prefix_stops_the_program(self):
"""Trois octets, et un /24 que ipaddress accepte. Un préfixe pris de
travers réécrirait le XML au hasard."""
for mauvais in ("192.168", "192.168.1.1", "192.168.999", "abc"):
with self.assertRaises(SystemExit):
NQ.valider_prefixe(mauvais)
self.assertEqual("192.168.122", NQ.valider_prefixe("192.168.122"))
class LaLecture(unittest.TestCase):
def test_the_prefix_comes_from_the_subnet(self):
self.assertEqual("192.168.122", NQ.prefixe_de("192.168.122.0/24"))
self.assertEqual("", NQ.prefixe_de(""))
def test_the_attached_vms_are_read_from_the_persistent_definition(self):
"""« --inactive » : la vue vivante d'une VM allumée est décorée de ce
que libvirt lui a alloué au démarrage, mais c'est la définition qui
dit à quel réseau elle revient."""
parc = Parc({"vm-a": "running"})
with mock.patch.object(NQ.DQ, "virsh_out", side_effect=parc.virsh_out):
self.assertEqual(["vm-a"], NQ.domaines_du_reseau("default", False))
self.assertEqual([], NQ.domaines_du_reseau("autre", False))
def test_a_paused_vm_counts_as_alive(self):
"""Une VM en pause tient toujours son tap : la redéfinir sous elle la
casse comme une VM qui tourne."""
parc = Parc({"vm-a": "paused"})
with mock.patch.object(NQ.DQ, "virsh_out", side_effect=parc.virsh_out):
self.assertTrue(NQ.domaine_actif("vm-a", False))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,250 @@
#!/usr/bin/env python3
# © 2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Pourquoi le déploiement demande un mot de passe, dit AVANT de le demander.
sudo n'explique jamais ce qu'il sert à faire : son invite tombe entre deux
lignes de journal, et on la subit sans savoir si elle porte sur libvirt, sur
un paquet ou sur un fichier. Appartenir au groupe libvirt ne suffit pas à
s'en passer, et la raison n'est pas libvirt : le déploiement écrit le disque
et le seed dans le pool de libvirt, un répertoire de root où le groupe ne
donne aucun droit d'écriture.
Ce que ces tests gardent :
- la raison est CONSTATÉE, jamais supposée : l'écriture d'un répertoire se
teste, elle ne se déduit pas de son mode — une ACL peut l'accorder là où
« drwxr-xr-x root root » semble la refuser ;
- l'explication précède la commande, et ne se répète pas ;
- root ne s'entend rien annoncer : aucune invite ne viendra ;
- les faits ont UNE source, deploy_qemu, et deux rendus — le français du
script et les deux langues du menu.
"""
import importlib.util
import io
import os
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.todo import TODO # noqa: E402
from script.todo.todo_i18n import t # noqa: E402
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()
PAS_ROOT = os.geteuid() != 0
class LeConstat(unittest.TestCase):
def test_a_writable_directory_needs_nothing(self):
with tempfile.TemporaryDirectory() as chemin:
self.assertIsNone(DQ.repertoire_a_root(Path(chemin)))
@unittest.skipUnless(PAS_ROOT, "root écrit partout")
def test_a_directory_closed_to_writing_is_reported_with_its_mode(self):
"""Le mode et le propriétaire ne sont lus qu'APRÈS le test d'écriture,
et servent à dire au lecteur ce qui bloque."""
with tempfile.TemporaryDirectory() as chemin:
os.chmod(chemin, 0o500)
try:
vu = DQ.repertoire_a_root(Path(chemin))
finally:
os.chmod(chemin, 0o700)
self.assertIsNotNone(vu)
self.assertEqual(chemin, vu[0])
self.assertIn(":", vu[1])
self.assertTrue(vu[2].startswith("d"), vu[2])
def test_a_missing_directory_says_nothing(self):
"""Le déploiement le créera, et c'est son parent qui décide alors."""
self.assertIsNone(DQ.repertoire_a_root(Path("/nexiste/pas/ici")))
def test_the_default_pool_is_named_once(self):
"""Deux écritures du même chemin divergent dès qu'on en change une :
argparse et le constat doivent lire la même constante."""
parser = DQ.build_parser()
args = parser.parse_args(["--distro", "ubuntu", "--hostname", "vm"])
self.assertEqual(DQ.DEFAULT_DISK_DIR, args.disk_dir)
self.assertEqual(DQ.DEFAULT_IMAGE_DIR, args.image_dir)
self.assertEqual(DQ.DEFAULT_IMAGE_DIR, args.seed_dir)
class LesFaits(unittest.TestCase):
def test_the_socket_is_always_probed(self):
"""Ce que le groupe libvirt couvre RÉELLEMENT se teste en essayant :
y figurer et en disposer dans la session courante sont deux choses."""
with mock.patch.object(DQ, "libvirt_ready", return_value=True):
faits = DQ.sudo_facts()
self.assertIn(("socket", ("ok",)), faits)
@unittest.skipUnless(PAS_ROOT, "root écrit partout")
def test_a_closed_directory_becomes_a_fact(self):
with tempfile.TemporaryDirectory() as chemin:
os.chmod(chemin, 0o500)
try:
with mock.patch.object(DQ, "libvirt_ready", return_value=True):
faits = DQ.sudo_facts(Path(chemin), Path(chemin))
finally:
os.chmod(chemin, 0o700)
ecritures = [v for cle, v in faits if cle == "ecriture"]
# Le même répertoire deux fois ne se dit qu'une.
self.assertEqual(1, len(ecritures))
self.assertEqual(chemin, ecritures[0][0])
def test_a_missing_virsh_does_not_accuse_the_group(self):
"""Sans virsh il n'y a rien à joindre : dire « la socket ne répond
pas » ferait chercher un droit là où il manque un paquet."""
with mock.patch.object(DQ.shutil, "which", return_value=None):
faits = DQ.sudo_facts()
self.assertIn(("socket", ("absent",)), faits)
self.assertNotIn("ne répond pas", " ".join(DQ.sudo_lignes(faits)))
class LeRenduDuScript(unittest.TestCase):
def test_it_names_the_directory_and_what_the_group_does_not_cover(self):
faits = [
(
"ecriture",
("/var/lib/libvirt/images", "root:root", "drwxr-xr-x"),
),
("socket", ("ok",)),
]
texte = " ".join(DQ.sudo_lignes(faits))
self.assertIn("/var/lib/libvirt/images", texte)
self.assertIn("root:root", texte)
self.assertIn("groupe libvirt", texte)
def test_without_a_directory_it_names_the_system_steps(self):
"""Sans répertoire fermé, le mot de passe reste dû aux gestes système
— dire « écrire dans » serait faux."""
texte = " ".join(DQ.sudo_lignes([("socket", ("ok",))]))
self.assertIn("gestes système", texte)
self.assertNotIn("écrire dans", texte)
def test_a_silent_socket_is_said_too(self):
"""Le cas du groupe déclaré mais absent de la session : la table dit
ce qui est DÉCLARÉ, l'essai dit ce dont la session DISPOSE."""
texte = " ".join(DQ.sudo_lignes([("socket", ("non",))]))
self.assertIn("ne répond pas non plus sans sudo", texte)
class LAnnonceAvantLInvite(unittest.TestCase):
def _lancer(self, appels=2):
runner = DQ.Runner(use_sudo=True, dry_run=True)
with mock.patch.object(
DQ,
"sudo_facts",
return_value=[
("ecriture", ("/var/lib/libvirt/images", "root:root", "drwx")),
("socket", ("ok",)),
],
):
with redirect_stdout(io.StringIO()) as sortie:
for _ in range(appels):
runner.run(["qemu-img", "resize", "x"], privileged=True)
return sortie.getvalue()
def test_the_reason_comes_before_the_command(self):
"""Après l'invite, l'explication n'explique plus rien : le mot de
passe est déjà tapé."""
texte = self._lancer(appels=1)
self.assertLess(
texte.index("sudo va demander"), texte.index("qemu-img")
)
def test_it_is_said_once_for_the_whole_run(self):
"""sudo garde sa réponse quelques minutes ; répéter l'explication à
chaque étape noierait le journal."""
self.assertEqual(1, self._lancer(appels=3).count("sudo va demander"))
def test_nothing_is_said_where_nothing_is_prefixed(self):
runner = DQ.Runner(use_sudo=False, dry_run=True)
with redirect_stdout(io.StringIO()) as sortie:
runner.run(["qemu-img", "resize", "x"], privileged=True)
self.assertNotIn("sudo", sortie.getvalue())
class LeRenduDuMenu(unittest.TestCase):
"""Le récapitulatif parle deux langues, le script une seule."""
def _lignes(self, faits, euid=1000):
todo = TODO.__new__(TODO)
module = mock.MagicMock()
module.sudo_facts.return_value = faits
with mock.patch.object(
TODO, "_qemu_import_module", return_value=module
), mock.patch.object(os, "geteuid", return_value=euid):
return todo._qemu_sudo_lines()
def test_root_is_told_nothing(self):
"""Aucune invite ne viendra : annoncer une question qui ne se posera
pas est pire que se taire."""
self.assertEqual([], self._lignes([("socket", ("ok",))], euid=0))
def test_the_directory_and_its_rights_are_shown(self):
lignes = self._lignes(
[
("ecriture", ("/var/lib/libvirt/images", "root:root", "drwx")),
("socket", ("ok",)),
]
)
texte = " ".join(lignes)
self.assertIn("/var/lib/libvirt/images", texte)
self.assertIn("root:root", texte)
self.assertIn("drwx", texte)
def test_a_silent_socket_is_reported(self):
lignes = self._lignes([("socket", ("non",))])
self.assertTrue(
any(
t(
"the libvirt socket does not answer without sudo either:"
" group absent from this session, or libvirt not started"
)
== ligne
for ligne in lignes
),
lignes,
)
def test_an_unreadable_module_says_nothing(self):
"""Le récapitulatif ne doit pas tomber parce qu'un module ne se charge
pas : c'est la page qu'on relit avant de créer des disques."""
todo = TODO.__new__(TODO)
with mock.patch.object(
TODO, "_qemu_import_module", side_effect=OSError("absent")
), mock.patch.object(os, "geteuid", return_value=1000):
self.assertEqual([], todo._qemu_sudo_lines())
def test_every_sentence_is_translated(self):
"""Une clé absente du dictionnaire ressortirait en anglais au milieu
d'un récapitulatif français."""
for cle in (
"sudo password: asked when the deployment starts",
"write into %s — checked: %s %s, writing refused here",
"the libvirt group opens the qemu:///system socket, not this"
" directory",
"the system steps of the script (service, group)",
):
with self.subTest(cle=cle[:40]):
self.assertNotEqual(cle, t(cle))
if __name__ == "__main__":
unittest.main()

View file

@ -115,6 +115,19 @@ def releve(fabrique, ctx, gestes=None):
return vu
def liaison(entree):
"""(touche, action) d'une entrée de BINDINGS.
Textual en accepte DEUX formes : le tuple, et l'objet Binding quand la
liaison porte un réglage — « show=False » pour un raccourci qui doublerait
une ligne du pied de page. Lire les deux ici évite qu'un écran ait à
choisir sa forme pour plaire à un test.
"""
if isinstance(entree, tuple):
return entree[0], entree[1]
return entree.key, entree.action
@unittest.skipUnless(TEXTUAL, "Textual absent")
class TestLesDeuxEcrans(unittest.TestCase):
@classmethod
@ -188,8 +201,11 @@ class TestLesDeuxEcrans(unittest.TestCase):
("QEMU/KVM", run_deploy_form(todo._qemu_form_context(mod), False)),
("Proxmox", run_proxmox_form(contexte_proxmox(todo), False)),
):
paires = [liaison(b) for b in app.BINDINGS]
touches[nom] = {
b[0]: b[1] for b in app.BINDINGS if b[1].startswith("select_")
touche: action
for touche, action in paires
if action.startswith("select_")
}
self.assertEqual(touches["QEMU/KVM"], touches["Proxmox"])
self.assertIn("select_main", touches["Proxmox"].values())

View file

@ -383,6 +383,8 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
"List available images": "_qemu_list_images",
"Recover files from a VM disk (libguestfs)": "_qemu_recover_files",
"Diagnostics (report to share)": "_qemu_diagnostics",
"Show the libvirt network state": "_qemu_network_status",
"Recreate the VM subnet": "_qemu_network_recreate",
}