From c98c69db1cf43c3dbcb6b40cfc6b6e5c767c3096 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:19 -0400 Subject: [PATCH 01/14] =?UTF-8?q?[IMP]=20hygi=C3=A8ne=20:=20lire=20aussi?= =?UTF-8?q?=20les=20commentaires=20Go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'outil n'inspectait que « # » : sondé avec des motifs interdits, il restait muet sur un « .go » et sortait 0. Du Go étant entré dans le dépôt, ses commentaires n'étaient relus par personne d'autre que leur auteur, alors que la convention vaut pour tout commentaire. Trois pièges, et le premier est celui qui compte : « // » ouvre un commentaire SAUF dans une chaîne, et « https:// » en porte deux. La chaîne brute entre accents graves n'échappe rien, et « /* … */ » couvre plusieurs lignes. Vérifié : 8 cas neufs, dont l'URL qui ne doit rien ouvrir. --- EN --- The tool only inspected « # »: probed with forbidden patterns, it stayed silent on a « .go » and exited 0. Now that Go has entered the repository, its comments were read by nobody but their author, while the convention covers every comment. Three traps, and the first is the one that matters: « // » opens a comment EXCEPT inside a string, and « https:// » carries two of them. The raw string between backticks escapes nothing, and « /* … */ » spans several lines. Checked: 8 new cases, including the URL that must open nothing. Assisted-by: Claude Opus 5 --- script/analyse/check_comment_hygiene.py | 62 ++++++++++++++++++++++++- test/test_check_comment_hygiene.py | 61 ++++++++++++++++++++++++ 2 files changed, 122 insertions(+), 1 deletion(-) diff --git a/script/analyse/check_comment_hygiene.py b/script/analyse/check_comment_hygiene.py index a2e8f8d..d1c1795 100755 --- a/script/analyse/check_comment_hygiene.py +++ b/script/analyse/check_comment_hygiene.py @@ -68,7 +68,7 @@ except Exception: # pragma: no cover - repli si i18n indisponible return key -SUFFIXES = (".py", ".sh", ".bash") +SUFFIXES = (".py", ".sh", ".bash", ".go") # Ce qui vient d'ailleurs ou n'est pas du source : le dépôt ne le réécrit pas. EXCLUS = ( @@ -308,10 +308,70 @@ def blocs_shell(source): return _regroupe(commentaires) +def blocs_go(source): + """Les commentaires d'un fichier Go, les consécutifs regroupés. + + Trois pièges, et le premier est celui qui compte : « // » ouvre un + commentaire SAUF dans une chaîne — et « https:// » en porte deux. Go a + trois formes de chaîne, dont la brute entre accents graves, où la barre + oblique inverse n'échappe rien. Les commentaires de bloc « /* … */ » + couvrent plusieurs lignes, chacune comptant pour ce qu'elle dit. + """ + commentaires = [] + en_bloc = False + for numero, ligne in enumerate(source.split("\n"), start=1): + if en_bloc: + fin = ligne.find("*/") + texte = (ligne if fin < 0 else ligne[:fin]).strip() + if texte: + commentaires.append((numero, texte.lstrip("*").strip())) + if fin >= 0: + en_bloc = False + continue + quote = None + precedent = "" + index = 0 + while index < len(ligne): + caractere = ligne[index] + if precedent == "\\" and quote in ('"', "'"): + # La chaîne brute ignore l'échappement : seules les deux + # autres formes le connaissent. + precedent = "" + index += 1 + continue + if quote: + if caractere == quote: + quote = None + elif caractere in "\"'`": + quote = caractere + elif caractere == "/" and ligne[index : index + 2] == "//": + texte = ligne[index:].lstrip("/").strip() + if texte: + commentaires.append((numero, texte)) + break + elif caractere == "/" and ligne[index : index + 2] == "/*": + reste = ligne[index + 2 :] + fin = reste.find("*/") + texte = (reste if fin < 0 else reste[:fin]).strip() + if texte: + commentaires.append((numero, texte)) + if fin < 0: + en_bloc = True + break + index += 2 + fin + 2 + precedent = "" + continue + precedent = caractere + index += 1 + return _regroupe(commentaires) + + def blocs(chemin, source): """Les commentaires d'un fichier, selon son suffixe.""" if chemin.endswith(".py"): return blocs_python(source) + if chemin.endswith(".go"): + return blocs_go(source) return blocs_shell(source) diff --git a/test/test_check_comment_hygiene.py b/test/test_check_comment_hygiene.py index ae248a3..84d5173 100644 --- a/test/test_check_comment_hygiene.py +++ b/test/test_check_comment_hygiene.py @@ -134,6 +134,67 @@ class TestLeTemoignage(unittest.TestCase): self.assertEqual(2, len({t[1].lower() for t in trouves})) +class TestLeGo(unittest.TestCase): + """Go est entré dans le dépôt, et l'outil ne le lisait pas. + + Sondé avec des motifs interdits, il restait muet sur un « .go » et sortait + 0 : les commentaires du cache de téléchargement n'ont donc jamais été + relus par personne d'autre que leur auteur. + """ + + def texte(self, source): + """Le texte recollé de tous les blocs. Un bloc est un dict : + « line », « text », « lines », « offsets ».""" + return " ".join(b["text"] for b in hygiene.blocs_go(source)) + + def test_un_commentaire_de_ligne(self): + trouve = hygiene.inspect("x.go", "// Vécu : la panne\npackage main\n") + self.assertTrue(trouve, "un commentaire Go n'est pas inspecté") + + def test_une_url_en_chaine_nouvre_rien(self): + """« https:// » porte deux barres obliques : c'est LE piège du Go.""" + source = 'package main\n\nconst a = "https://vecu.example/hier"\n' + self.assertEqual(hygiene.blocs_go(source), []) + + def test_une_chaine_brute_nouvre_rien(self): + """L'accent grave délimite une chaîne où rien ne s'échappe.""" + source = ( + "package main\n\nconst a = `https://mesure.example/nous avons`\n" + ) + self.assertEqual(hygiene.blocs_go(source), []) + + def test_un_commentaire_apres_une_chaine(self): + source = ( + 'package main\n\nconst a = "https://x.example" // Vécu : ici\n' + ) + trouve = hygiene.recits(self.texte(source)) + self.assertTrue(trouve, "le commentaire qui suit une URL est perdu") + + def test_un_bloc_sur_plusieurs_lignes(self): + source = ( + "package main\n\n/*\nVécu : la semaine où\nnous avons vu.\n*/\n" + ) + self.assertTrue( + hygiene.blocs_go(source), "un commentaire /* */ n'est pas lu" + ) + texte = self.texte(source) + self.assertIn("Vécu", texte) + self.assertIn("nous avons", texte) + + def test_un_bloc_sur_une_seule_ligne(self): + source = "package main\n\nvar x = 1 /* Vécu : ici */\nvar y = 2\n" + self.assertTrue(hygiene.blocs_go(source)) + self.assertIn("Vécu", self.texte(source)) + + def test_le_code_apres_un_bloc_ferme_est_relu(self): + """Un « // » qui suit un bloc fermé sur la même ligne compte encore.""" + source = "package main\n\nvar x = 1 /* rien */ // Vécu : là\n" + self.assertIn("Vécu", self.texte(source)) + + def test_le_suffixe_est_balaye(self): + self.assertIn(".go", hygiene.SUFFIXES) + + class TestLesIdentifiants(unittest.TestCase): def test_un_courriel(self): self.assertIn( From 41bb10ab651d7da9e3df764303283366e88e5ba2 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:19 -0400 Subject: [PATCH 02/14] =?UTF-8?q?[FIX]=20script=20todo=20:=20lire=20=C2=AB?= =?UTF-8?q?=20default=20=C2=BB=20l=C3=A0=20o=C3=B9=20le=20fichier=20l'?= =?UTF-8?q?=C3=A9crit=20ainsi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'étiquette « - Default » ne paraissait jamais, ni au choix de version ni au choix d'environnement : les deux lectures interrogeaient « Default » avec une majuscule, quand conf/supported_version_erplibre.json écrit « default ». dict.get rend None sur une clé absente, sans rien dire — la version par défaut passait donc pour une version ordinaire, et rien ne le signalait. Le garde-fou ne vérifie pas ce seul cas : il apparie les deux côtés, et toute clé que le menu interroge doit exister dans le fichier. Vérifié : 4 tests, dont deux tombent si la majuscule revient. --- EN --- The « - Default » label never appeared, neither on the version choice nor on the environment one: both reads asked for « Default » with a capital, while conf/supported_version_erplibre.json writes « default ». dict.get returns None on a missing key without a word — the default version therefore looked like an ordinary one, and nothing said otherwise. The guard does not check that single case: it pairs both sides, and every key the menu asks for must exist in the file. Checked: 4 tests, two of which fail if the capital comes back. Assisted-by: Claude Opus 5 --- script/todo/todo.py | 4 +- test/test_version_menu_keys.py | 101 +++++++++++++++++++++++++++++++++ 2 files changed, 103 insertions(+), 2 deletions(-) create mode 100644 test/test_version_menu_keys.py diff --git a/script/todo/todo.py b/script/todo/todo.py index d0c9eba..6880299 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -424,7 +424,7 @@ class TODO( label += " - Installed" if odoo_version == odoo_installed_version: label += " - Actual" - if version_info.get("Default"): + if version_info.get("default"): label += " - Default" if version_info.get("is_deprecated"): label += " - Deprecated" @@ -5262,7 +5262,7 @@ class TODO( label = f"{key_s}: {erplibre_version}" if odoo_version == odoo_installed_version: label += f" - {t('Current')}" - if version_info.get("Default"): + if version_info.get("default"): label += f" - {t('Default')}" environments[key_s] = { diff --git a/test/test_version_menu_keys.py b/test/test_version_menu_keys.py new file mode 100644 index 0000000..76f30e6 --- /dev/null +++ b/test/test_version_menu_keys.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le menu des versions lit-il les clés que le fichier des versions écrit ? + +`get_odoo_version()` rend les entrées de `conf/supported_version_erplibre.json` +TELLES QUELLES, plus une clé synthétisée. Le menu les interroge par +`version_info.get("...")`, et `dict.get` sur une clé absente rend None sans +rien dire : une étiquette qui ne s'affiche jamais, et aucun message. + +C'est arrivé — `get("Default")` avec une majuscule contre `"default"` dans le +JSON, à deux endroits : l'étiquette « - Default » ne paraissait ni au choix de +version, ni au choix d'environnement, et la version par défaut passait donc +pour une version ordinaire. + +Le test apparie les deux côtés plutôt que de vérifier ce seul cas : toute clé +que le menu lit doit exister dans le fichier, quelle que soit celle qu'on +ajoutera demain. +""" + +import json +import re +import sys +import unittest +from pathlib import Path + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +TODO_PY = RACINE / "script" / "todo" / "todo.py" +VERSIONS = RACINE / "conf" / "supported_version_erplibre.json" + +# Ce que `get_odoo_version` ajoute aux entrées du fichier. +SYNTHETISEES = {"erplibre_version"} + + +def cles_du_fichier(): + with VERSIONS.open(encoding="utf-8") as fh: + data = json.load(fh) + cles = set(SYNTHETISEES) + for entree in data.values(): + cles |= set(entree) + return cles + + +def cles_lues_par_le_menu(): + src = TODO_PY.read_text(encoding="utf-8") + return set(re.findall(r'version_info\.get\(\s*["\']([^"\']+)["\']', src)) + + +class TestClesDuMenuDesVersions(unittest.TestCase): + def test_le_menu_ne_lit_que_des_cles_qui_existent(self): + lues = cles_lues_par_le_menu() + self.assertTrue( + lues, "aucune lecture trouvée : le test ne mesure rien" + ) + inconnues = sorted(lues - cles_du_fichier()) + self.assertEqual( + inconnues, + [], + "le menu interroge des clés que le fichier des versions n'écrit " + f"pas : {inconnues} — dict.get rend None sans rien dire, donc " + "l'étiquette ne paraît jamais", + ) + + def test_la_casse_compte(self): + """La panne d'origine tenait à une seule majuscule.""" + self.assertNotIn( + "Default", + cles_lues_par_le_menu(), + "« Default » est relu avec une majuscule, alors que le fichier " + "écrit « default »", + ) + + def test_une_seule_version_par_defaut(self): + with VERSIONS.open(encoding="utf-8") as fh: + data = json.load(fh) + defauts = [k for k, v in data.items() if v.get("default")] + self.assertEqual( + len(defauts), + 1, + f"{len(defauts)} versions marquées par défaut : {defauts}", + ) + + def test_letiquette_par_defaut_est_atteignable(self): + """La clé lue et la valeur du fichier doivent se rencontrer : c'est ce + que la panne empêchait.""" + from script.todo.version_manager import get_odoo_version + + versions, _installees, _actuelle = get_odoo_version() + marquees = [v for v in versions if v.get("default")] + self.assertEqual( + len(marquees), + 1, + "aucune entrée rendue par get_odoo_version ne porte « default »", + ) + + +if __name__ == "__main__": + unittest.main() From f6cc0bfc537a2888eb3f09a02c5642952fb41af4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:19 -0400 Subject: [PATCH 03/14] [FIX] qemu install : attendre le verrou apt d'apt-daily et de l'agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A desktop install repeated « Could not get lock » for minutes, then could run on an index never refreshed. Disabling the apt-daily timer does not interrupt the apt-get it already started, so its service is stopped too. The guest agent is laid by a detached unit that runs its own apt-get update: « cloud-init status --wait » says done while that lock is still held, so the wait covers that unit. Retries come back every two seconds, DPkg::Lock::Timeout not covering the list lock, and an update that never succeeds says so at once. --- FR --- Une installation de bureau répétait « Impossible d'obtenir le verrou » pendant des minutes, puis pouvait tourner sur un index jamais rafraîchi. Désactiver le minuteur apt-daily n'interrompt pas l'apt-get qu'il a lancé : son service est donc arrêté aussi. L'agent invité est posé par une unité détachée qui fait son propre apt-get update : « cloud-init status --wait » dit « done » pendant que ce verrou est tenu, et l'attente couvre donc cette unité. La reprise repasse toutes les deux secondes, DPkg::Lock::Timeout ne couvrant pas le verrou des listes, et un update qui n'aboutit pas le dit sur-le-champ. Assisted-by: Claude Opus 5 --- script/todo/qemu_install.py | 39 ++++++++++- script/todo/todo_i18n.py | 8 +++ test/test_qemu_desktop_tools.py | 112 ++++++++++++++++++++++++++++++++ 3 files changed, 157 insertions(+), 2 deletions(-) diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 04da228..e3a56f6 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -415,7 +415,16 @@ class QemuInstallMixin: L'attente dure jusqu'à 15 min et n'écrivait RIEN : sur une architecture émulée, le log restait muet un quart d'heure juste après avoir annoncé le début de l'installation, ce qui se lit comme un blocage. Deux lignes - l'encadrent, et le « status » final dit si elle a abouti ou expiré.""" + l'encadrent, et le « status » final dit si elle a abouti ou expiré. + + « cloud-init status --wait » NE SUFFIT PAS. La pose de l'agent invité + est lancée en service DÉTACHÉ — « systemd-run --no-block » — pour que + cloud-init rende la main en quelques secondes ; ce service, lui, fait + un « apt-get update » puis une installation, et garde donc le verrou + des paquets bien après que cloud-init s'est dit terminé. L'étape + suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis + installait sur un index jamais rafraîchi : « Impossible de trouver le + paquet », un message qui n'accuse personne.""" return ( "if command -v cloud-init >/dev/null 2>&1; then " 'echo "== ' @@ -426,6 +435,14 @@ class QemuInstallMixin: + f'echo " {t("cloud-init:")} $(cloud-init status 2>/dev/null ' '| head -1)"; ' "fi; " + # Le service détaché de l'agent invité, s'il court encore. Le nom + # est celui que le déploiement lui donne ; « --collect » l'efface + # une fois fini, donc « is-active » redevient faux tout seul. + "if systemctl is-active --quiet erplibre-qga 2>/dev/null; then " + f'echo " {t("waiting for the guest agent install (apt lock)")}"; ' + "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; " + "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " + "fi; " ) @staticmethod @@ -468,6 +485,13 @@ class QemuInstallMixin: return "" return ( "if command -v apt-get >/dev/null 2>&1; then " + # Les SERVICES autant que les minuteurs. « disable --now » sur un + # minuteur l'empêche de repartir mais n'interrompt pas l'apt-get + # qu'il a DÉJÀ lancé : celui-ci garde /var/lib/apt/lists/lock + # jusqu'au bout de sa mise à jour, et l'installation qui suit + # répète « Impossible d'obtenir le verrou » pendant des minutes. + "sudo systemctl stop apt-daily.service apt-daily-upgrade.service " + ">/dev/null 2>&1 || true; " "sudo systemctl disable --now unattended-upgrades.service " "apt-daily.timer apt-daily-upgrade.timer " ">/dev/null 2>&1 || true; " @@ -669,8 +693,19 @@ class QemuInstallMixin: return ( f'echo "== {t("Installing the desktop (long):")} {label} =="; ' "if command -v apt-get >/dev/null 2>&1; then " + # « DPkg::Lock::Timeout » ne couvre PAS le verrou des listes : + # il ne vaut que pour celui de dpkg. « apt-get update » échoue + # donc en moins d'une seconde quand une tâche quotidienne le + # tient, et dormir dix secondes entre deux essais coûte des + # minutes à ne rien faire. On repasse plus souvent, et on rend la + # main dès que le verrou se libère. "n=0; until sudo apt-get -o DPkg::Lock::Timeout=120 update -qq; do " - "n=$((n+1)); [ $n -ge 30 ] && break; sleep 10; done; " + "n=$((n+1)); [ $n -ge 60 ] && " + # Le dire ICI. Sans cette ligne, l'installation continue sur un + # index jamais rafraîchi et échoue plus bas sur « Impossible de + # trouver le paquet », qui accuse le dépôt et non le verrou. + f'{{ echo " ⚠ {t("apt-get update never succeeded: the lock stayed held")}"; ' + "break; }; sleep 2; done; " "sudo DEBIAN_FRONTEND=noninteractive " "apt-get -o DPkg::Lock::Timeout=600 install -y " f"{de['apt']} {rem['apt']['packages']} " diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 8e6ec0d..f547221 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -5678,6 +5678,14 @@ TRANSLATIONS = { "fr": "fuseau non posé", "en": "timezone not set", }, + "waiting for the guest agent install (apt lock)": { + "fr": "attente de la pose de l'agent invité (verrou apt)", + "en": "waiting for the guest agent install (apt lock)", + }, + "apt-get update never succeeded: the lock stayed held": { + "fr": "apt-get update n'a jamais abouti : le verrou est resté pris", + "en": "apt-get update never succeeded: the lock stayed held", + }, "Timezone": { "fr": "Fuseau horaire", "en": "Timezone", diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index 2d9ba76..eda77d6 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -13,6 +13,7 @@ import pathlib import subprocess import sys import unittest +from unittest import mock sys.argv = ["todo.py"] from script.todo.todo import TODO # noqa: E402 @@ -1329,5 +1330,116 @@ class TestGnomeSiteExtensions(unittest.TestCase): self.assertIn("dbus-run-session", self.block) +class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase): + """« Impossible d'obtenir le verrou /var/lib/apt/lists/lock. Il est + occupé par le processus N (apt-get) », répété pendant des minutes au + début de chaque installation de bureau. + + Deux causes, et la première est la vraie : « disable --now » arrête un + MINUTEUR mais n'interrompt pas l'apt-get qu'il a déjà lancé, lequel garde + le verrou jusqu'au bout de sa mise à jour. + + La seconde est le rythme : « DPkg::Lock::Timeout » ne couvre pas ce + verrou-là — il ne vaut que pour celui de dpkg — si bien qu'apt rend la + main en moins d'une seconde, et qu'un sommeil de dix secondes entre deux + essais est du temps payé pour rien. + """ + + def setUp(self): + self.todo = TODO.__new__(TODO) + self.desarme = self.todo._qemu_no_auto_upgrade(prod=False) + self.cmd = self.todo._qemu_desktop_remote_cmd("gnome", "deb") + + def test_le_service_deja_lance_est_arrete_lui_aussi(self): + """C'est lui qui tient le verrou, pas le minuteur.""" + self.assertIn("stop apt-daily.service", self.desarme) + self.assertIn("apt-daily-upgrade.service", self.desarme) + + def test_les_minuteurs_restent_desarmes(self): + """Les arrêter sans les désactiver les laisserait repartir en + pleine installation.""" + self.assertIn("apt-daily.timer", self.desarme) + self.assertIn("apt-daily-upgrade.timer", self.desarme) + + def test_en_production_on_ne_touche_a_rien(self): + """Les correctifs de sécurité automatiques doivent rester actifs.""" + self.assertEqual(self.todo._qemu_no_auto_upgrade(prod=True), "") + + def test_la_boucle_repasse_souvent(self): + """Un essai coûte moins d'une seconde : dormir dix secondes entre + deux multiplie par cinq l'attente d'un verrou qui se libère.""" + i = self.cmd.index("until sudo apt-get") + boucle = self.cmd[i : self.cmd.index("done;", i)] + self.assertIn("sleep 2", boucle) + self.assertNotIn("sleep 10", boucle) + + def test_lattente_couvre_notre_propre_service_detache(self): + """Le vrai teneur du verrou, et c'est nous. + + La pose de l'agent invité part en service DÉTACHÉ pour que + cloud-init rende la main en quelques secondes. Ce service fait un + « apt-get update » puis une installation : « cloud-init status + --wait » dit « done » pendant qu'il tient encore le verrou, et + l'étape suivante épuise ses reprises pour rien. + """ + attente = self.todo._qemu_cloud_init_wait() + self.assertIn("erplibre-qga", attente) + self.assertIn("is-active", attente) + + def test_le_nom_du_service_est_celui_que_le_deploiement_donne(self): + """Deux noms qui divergent et l'attente ne trouve jamais rien.""" + from script.qemu import deploy_qemu + + src = pathlib.Path(deploy_qemu.__file__).read_text(encoding="utf-8") + self.assertIn("--unit=erplibre-qga", src) + + def test_un_update_qui_nabouti_pas_le_dit(self): + """Sans cette ligne, l'installation continue sur un index jamais + rafraîchi et échoue plus bas sur « Impossible de trouver le + paquet » — qui accuse le dépôt et non le verrou.""" + i = self.cmd.index("until sudo apt-get") + boucle = self.cmd[i : self.cmd.index("done;", i)] + self.assertIn("echo", boucle) + self.assertIn("⚠", boucle) + + def test_une_apostrophe_traduite_ne_casse_pas_la_commande(self): + """Les messages sont traduits, et le français est plein + d'apostrophes. Une seule mal placée casse la commande distante + ENTIÈRE : la VM ne dit alors pas pourquoi elle n'a rien fait. + + La langue n'est pas changée pour l'éprouver — « set_lang » la + PERSISTE dans env_var.sh, et un test qui la déplace fait échouer + tout ce qui suit. On remplace la traduction elle-même, le temps du + contrôle, par une chaîne qui porte le caractère dangereux. + """ + import tempfile + + piege = "l'agent n'a pas fini « attendre »" + with mock.patch("script.todo.qemu_install.t", lambda k: piege): + todo = TODO.__new__(TODO) + cmd = ( + todo._qemu_cloud_init_wait() + + todo._qemu_no_auto_upgrade(prod=False) + + todo._qemu_desktop_remote_cmd("gnome", "deb") + ) + with tempfile.NamedTemporaryFile( + "w", suffix=".sh", delete=False, encoding="utf-8" + ) as fh: + fh.write(cmd) + chemin = fh.name + res = subprocess.run( + ["bash", "-n", chemin], capture_output=True, text=True + ) + self.assertEqual(res.returncode, 0, res.stderr[:400]) + + def test_la_boucle_reste_bornee(self): + """Sans borne, un verrou jamais rendu tiendrait l'installation pour + toujours.""" + i = self.cmd.index("until sudo apt-get") + boucle = self.cmd[i : self.cmd.index("done;", i)] + self.assertRegex(boucle, r"-ge \d+ \]") + self.assertIn("break", boucle) + + if __name__ == "__main__": unittest.main() From ddde76f0cf158974f8674575b58684b15c09b0d1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:19 -0400 Subject: [PATCH 04/14] =?UTF-8?q?[FIX]=20qemu=20deploy=20:=20l'unit=C3=A9?= =?UTF-8?q?=20de=20l'agent=20invit=C3=A9=20ne=20finit=20plus=20en=20=C3=A9?= =?UTF-8?q?chec?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dans le script du service qui pose qemu-guest-agent, « && » et « || » se lisent à égalité de gauche à droite : une pose apt réussie enchaînait sur « dnf install », puis sur le « command -v » d'un gestionnaire absent, qui rend 127 sous dash. Le service finissait en échec après avoir posé l'agent. Chaque branche est désormais entre accolades. Vérifié : le script extrait de la vraie configuration cloud-init rend 0 avec apt-get, dnf ou pacman factice. --- EN --- In the script of the service installing qemu-guest-agent, "&&" and "||" have equal precedence, left to right: a successful apt install went on to "dnf install", then to the "command -v" of a missing manager, which returns 127 under dash. The service ended in failure after installing the agent. Each branch is now braced. Checked: the script extracted from the real cloud-init configuration returns 0 with a fake apt-get, dnf or pacman. Assisted-by: Claude Opus 5 --- script/qemu/deploy_qemu.py | 24 ++++++++----- test/test_qemu_desktop_tools.py | 61 +++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+), 9 deletions(-) diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 83c50b5..36629fd 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2537,10 +2537,9 @@ def build_cloud_config( " 2>/dev/null && break; done || true", # qemu-guest-agent : installé APRÈS sshd, et surtout HORS de cloud-init. # - # Mesuré sur Ubuntu 26.04 s390x : « apt-get install qemu-guest-agent » - # tire liburing2, ubuntu-helper-virt-hwe et ubuntu-virt depuis - # ports.ubuntu.com, et cloud-final tourne 9 min 47. Or le suivi - # d'installation attend « cloud-init status: done » : dix minutes + # Son installation tire plusieurs paquets de virtualisation, et sur une + # architecture émulée elle tient cloud-final une dizaine de minutes. Or + # le suivi d'installation attend « cloud-init status: done » : autant # d'attente pour un paquet accessoire, avant même de commencer le # travail utile. # @@ -2548,13 +2547,20 @@ def build_cloud_config( # en quelques secondes et l'agent apparaît quand il apparaît. On saute # aussi l'installation quand qemu-ga est déjà là, ce qui est le cas de # beaucoup d'images. Repli en ligne si systemd-run manque. + # + # Chaque branche de gestionnaire est entre accolades. Sans elles, « && » + # et « || » se lisent à égalité de gauche à droite : une pose apt + # réussie enchaîne sur « dnf install », puis sur le « command -v » + # d'un gestionnaire absent, qui rend 127 sous dash — et le service + # finit en échec après avoir posé l'agent. Le nom du service est celui + # qu'attend _qemu_cloud_init_wait, côté hôte. " - command -v qemu-ga >/dev/null 2>&1 ||" " systemd-run --no-block --unit=erplibre-qga --collect" - " /bin/sh -c 'command -v apt-get >/dev/null && { apt-get update -qq" - " || true; apt-get install -y qemu-guest-agent; }" - " || command -v dnf >/dev/null && dnf install -y qemu-guest-agent" - " || command -v pacman >/dev/null && pacman -Sy --noconfirm" - " qemu-guest-agent' 2>/dev/null" + " /bin/sh -c '{ command -v apt-get >/dev/null && { apt-get update -qq" + " || true; apt-get install -y qemu-guest-agent; }; }" + " || { command -v dnf >/dev/null && dnf install -y qemu-guest-agent; }" + " || { command -v pacman >/dev/null && pacman -Sy --noconfirm" + " qemu-guest-agent; }' 2>/dev/null" " || (command -v apt-get >/dev/null && (timeout 120 apt-get update -qq" " || true; timeout 300 apt-get install -y qemu-guest-agent)) ||" " (command -v dnf >/dev/null && timeout 300 dnf install -y" diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index eda77d6..362f9cd 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -1330,6 +1330,67 @@ class TestGnomeSiteExtensions(unittest.TestCase): self.assertIn("dbus-run-session", self.block) +class TestLeServiceDeLAgentInvite(unittest.TestCase): + """Le service détaché qui pose qemu-guest-agent, lancé par cloud-init. + + Son script essaie un gestionnaire de paquets après l'autre. Sans + accolades autour de chaque branche, une pose réussie enchaîne sur le + gestionnaire suivant, absent, et le service finit en échec — 127 sous + dash — alors que l'agent est posé. Le script est extrait de la vraie + configuration cloud-init, lue en YAML, et tourne dans un PATH où seul le + gestionnaire choisi existe, faux : aucun paquet n'est posé, aucun + service lancé. + """ + + def _script(self): + import shlex + + import yaml + + from script.qemu import deploy_qemu + + args = deploy_qemu.build_parser().parse_args( + ["--distro", "ubuntu", "--hostname", "vm"] + ) + doc = yaml.safe_load(deploy_qemu.build_cloud_config(args, None, [])) + ligne = next( + c + for c in doc["runcmd"] + if isinstance(c, str) and "--unit=erplibre-qga" in c + ) + mots = shlex.split(ligne) + # « systemd-run … /bin/sh -c '