[FIX] qemu install : attendre le verrou apt d'apt-daily et de l'agent

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
This commit is contained in:
Mathieu Benoit 2026-09-14 14:41:19 -04:00
parent 41bb10ab65
commit f6cc0bfc53
3 changed files with 157 additions and 2 deletions

View file

@ -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']} "

View file

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

View file

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