From f6cc0bfc537a2888eb3f09a02c5642952fb41af4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 14 Sep 2026 14:41:19 -0400 Subject: [PATCH] [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()