[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:
parent
76ea0a2707
commit
aa6774939f
5 changed files with 122 additions and 1 deletions
|
|
@ -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()}"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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() + "; "
|
||||
|
|
|
|||
|
|
@ -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é."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue