[FIX] LongTest : garder le VMID, le code des lectures, le journal PVE

Trois trouvailles de la relecture adversaire, toutes de la même famille : une
information qu'on possédait et qu'on jetait.

Le VMID ne remontait qu'au RETOUR de creer_enfant, qui enchaîne six commandes
sur le parent. Un échec à la quatrième — « qm resize » sur un stockage plein —
laissait une VM allumée et un disque alloué que le rapport ne nommait nulle
part : « --detruire » ne pouvait pas la défaire.

preparer_parent jetait le code de retour de ses lectures. Un « ip link show »
qui échoue se lisait « pas de pont », et de là on POSAIT un pont et un NAT sur
une machine qui en avait déjà un. Pour une lecture, le code de retour est la
seule chose qui distingue « j'ai lu, il n'y a rien » de « je n'ai pas pu lire ».

reparer_pmxcfs jetait le journal des unités PVE, que pve_unit_cmd joint exprès
à un échec. Il ne restait qu'un « /etc/pve : ABSENT » sans cause, à chercher
sur un hyperviseur mesuré 36 fois plus lent que son hôte.

Les trois meurent sous mutation, chacune avec son contrôle négatif.

--- EN ---

Three findings from the adversarial review, all the same family: information
we already held and threw away.

The VMID only surfaced on creer_enfant's RETURN, and that function chains six
commands on the parent. A failure at the fourth — "qm resize" on a full
storage — left a running VM and an allocated disk that the report named
nowhere: "--detruire" could not undo it.

preparer_parent discarded its reads' exit codes. An "ip link show" that fails
read as "no bridge", and from there we CREATED a bridge and a NAT on a machine
that already had one. For a read, the exit code is the only thing separating
"I read it, there is nothing" from "I could not read".

reparer_pmxcfs discarded the PVE units' journal, which pve_unit_cmd attaches
to a failure on purpose. All that remained was "/etc/pve : ABSENT" with no
cause, to be hunted on a hypervisor measured 36 times slower than its host.

All three die under mutation, each with its negative control.

Assisted-by: claude-opus-5
(cherry picked from commit 4c0279665e590169c23c4629e69ad945c4ae3fe4)
This commit is contained in:
Mathieu Benoit 2026-08-27 07:48:19 -04:00
parent 4d26155901
commit 178785b90a
2 changed files with 276 additions and 11 deletions

View file

@ -327,35 +327,72 @@ class Descente:
if code or "-KO" in pve.strip_ssh_noise(sortie):
self.dire(f" ✗ {etiquette}")
return False
# « pve_unit_cmd » joint le journal de l'unité à un échec — « la seule
# façon de dire la cause à quelqu'un dont le seul accès à l'hôte est
# cet outil », dit son propre commentaire. On le JETAIT : quand le
# montage échouait ensuite, il ne restait qu'un « /etc/pve : ABSENT »
# sans cause, et il fallait retourner sur la machine pour la chercher.
echecs = []
for unite in pve.PVE_UNITS:
self.executer(
code, sortie = self.executer(
hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite
)
propre = pve.strip_ssh_noise(sortie)
if code or "-KO" in propre:
echecs.append((unite, propre))
_c, out = self.executer(
hote, pve.mount_wait_cmd(), self.delai("reparation"), "montage"
)
vu = pve.parse_mount_wait(out)
self.dire(f" /etc/pve : {vu['verdict']}")
if vu["verdict"] != "MONTE":
for unite, propre in echecs:
self.dire(f" ↳ {unite} : {propre.strip()[-400:]}")
if not echecs:
# Toutes debout et le montage absent : le dire, plutôt que de
# laisser croire qu'on n'a pas regardé.
self.dire(" ↳ toutes les unités PVE sont debout")
return vu["verdict"] == "MONTE"
def preparer_parent(self, parent):
"""Stockage, pont et réseau interne du parent, ou None."""
_c, out = self.executer(
"""Stockage, pont et réseau interne du parent, ou None.
Les codes de retour des LECTURES sont regardés, et c'est tout le
sujet ici. Ailleurs dans ce dépôt un code de retour ne prouve rien —
celui d'une commande distante composée est celui du dernier maillon.
Mais pour une lecture, il est la SEULE chose qui distingue « j'ai lu,
il n'y a rien » de « je n'ai pas pu lire ».
La différence n'est pas académique : de l'absence de pont on
RECONFIGURE le réseau du parent. Un « ip link show » qui échoue — un
hoquet ssh, un sudo pas encore prêt — se lisait « pas de pont », et on
posait un pont et un NAT sur une machine qui en avait déjà un.
"""
code, out = self.executer(
parent,
"pvesm status --content images",
DELAIS["controle"],
"pvesm",
)
if code and not self.dry_run:
self.dire(" ✗ « pvesm status » a échoué : rien conclu")
return None
stockage = pve.pick_storage(pve.parse_storages(out))
if not stockage and not self.dry_run:
self.dire(" ✗ aucun stockage sur le parent")
return None
_c, out = self.executer(
code, out = self.executer(
parent,
"ip -o link show type bridge",
self.delai("controle"),
"ponts",
)
if code and not self.dry_run:
self.dire(
" ✗ liste des ponts illisible : on ne touche PAS au"
" réseau du parent"
)
return None
ponts = pve.parse_bridges(out)
if not ponts:
_c, nets = self.executer(
@ -444,8 +481,16 @@ class Descente:
)
return nom
def creer_enfant(self, parent, niveau, res, prepare):
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None)."""
def creer_enfant(self, parent, niveau, res, prepare, noter=None):
"""« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).
`noter` reçoit le VMID AVANT la première commande qui peut créer la
VM. Sans lui, le VMID ne remontait qu'au RETOUR : une création qui
échouait à la quatrième de ses six commandes — « qm resize » sur un
stockage plein, par exemple — laissait une VM allumée et un disque
alloué que le rapport ne nommait nulle part, donc que « --detruire »
ne pouvait pas défaire.
"""
stockage, pont, info_pont, dns = prepare
mod = module_qemu()
version = mod.DISTROS[DISTRO][1]
@ -501,6 +546,12 @@ class Descente:
DELAIS["controle"],
"clé",
)
# Le VMID est annoncé MAINTENANT. Un numéro noté pour une VM qui
# n'existera jamais ne coûte rien — « --detruire » lit « qm list » et
# la dit absente — alors qu'une VM créée et non notée reste sur le
# parent, invisible.
if noter:
noter(vmid)
for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds(
vmid, spec
):
@ -547,7 +598,15 @@ class Descente:
self.etages.append(etage)
self.interrompu = True
break
vmid, adresse = self.creer_enfant(parent, niveau, res, prepare)
def noter(numero, etage=etage, parent_alias=parent_alias):
etage["vmid"] = numero
etage["parent_alias"] = parent_alias
self._sauver(etage)
vmid, adresse = self.creer_enfant(
parent, niveau, res, prepare, noter
)
if vmid is None:
self.etages.append(etage)
self.interrompu = True

View file

@ -326,10 +326,14 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase):
appels = []
d.creer_etage1 = lambda res: "deep-pve-1"
d.preparer_parent = lambda parent: {"stockage": "local-lvm"}
d.creer_enfant = lambda parent, niveau, res, prep: (
100 + niveau,
f"10.10.10.{niveau}",
)
def creer_enfant(parent, niveau, res, prep, noter=None):
# Le VRAI ordre : le VMID est annoncé AVANT que la VM existe.
if noter:
noter(100 + niveau)
return 100 + niveau, f"10.10.10.{niveau}"
d.creer_enfant = creer_enfant
d.ecrire_alias = lambda *a, **k: None
d.attendre_ssh = lambda cible, delai, parent=None: 1
d.redemarrer_et_verifier = lambda cible: True
@ -557,6 +561,208 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase):
self.assertIn("descente tourne", sortie.getvalue())
class TestLaCauseDUnMontageAbsent(unittest.TestCase):
"""« pve_unit_cmd » joint le journal de l'unité à un échec — « la seule
façon de dire la cause à quelqu'un dont le seul accès à l'hôte est cet
outil », dit son propre commentaire dans proxmox_deploy.py.
reparer_pmxcfs le jetait. Quand le montage échouait ensuite, il ne restait
qu'un « /etc/pve : ABSENT » sans cause, et il fallait retourner sur la
machine pour la chercher — sur un hyperviseur imbriqué mesuré 36 fois plus
lent que son hôte."""
def setUp(self):
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
import deep_proxmox
self.dp = deep_proxmox
self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 3
self.vrai_run = deep_proxmox.pve.run
self.addCleanup(setattr, deep_proxmox.pve, "run", self.vrai_run)
deep_proxmox.pve.run = lambda h, c, t=None: (
0,
"10.0.0.2 22 10.0.0.1 22",
)
def _monter(self, verdict, unite_ko=None):
def executer(hote, cmd, delai, etiquette=""):
if etiquette == "montage":
return 0, f"MOUNT-{verdict}"
if unite_ko and etiquette == unite_ko:
return 1, "pmxcfs-KO\nquorum_initialize failed: 2"
return 0, "OK"
self.d.executer = executer
vrai = self.dp.pve.parse_mount_wait
self.dp.pve.parse_mount_wait = lambda out: {
"verdict": "MONTE" if "MONTE" in out else "ABSENT"
}
self.addCleanup(setattr, self.dp.pve, "parse_mount_wait", vrai)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
res = self.d.reparer_pmxcfs({"target": "h", "sudo": "sudo "})
return res, sortie.getvalue()
def test_a_failing_unit_is_named_with_its_journal(self):
unite = self.dp.pve.PVE_UNITS[0]
res, texte = self._monter("ABSENT", unite_ko=unite)
self.assertFalse(res)
self.assertIn(unite, texte)
self.assertIn("quorum_initialize failed", texte)
def test_all_units_up_and_still_no_mount_is_said_so(self):
# Le silence ici se lisait « on n'a pas regardé ».
res, texte = self._monter("ABSENT")
self.assertFalse(res)
self.assertIn("toutes les unités PVE sont debout", texte)
def test_a_successful_mount_stays_quiet(self):
"""Le contrôle NÉGATIF : ne pas déverser un journal quand tout va
bien. Un diagnostic qui sort toujours ne se lit plus."""
res, texte = self._monter("MONTE", unite_ko=self.dp.pve.PVE_UNITS[0])
self.assertTrue(res)
self.assertNotIn("↳", texte)
class TestUneLectureRateeNeConclutRien(unittest.TestCase):
"""De l'absence de pont, preparer_parent RECONFIGURE le réseau du parent.
Les codes de retour des lectures étaient jetés. Un « ip link show » qui
échoue — hoquet ssh, sudo pas encore prêt — se lisait « pas de pont », et
on posait un pont et un NAT sur une machine qui en avait déjà un."""
def setUp(self):
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
import deep_proxmox
self.dp = deep_proxmox
self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente)
self.d.dry_run = False
self.d.journal = None
self.d.niveau_courant = 2
def _descente_qui_lit(self, reponses):
"""`reponses` : [(code, sortie)] rendus dans l'ordre des lectures."""
faites = []
def executer(hote, cmd, delai, etiquette=""):
faites.append(etiquette or cmd[:20])
return reponses[len(faites) - 1] if reponses else (0, "")
self.d.executer = executer
return faites
def test_an_unreadable_bridge_list_touches_nothing(self):
faites = self._descente_qui_lit(
[
(0, "local-lvm lvmthin active 1 1 1 1.00%"),
(255, ""), # « ip link show » : échec de transport
]
)
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertIsNone(self.d.preparer_parent({"target": "p"}))
# Rien après la lecture ratée : pas de USED_NETS_CMD, pas de « pont ».
self.assertEqual(faites, ["pvesm", "ponts"])
self.assertIn("on ne touche PAS au réseau", sortie.getvalue())
def test_an_empty_but_successful_read_does_create_the_bridge(self):
"""Le contrôle NÉGATIF. Sans lui, ce garde-fou interdirait la seule
chose que preparer_parent est là pour faire."""
faites = self._descente_qui_lit(
[
(0, "local-lvm lvmthin active 1 1 1 1.00%"),
(0, ""), # lu, et il n'y a vraiment aucun pont
(0, ""), # USED_NETS_CMD
(0, "default via 10.0.0.1 dev eth0"),
]
+ [(0, "")] * 12
)
with contextlib.redirect_stdout(io.StringIO()):
self.d.preparer_parent({"target": "p"})
self.assertIn("réseaux", faites)
self.assertIn("pont", faites)
def test_an_unreadable_storage_list_is_named_as_such(self):
faites = self._descente_qui_lit([(255, "")])
with contextlib.redirect_stdout(io.StringIO()) as sortie:
self.assertIsNone(self.d.preparer_parent({"target": "p"}))
self.assertEqual(faites, ["pvesm"])
texte = sortie.getvalue()
self.assertIn("a échoué", texte)
# Et NON « aucun stockage » : ce serait imputer au parent un défaut
# qu'on n'a pas constaté.
self.assertNotIn("aucun stockage", texte)
class TestUneVmCreeeEstToujoursNommee(unittest.TestCase):
"""Le VMID ne remontait qu'au RETOUR de creer_enfant.
Or celle-ci enchaîne six commandes sur le parent. Un échec à la quatrième
— « qm resize » sur un stockage plein — laissait une VM allumée et un
disque alloué que le rapport ne nommait nulle part : « --detruire » ne
pouvait pas la défaire, et il fallait la retrouver par son NOM."""
def setUp(self):
sys.path.insert(0, os.path.join(RACINE, "LongTest"))
import deep_proxmox
self.dp = deep_proxmox
self.dossier = tempfile.mkdtemp(prefix="longtest-vmid-")
self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True)
def test_a_creation_that_dies_midway_still_names_the_vm(self):
niveaux = [
{"niveau": n, "vcpu": 2, "ram": 4096, "disque": 25} for n in (1, 2)
]
chemin = os.path.join(self.dossier, "rapport.json")
d = self.dp.Descente(
{"demandee": 2, "atteignable": 2, "niveaux": niveaux},
None,
False,
chemin,
)
d.creer_etage1 = lambda res: "deep-pve-1"
d.preparer_parent = lambda parent: (
"local-lvm",
"vmbr1",
{},
"1.1.1.1",
)
d.ecrire_alias = lambda *a, **k: None
d.attendre_ssh = lambda cible, delai, parent=None: 1
d.installer_proxmox = lambda cible: True
d.redemarrer_et_verifier = lambda cible: True
d.reparer_pmxcfs = lambda cible: True
# La création note son VMID, puis MEURT — comme « qm resize » sur un
# stockage plein.
def creer_enfant(parent, niveau, res, prep, noter=None):
noter(142)
return None, None
d.creer_enfant = creer_enfant
with contextlib.redirect_stdout(io.StringIO()):
d.parcourir()
with open(chemin, encoding="utf-8") as fh:
rapport = json.load(fh)
self.assertEqual(
self.dp.a_defaire(rapport),
[(2, "deep-pve-1", 142, self.dp.nom_etage(2))],
)
def test_the_vmid_is_announced_before_the_creating_commands(self):
"""Par l'ORDRE, pas par le résultat : noter après la première commande
laisserait déjà passer un « qm create » réussi suivi d'un échec."""
import inspect
src = inspect.getsource(self.dp.Descente.creer_enfant)
i_noter = src.index("noter(vmid)")
i_boucle = src.index("for cmd in [pve.image_fetch_cmd")
self.assertLess(i_noter, i_boucle)
class TestNePasAttendreUneMaisonDisparue(unittest.TestCase):
"""L'étage 1 a redémarré pendant l'installation de l'étage 4, éteignant
les étages 2, 3 et 4 d'un coup.