[FIX] déploiement qemu : attendre l'étape finale de cloud-init

« 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
This commit is contained in:
Mathieu Benoit 2026-09-16 03:00:57 -04:00
parent 76ea0a2707
commit aa6774939f
5 changed files with 122 additions and 1 deletions

View file

@ -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()}"

View file

@ -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.

View file

@ -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() + "; "

View file

@ -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é."""

View file

@ -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