[FIX] proxmox : apt-daily tient le verrou au démarrage
Trois pannes trouvées en LANÇANT la descente, aucune vue en la relisant — ni
par moi, ni par l'attaque adversariale.
Le premier « apt update » d'une image cloud échoue 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 — « 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 », que l'installeur
réglait pourtant déjà à 600 s : cette attente ne vaut que pour dpkg. On arrête
donc les minuteurs, puis on RÉESSAIE — arrêter une unité n'interrompt pas
l'apt-get déjà en vol. Le défaut touchait tout déploiement Proxmox, pas
seulement ce test.
Le premier étage n'avait pas d'alias ssh. « deploy_qemu.py » en ligne de
commande n'écrit pas d'entrée ~/.ssh/config — le menu le fait, la CLI non. La
descente aurait attendu son plein délai avant de conclure « jamais joignable »
sur une VM qui répondait à son adresse. Elle l'écrit maintenant elle-même,
depuis l'adresse résolue, et refuse d'avancer si la VM n'en a pas.
Et la réserve de l'hôte est proportionnelle. Quatre gigaoctets sur une machine
de soixante, c'était 6 % laissés au système : le jour où les invités touchent
vraiment leur mémoire, c'est l'hôte qui part en swap — et la mesure serait
celle du swap, pas de l'imbrication. Un huitième, avec le plancher d'avant
pour les petites machines.
Ce que la descente a établi en trois étages : 392 s, 644 s, 1120 s, soit 1,7
fois par étage. Puis la poignée de main ssh passe de 77 à 1664 secondes au
quatrième — vingt fois d'un seul cran. Le coude est là.
Et le mur que j'avais pris pour une limite d'imbrication n'en était pas une.
La VM qui gelait au quatrième étage avait douze vCPU ; celle-ci en a deux et
elle passe, en écrivant. C'était une limite de parallélisme SOUS imbrication —
exactement ce que l'algorithme borne, vérifié pour la première fois plutôt que
supposé.
--- EN ---
Three faults found by RUNNING the descent, none seen by reading it — neither
by me nor by the adversarial attack.
A cloud image's first "apt update" fails on a lock that is not the one you
expect. Measured one second after the first ssh:
E: Could not get lock /var/lib/apt/lists/lock.
It is held by process 1026 (apt-get)
It is not cloud-init — "status --wait" had returned. It is apt-daily, Debian's
timer, firing at boot. And the LISTS lock is not covered by
"DPkg::Lock::Timeout", which the installer already set to 600 s: that wait
only applies to dpkg. So we stop the timers, then RETRY — stopping a unit does
not interrupt the apt-get already in flight. The defect affected every Proxmox
deployment, not just this test.
The first level had no ssh alias. "deploy_qemu.py" on the command line does not
write a ~/.ssh/config entry — the menu does, the CLI does not. The descent
would have waited its full timeout before concluding "never reachable" about a
VM answering at its address. It now writes the entry itself, from the resolved
address, and refuses to proceed if the VM has none.
And the host's reserve is proportional. Four gigabytes on a sixty-gigabyte
machine left 6 % to the system: the day the guests really touch their memory,
the host swaps — and the measurement would be of swap, not of nesting. One
eighth now, keeping the old floor for small machines.
What the descent established over three levels: 392 s, 644 s, 1120 s — 1.7x per
level. Then the ssh handshake goes from 77 to 1664 seconds at the fourth:
twenty times in one step. That is the elbow.
And the wall I had taken for a nesting limit was not one. The VM that froze at
the fourth level had twelve vCPU; this one has two and it gets through,
writing. It was a limit of parallelism UNDER nesting — exactly what the
algorithm caps, verified for the first time rather than assumed.
Assisted-by: Claude Opus 5
(cherry picked from commit 7f86562cbd8f10017dcb88fe4272efc162cbccbc)
This commit is contained in:
parent
9bce1a8503
commit
adf0f275d4
6 changed files with 158 additions and 3 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
Loading…
Reference in a new issue