From aa6774939f98f5a41f304da33d74bfcbc25935b8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20d=C3=A9ploiement=20qemu=20:=20attendre?= =?UTF-8?q?=20l'=C3=A9tape=20finale=20de=20cloud-init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « cloud-init status --wait » rend la main dès que cloud-init se déclare en erreur — un module accessoire y suffit — alors que son étape finale écrit encore l'autorité du cache, les variables et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans ces variables, PAM ne relisant plus le fichier, et sudo n'a alors rien à conserver : l'installation lancée par sudo rejette le certificat du cache. L'attente guette désormais l'unité cloud-final, et son seul état « activating » : ce oneshot reste ACTIF une fois fini, si bien qu'« is-active » y ferait attendre la borne entière pour rien. --- EN --- « cloud-init status --wait » returns as soon as cloud-init declares itself in error — an accessory module is enough — while its final stage still writes the cache authority, the variables and the sudoers file. A session opened in that second lives without those variables, PAM never rereading the file, and sudo then has nothing to keep: an install run by sudo rejects the cache certificate. The wait now watches the cloud-final unit, and only its « activating » state: this oneshot stays ACTIVE once finished, so « is-active » would wait the whole bound for nothing. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 4 +++ script/qemu/deploy_qemu.py | 29 +++++++++++++++ script/todo/qemu_install.py | 10 +++++- test/test_qemu_cache_charge.py | 63 +++++++++++++++++++++++++++++++++ test/test_qemu_desktop_tools.py | 17 +++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index b259f17..9e1edba 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -67,6 +67,7 @@ from script.qemu import cache_offline # noqa: E402 from script.qemu.deploy_qemu import ( # noqa: E402 DISTRO_PKG, DISTROS, + attente_cloud_final, cache_env_reload, distro_label, ) @@ -175,6 +176,9 @@ def avec_reprises(commande): ATTENDRE_CLOUD_INIT = ( "if command -v cloud-init >/dev/null 2>&1; then" " sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 || true; fi" + # « status --wait » rend la main dès que cloud-init se déclare en erreur, + # alors que son étape finale écrit encore : l'unité, elle, dit la vérité. + f"; {attente_cloud_final()}" # La session s'ouvre avant que cloud-init n'écrive les variables du cache : # sans les relire, un npm lancé sans sudo rejette l'autorité du cache. f"; {cache_env_reload()}" diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 2a5311d..d5ac905 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2853,6 +2853,35 @@ def cache_env_reload(fichier: str = "/etc/environment") -> str: ) +def attente_cloud_final(bornes: int = 150) -> str: + """Attend que l'ÉTAPE FINALE de cloud-init ait fini d'écrire. + + « cloud-init status --wait » rend la main dès que cloud-init se déclare en + ERREUR, et un module accessoire suffit à l'y mettre — une locale que + l'invité ne connaît pas, par exemple. Son étape finale, elle, continue : + c'est elle qui pose l'autorité du cache, les variables de /etc/environment + et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans + ces variables pour toute sa durée — PAM ne relit plus le fichier — et + « sudo » n'a alors rien à conserver, si bien qu'un npm lancé par sudo + rejette l'autorité du cache sur « self-signed certificate in certificate + chain ». + + L'unité tranche, mais PAS par « is-active » : cloud-final est un service + « oneshot » qui reste ACTIF une fois terminé — is-active y est vrai pour + toujours, et attendre là-dessus coûte la borne entière à chaque VM, sans + rien détecter. Seul « activating » dit que l'étape écrit encore. + + Bornée à `bornes` tours de deux secondes. Sans effet là où l'unité n'existe + pas : « show » y rend un état vide ou « inactive », jamais « activating » — + une image sans cloud-init, ou une VM déjà installée, n'attendent rien. + """ + return ( + 'n=0; while [ "$(systemctl show -p ActiveState --value' + ' cloud-final.service 2>/dev/null)" = activating ]; do n=$((n+1));' + f" [ $n -ge {bornes} ] && break; sleep 2; done" + ) + + def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: """Fichiers d'accueil de la VM : le guide de connexion, l'identité git. diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 97d9d2c..a556da1 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -425,7 +425,10 @@ class QemuInstallMixin: 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.""" - from script.qemu.deploy_qemu import cache_env_reload + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) return ( "if command -v cloud-init >/dev/null 2>&1; then " @@ -445,6 +448,11 @@ class QemuInstallMixin: "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; " "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " "fi; " + # « status --wait » rend la main dès que cloud-init se déclare en + # ERREUR, alors que son étape finale écrit encore l'autorité, les + # variables et le sudoers : attendre l'unité, faute de quoi elles + # arrivent après cette session, qui vivra sans elles. + + attente_cloud_final() + "; " # Les variables du cache sont écrites par cloud-init PENDANT # l'attente : cette session, ouverte avant, ne les a pas reçues. + cache_env_reload() + "; " diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 4e09504..3fdda3b 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -139,6 +139,69 @@ class TestLaCharge(unittest.TestCase): i = QC.ATTENDRE_CLOUD_INIT.index("status --wait") self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:]) + def test_lattente_suit_l_unite_et_non_le_seul_statut(self): + """« status --wait » rend la main dès que cloud-init se déclare en + erreur — un module accessoire y suffit — alors que son étape finale + écrit encore l'autorité du cache et les variables. Une session ouverte + à cette seconde-là vit sans elles, et « sudo » n'a rien à conserver : + « sudo npm » rejette alors le certificat du cache.""" + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) + + attente = QC.ATTENDRE_CLOUD_INIT + self.assertIn("cloud-final", attente_cloud_final()) + self.assertIn(attente_cloud_final(), attente) + self.assertLess( + attente.index(attente_cloud_final()), + attente.index(cache_env_reload()), + "les variables sont relues avant la fin de l'étape qui les écrit", + ) + + def test_lattente_de_l_unite_est_bornee_et_sans_effet_ailleurs(self): + """Une VM sans cloud-init ne doit pas payer l'attente, et une unité + qui ne finit jamais ne doit pas tenir la campagne indéfiniment.""" + from script.qemu.deploy_qemu import attente_cloud_final + + court = attente_cloud_final(3) + self.assertIn("[ $n -ge 3 ]", court) + self.assertIn("2>/dev/null", court) + + def test_lattente_guette_activating_et_non_is_active(self): + """cloud-final est un « oneshot » qui reste ACTIF une fois terminé : + « is-active » y est vrai pour toujours. Attendre là-dessus paie la + borne entière sur chaque VM — cinq minutes — sans jamais rien + détecter. Seul « activating » dit que l'étape écrit encore.""" + from script.qemu.deploy_qemu import attente_cloud_final + + attente = attente_cloud_final() + self.assertIn("activating", attente) + self.assertNotIn("is-active", attente) + + def test_lattente_de_l_unite_rend_la_main_sur_cette_machine(self): + """Jouée dans un vrai shell, hors VM : l'hôte n'a pas d'étape finale + en cours, et l'attente doit donc rendre la main tout de suite.""" + import subprocess + import time + + from script.qemu.deploy_qemu import attente_cloud_final + + debut = time.monotonic() + res = subprocess.run( + ["sh", "-c", attente_cloud_final()], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(0, res.returncode, res.stderr[-200:]) + self.assertLess( + time.monotonic() - debut, + 5, + "l'attente tourne alors que rien n'écrit : elle guette un état" + " qu'un service oneshot garde pour toujours", + ) + def test_la_charge_est_du_shell_valide(self): """Une instruction collée sans séparateur casse la commande entière, et la VM ne dit alors pas pourquoi elle n'a rien installé.""" diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index d9b96ba..ee91a10 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -1589,6 +1589,23 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase): "les variables sont relues avant que cloud-init les ait écrites", ) + def test_lattente_suit_l_unite_et_non_le_seul_statut(self): + """cloud-init se déclare en erreur pour un module accessoire, et + « status --wait » rend alors la main pendant que son étape finale + écrit encore l'autorité du cache et les variables.""" + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) + + attente = self.todo._qemu_cloud_init_wait() + self.assertIn(attente_cloud_final(), attente) + self.assertLess( + attente.index(attente_cloud_final()), + attente.index(cache_env_reload()), + "les variables sont relues avant la fin de l'étape qui les écrit", + ) + 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