diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 2348805..9bd0d26 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -407,6 +407,23 @@ class Descente: if res_proc.returncode: self.dire(" ✗ la CLI QEMU/KVM a échoué") return None + # L'entrée ~/.ssh/config, que la CLI n'écrit PAS. Sans elle, + # « ssh deep-pve-1 » rend « Name or service not known » et la descente + # attendait son plein délai avant de conclure « jamais joignable » — + # sur une VM qui répondait parfaitement à son adresse. Vécu au premier + # lancement réel. + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + ip = todo._qemu_vm_ip_now(nom) + if not ip: + self.dire(f" ✗ {nom} créée mais sans adresse") + return None + self.dire(f" {nom} : {ip}") + prive = cle_publique()[:-4] if cle_publique() else None + todo._write_ssh_config_entry( + [nom], "erplibre", ip, identity_file=prive + ) return nom def creer_enfant(self, parent, niveau, res, prepare): diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 9cd1226..0b9e4b9 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -437,6 +437,33 @@ wait_cloud_init() { return 0 } +# Le premier « apt update » d'une image cloud tombe sur un verrou qui n'est +# pas celui qu'on croit. Mesuré, une seconde après le premier ssh : +# +# E: Could not get lock /var/lib/apt/lists/lock. +# It is held by process 1026 (apt-get) +# +# Ce n'est pas cloud-init — « cloud-init status --wait » avait rendu la main. +# C'est apt-daily, le minuteur de Debian, qui se déclenche au démarrage. Et le +# verrou des LISTES n'est pas couvert par « DPkg::Lock::Timeout », qui ne vaut +# que pour celui de dpkg : l'attente configurée ne s'applique donc pas ici. +# +# On arrête les minuteurs, puis on RÉESSAIE — arrêter une unité n'interrompt +# pas l'apt-get déjà en vol, et cloud-init peut en avoir un autre en route. +prepare_apt() { + run sudo systemctl stop apt-daily.service apt-daily-upgrade.service \ + apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1 || true + local i + for i in $(seq 1 12); do + if apt_get update; then + return 0 + fi + say " verrou apt tenu, nouvel essai dans 15 s (${i}/12)" + sleep 15 + done + die "apt update impossible : le verrou des listes reste tenu." +} + install_pve() { wait_cloud_init preseed_debconf @@ -453,7 +480,7 @@ install_pve() { # loin — pas même la désactivation, si elle attendait la fin. disable_enterprise say "\n---- apt update ----" - apt_get update + prepare_apt # Le noyau d'abord, comme l'amont le prescrit : c'est lui qui porte les # modules dont pve a besoin, et l'installer seul laisse une machine qui # redémarre proprement même si la suite échoue. diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index b17b429..26bb504 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -34,8 +34,12 @@ Deux nombres viennent de la même mesure, et méritent d'être dits : """ # Ce qu'on laisse à la machine physique : elle fait tourner l'orchestrateur, -# le menu TODO, et le premier QEMU. +# le menu TODO, et le premier QEMU. Un PLANCHER, complété par une part — +# quatre gigaoctets sur une machine de soixante, c'est 6 % laissés à l'hôte, +# et le jour où les invités touchent vraiment leur mémoire c'est l'hôte qui +# part en swap. La mesure serait alors celle du swap, pas de l'imbrication. HOTE_RESERVE_RAM_MO = 4096 +HOTE_RESERVE_PART = 8 # un huitième HOTE_RESERVE_DISQUE_GO = 20 # Ce qu'un étage garde pour lui avant de céder le reste. La RAM vient de @@ -78,7 +82,8 @@ def nesting_plan( # Arrondi au gibioctet inférieur : « --memory 25203 » marche, mais un # nombre rond se relit, se compare d'un étage à l'autre, et évite de # traîner les kibioctets du hasard de la mesure jusqu'au dixième étage. - ram = ((int(ram_dispo_mo) - HOTE_RESERVE_RAM_MO) // 1024) * 1024 + reserve = max(HOTE_RESERVE_RAM_MO, int(ram_dispo_mo) // HOTE_RESERVE_PART) + ram = ((int(ram_dispo_mo) - reserve) // 1024) * 1024 disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO niveaux, arret = [], "" # « max(1, …) » forçait un tour : profondeur 0 rendait un plan d'UN diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 1d4deff..86489cb 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -947,6 +947,68 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertIn("manage_etc_hosts:[[:space:]]*false", bloc) self.assertNotIn('[ -f "${fichier}" ]', bloc) + def test_the_first_apt_survives_the_boot_time_lock(self): + """Mesuré une seconde après le premier ssh d'une image cloud : + + E: Could not get lock /var/lib/apt/lists/lock. + It is held by process 1026 (apt-get) + + Ce n'est pas cloud-init — « status --wait » avait rendu la main. C'est + apt-daily, qui se déclenche au démarrage. Et le verrou des LISTES + n'est pas couvert par « DPkg::Lock::Timeout », qui ne vaut que pour + celui de dpkg.""" + self.assertIn("prepare_apt", self.src) + bloc = self.src[self.src.index("prepare_apt() {") :] + bloc = bloc[: bloc.index("\ninstall_pve()")] + self.assertIn("apt-daily", bloc) + # Arrêter le minuteur n'interrompt pas l'apt-get déjà en vol : il faut + # RÉESSAYER, pas seulement stopper. + self.assertIn("for i in", bloc) + self.assertIn("nouvel essai", bloc) + + def test_the_retry_loop_really_retries(self): + """Exécutée, apt_get bouchonné : elle doit insister puis rendre 0.""" + import re + import subprocess + + fonction = re.search( + r"^prepare_apt\(\) \{.*?^\}", self.src, re.M | re.S + ) + self.assertIsNotNone(fonction) + shell = ( + "say() { :; }; die() { exit 9; }; run() { :; }; sudo() { :; }; " + "sleep() { :; }; N=0; " + "apt_get() { N=$((N+1)); [ $N -ge 3 ] && return 0 || return 100; };" + + fonction.group(0) + + '\nprepare_apt && echo "ESSAIS $N"' + ) + res = subprocess.run( + ["bash", "-c", shell], capture_output=True, text=True, timeout=60 + ) + self.assertEqual(res.returncode, 0, res.stderr) + self.assertIn("ESSAIS 3", res.stdout) + + def test_the_retry_loop_gives_up_loudly(self): + # Une boucle qui abandonne en silence laisserait « apt update » échoué + # passer pour un succès. + import re + import subprocess + + fonction = re.search( + r"^prepare_apt\(\) \{.*?^\}", self.src, re.M | re.S + ) + shell = ( + "say() { :; }; die() { echo ABANDON; exit 9; }; run() { :; }; " + "sudo() { :; }; sleep() { :; }; apt_get() { return 100; };" + + fonction.group(0) + + "\nprepare_apt" + ) + res = subprocess.run( + ["bash", "-c", shell], capture_output=True, text=True, timeout=60 + ) + self.assertEqual(res.returncode, 9) + self.assertIn("ABANDON", res.stdout) + def test_the_mount_is_verified_not_assumed(self): self.assertIn("/etc/pve/.version", self.src) diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index cf976ea..3da0362 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -84,6 +84,31 @@ class TestLePlanDesEtages(unittest.TestCase): for n in plan["niveaux"]: self.assertGreaterEqual(n["disque"], nesting.DISQUE_MIN_GO) + def test_the_host_keeps_a_share_not_just_a_floor(self): + """Quatre gigaoctets sur une machine de soixante, c'est 6 % laissés à + l'hôte : le jour où les invités touchent vraiment leur mémoire, c'est + lui qui part en swap — et la mesure serait celle du swap, pas de + l'imbrication.""" + for dispo in (60000, 260000): + with self.subTest(dispo=dispo): + plan = nesting.nesting_plan( + 1, cpu_hote=28, ram_dispo_mo=dispo, disque_libre_go=500 + ) + reserve = dispo - plan["niveaux"][0]["ram"] + self.assertGreater(reserve, nesting.HOTE_RESERVE_RAM_MO) + self.assertGreaterEqual( + reserve, dispo // nesting.HOTE_RESERVE_PART + ) + + def test_a_small_host_keeps_the_floor(self): + # Sur une petite machine, la part serait dérisoire : le plancher tient. + plan = nesting.nesting_plan( + 1, cpu_hote=4, ram_dispo_mo=16384, disque_libre_go=200 + ) + self.assertEqual( + 16384 - plan["niveaux"][0]["ram"], nesting.HOTE_RESERVE_RAM_MO + ) + def test_a_depth_of_zero_asks_for_nothing(self): for profondeur in (0, -1, -7): with self.subTest(profondeur=profondeur): diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 9c27cb6..7ad9c73 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -144,6 +144,25 @@ class TestLEssaiABlanc(unittest.TestCase): ligne.startswith("bash "), f"lancé par autre chose : {ligne}" ) + def test_the_first_level_gets_an_ssh_entry(self): + """La CLI QEMU/KVM n'écrit PAS d'entrée ~/.ssh/config. + + Sans elle, « ssh deep-pve-1 » rend « Name or service not known » et la + descente attendait son plein délai avant de conclure « jamais + joignable » — sur une VM qui répondait parfaitement à son adresse. + Trouvé au premier lancement réel, pas par l'attaque.""" + import inspect + import sys as _sys + + _sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + src = inspect.getsource(deep_proxmox.Descente.creer_etage1) + self.assertIn("_write_ssh_config_entry", src) + self.assertIn("_qemu_vm_ip_now", src) + # Et une VM sans adresse n'est pas déclarée prête. + self.assertIn("créée mais sans adresse", src) + def test_the_dry_run_claims_nothing_reached(self): """Le rapport d'un essai à blanc était indiscernable d'une réussite — JSON compris — et « --detruire » s'en servait."""