From 416a0c86311e0ef8307c3cbe59bc551216ef85be Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 4 Sep 2026 02:27:51 -0400 Subject: [PATCH] [IMP] qemu deploy : constater le motif du sudo, le dire avant l'invite MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sudo ne dit jamais ce qu'il sert à faire : son 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 l'air de suffire. Il ne suffit pas, et libvirt n'y est pour rien : le disque et le seed s'écrivent dans le pool par défaut, répertoire de root où le groupe ne donne pas l'écriture. Le motif est constaté, non déduit : l'écriture s'essaie, une ACL pouvant l'accorder là où le mode semble la refuser. Dit une fois avant la première commande privilégiée, et en dernière ligne du récapitulatif. Vérifié : 18 tests, root compris, à qui rien n'est dit. --- EN --- sudo never states what it is about to do: its 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 seed are written into the default pool, a root-owned directory where the group grants no write right. The reason is checked, not deduced: writing is tried, since an ACL can grant it where the mode seems to refuse it. Said once before the first privileged command, and last on the review page. Checked: 18 tests, root included, which is told nothing. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/qemu/deploy_qemu.py | 128 ++++++++++++++++- script/todo/qemu_deploy.py | 46 +++++++ script/todo/todo_i18n.py | 34 +++++ test/test_qemu_sudo_reason.py | 250 ++++++++++++++++++++++++++++++++++ 7 files changed, 457 insertions(+), 5 deletions(-) create mode 100644 test/test_qemu_sudo_reason.py diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 1e3712d..631f1c3 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -273,6 +273,7 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Corrigé +- 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 - `--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 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 @@ -301,6 +302,7 @@ 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 +- 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 - `--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 - 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 diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index abae75b..6ad5e7c 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -128,6 +128,7 @@ 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 - `--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 - 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 diff --git a/CHANGELOG.md b/CHANGELOG.md index d387818..38ac332 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -126,6 +126,7 @@ 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 - `--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 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 diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index edc34cf..d025a62 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -56,7 +56,9 @@ import zlib import os import re import shutil +import pwd import socket +import stat as stat_mod import subprocess import sys import tempfile @@ -440,7 +442,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, ...] = ( @@ -678,6 +685,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.""" @@ -685,6 +784,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, @@ -701,6 +818,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: @@ -3712,14 +3830,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", diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index e4ae723..44f3440 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -969,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, diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8752cea..c59cb90 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5015,6 +5015,40 @@ TRANSLATIONS = { "fr": "Outils d'assistance IA", "en": "AI coding tools", }, + # 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", diff --git a/test/test_qemu_sudo_reason.py b/test/test_qemu_sudo_reason.py new file mode 100644 index 0000000..0b0dace --- /dev/null +++ b/test/test_qemu_sudo_reason.py @@ -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()