[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 from script.qemu.deploy_qemu import ( # noqa: E402
DISTRO_PKG, DISTRO_PKG,
DISTROS, DISTROS,
attente_cloud_final,
cache_env_reload, cache_env_reload,
distro_label, distro_label,
) )
@ -175,6 +176,9 @@ def avec_reprises(commande):
ATTENDRE_CLOUD_INIT = ( ATTENDRE_CLOUD_INIT = (
"if command -v cloud-init >/dev/null 2>&1; then" "if command -v cloud-init >/dev/null 2>&1; then"
" sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 || true; fi" " 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 : # 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. # sans les relire, un npm lancé sans sudo rejette l'autorité du cache.
f"; {cache_env_reload()}" 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]]: 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. """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 suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis
installait sur un index jamais rafraîchi : « Impossible de trouver le installait sur un index jamais rafraîchi : « Impossible de trouver le
paquet », un message qui n'accuse personne.""" 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 ( return (
"if command -v cloud-init >/dev/null 2>&1; then " "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; " "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; "
"do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; "
"fi; " "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 # Les variables du cache sont écrites par cloud-init PENDANT
# l'attente : cette session, ouverte avant, ne les a pas reçues. # l'attente : cette session, ouverte avant, ne les a pas reçues.
+ cache_env_reload() + "; " + cache_env_reload() + "; "

View file

@ -139,6 +139,69 @@ class TestLaCharge(unittest.TestCase):
i = QC.ATTENDRE_CLOUD_INIT.index("status --wait") i = QC.ATTENDRE_CLOUD_INIT.index("status --wait")
self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:]) 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): def test_la_charge_est_du_shell_valide(self):
"""Une instruction collée sans séparateur casse la commande entière, """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é.""" 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", "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): def test_le_nom_du_service_est_celui_que_le_deploiement_donne(self):
"""Deux noms qui divergent et l'attente ne trouve jamais rien.""" """Deux noms qui divergent et l'attente ne trouve jamais rien."""
from script.qemu import deploy_qemu from script.qemu import deploy_qemu