From 7dc5df67c32671cfb154b2b67a1ea05719b3efe4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 03:46:57 -0400 Subject: [PATCH 01/26] [FIX] proxmox : le pont interne prenait l'adresse de sa propre passerelle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un Proxmox dans un Proxmox hérite du réseau interne de son parent : la VM vivait en 10.10.10.152, passerelle 10.10.10.1. Le pont interne, lui, avait son adresse CODÉE EN DUR à 10.10.10.1/24. Lui demander de la poser sur son propre pont, c'est prendre l'adresse de sa passerelle et rendre tout le /24 local. La machine s'isole au milieu de la commande qui la configure : « ifup » n'a jamais rendu la main, la VM ne répondait plus ni en ssh ni en ping. Le réseau est donc CHOISI, d'après ce que l'hôte connaît déjà — ses adresses et ses routes, car une route sans adresse locale suffit à créer le conflit, et la route par défaut en est l'exemple exact. Le chevauchement se calcule sur les réseaux et non sur les trois premiers octets : « 10.0.0.0/8 » écarte alors bien tous les candidats en 10.x. Plus aucun libre ? On le dit, plutôt que d'en écraser un — écraser, ici, c'est couper la seule voie d'accès. Le repli « ifreload -a » s'en va aussi. Il rechargeait TOUTES les interfaces, y compris celle qui porte la session, et sur une image cloud l'interface principale est décrite ailleurs — ifupdown2 la descend sans la remonter. Le repli monte maintenant le pont à la main, sans toucher à rien d'autre ; la strophe le rend persistant. La règle de masquerading se teste avant de s'ajouter, donc une reprise n'empile rien. Le test d'origine interdisait « 2>/dev/null » sur toute la ligne pour que l'erreur d'ifup reste lisible. L'intention est gardée, portée sur l'appel à ifup seul : le repli, lui, sonde légitimement. --- EN --- Proxmox inside Proxmox inherits its parent's internal network: the VM lived at 10.10.10.152, gateway 10.10.10.1. The internal bridge had its address HARDCODED to 10.10.10.1/24. Asking it to put that on its own bridge takes its gateway's address and makes the whole /24 local. The machine isolates itself in the middle of the command configuring it: "ifup" never returned, the VM answered neither ssh nor ping. The subnet is now CHOSEN from what the host already knows — its addresses and its routes, since a route with no local address is enough to collide, and the default route is exactly that case. Overlap is computed on networks rather than on the first three octets, so "10.0.0.0/8" correctly rules out every 10.x candidate. None left? We say so rather than overwrite one — overwriting here means cutting the only way in. The "ifreload -a" fallback goes too. It reloaded ALL interfaces, including the one carrying the session, and on a cloud image the main interface is described elsewhere — ifupdown2 takes it down without bringing it back. The fallback now raises the bridge by hand, touching nothing else; the stanza makes it persistent. The masquerade rule is checked before being added, so a retry piles nothing up. The original test banned "2>/dev/null" across the whole line so ifup's error stayed readable. That intent is kept, narrowed to the ifup call itself: the fallback legitimately probes. Assisted-by: Claude Opus 5 (cherry picked from commit 57991b186cf891b0db6b7228fb626c4c1af317cd) --- script/proxmox/proxmox_deploy.py | 84 +++++++++++++++++++++- script/todo/proxmox_menu.py | 41 +++++++++-- script/todo/todo_i18n.py | 4 ++ test/test_proxmox_deploy.py | 117 ++++++++++++++++++++++++++++++- 4 files changed, 238 insertions(+), 8 deletions(-) diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 12d1be2..6893f74 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -426,6 +426,69 @@ def pick_bridge(bridges, voulu: str = "") -> str: INTERNAL_BRIDGE = "vmbr0" INTERNAL_CIDR = "10.10.10.1/24" +# Le réseau interne ne peut PAS être une constante : un Proxmox dans un +# Proxmox hérite du réseau interne de son parent, et 10.10.10.1 y est +# l'adresse de sa propre PASSERELLE. La poser sur son pont rend tout le /24 +# local — la passerelle devient injoignable et la machine s'isole +# instantanément, au milieu de la commande qui la configure. Vécu : « ifup » +# n'a jamais rendu la main et la VM ne répondait plus, ni en ssh ni en ping. +# +# On choisit donc un /24 que l'hôte ne connaît pas encore. La liste va du plus +# attendu au plus improbable : un parc imbriqué descend d'un cran par étage. +INTERNAL_CANDIDATES = ( + "10.10.10.1/24", + "10.10.20.1/24", + "10.10.30.1/24", + "10.10.40.1/24", + "10.20.10.1/24", + "10.30.10.1/24", + "172.31.10.1/24", + "192.168.210.1/24", +) + +# Tout ce que l'hôte sait déjà d'IPv4 : ses adresses ET ses routes. Les deux, +# parce qu'une route sans adresse locale suffit à créer le conflit — la route +# par défaut « via 10.10.10.1 » en est l'exemple exact. +USED_NETS_CMD = "ip -o -4 addr show; ip -4 route show" + + +def parse_used_nets(text: str) -> set: + """Réseaux IPv4 lus dans la sortie de USED_NETS_CMD. + + Une adresse nue compte pour un /32 : c'est honnête, et le + chevauchement avec un /24 candidat se calcule pareil. Un préfixe plus + large qu'un /24 — « 10.0.0.0/8 » — écarte donc bien tous nos candidats + en 10.x, ce qu'un test sur les trois premiers octets aurait raté.""" + import ipaddress + + nets = set() + motif = r"\b(\d{1,3}(?:\.\d{1,3}){3})(?:/(\d{1,2}))?\b" + for adresse, prefixe in re.findall(motif, text or ""): + try: + nets.add( + ipaddress.ip_network( + f"{adresse}/{prefixe or 32}", strict=False + ) + ) + except ValueError: + continue + return nets + + +def pick_internal_cidr(text: str, candidats=INTERNAL_CANDIDATES) -> str: + """Le premier candidat qui ne chevauche RIEN de ce que l'hôte connaît. + + Chaîne vide quand tous sont pris : le dire, plutôt que d'en écraser un. + Écraser, ici, c'est couper la seule voie d'accès à la machine.""" + import ipaddress + + utilises = parse_used_nets(text) + for candidat in candidats: + reseau = ipaddress.ip_network(candidat, strict=False) + if not any(reseau.overlaps(u) for u in utilises): + return candidat + return "" + def parse_bridge_config(text: str) -> dict: """/etc/network/interfaces -> {pont: {ports, address}}. @@ -509,7 +572,26 @@ def bridge_setup_cmds( # Et l'erreur d'ifup n'est PAS masquée : « 2>/dev/null » cachait # « operation failed with 'Operation not supported' » — le noyau cloud n'a # pas le module bridge, et c'est ce qu'il fallait lire. - cmds.append(f"mkdir -p /run/network; ifup {nom} || ifreload -a") + # Et SURTOUT pas « ifreload -a » en repli : il recharge TOUTES les + # interfaces, y compris celle qui porte la session ssh, et sur une image + # cloud l'interface principale est décrite ailleurs (interfaces.d, ou + # netplan) — ifupdown2 la descend alors sans la remonter. Le repli est + # donc CHIRURGICAL : on monte le pont à la main, sans toucher à rien + # d'autre. La strophe, elle, le rend persistant au prochain démarrage. + manuel = [ + f"ip link show {nom} >/dev/null 2>&1 || ip link add {nom} type bridge", + f"ip addr add {cidr} dev {nom} 2>/dev/null || true", + f"ip link set {nom} up", + ] + if uplink: + regle = f"POSTROUTING -s {reseau} -o {uplink} -j MASQUERADE" + manuel.append( + f"iptables -t nat -C {regle} 2>/dev/null" + f" || iptables -t nat -A {regle}" + ) + cmds.append( + f"mkdir -p /run/network; ifup {nom} || {{ " + "; ".join(manuel) + "; }" + ) return cmds diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 41f62a3..9fbc126 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -648,6 +648,20 @@ class ProxmoxMenuMixin: parts = (sortie or "").split() return parts[parts.index("dev") + 1] if "dev" in parts else "" + def _pve_internal_cidr(self, host): + """Réseau du futur pont interne, CHOISI d'après l'hôte. + + Pas une constante : un Proxmox dans un Proxmox hérite du réseau + interne de son parent, et 10.10.10.1 y est l'adresse de sa propre + PASSERELLE. La poser sur son pont rend tout le /24 local, la + passerelle devient injoignable, et la machine s'isole au milieu de la + commande qui la configure. Vécu : « ifup » n'a jamais rendu la main et + la VM ne répondait plus, ni en ssh ni en ping.""" + from script.proxmox import proxmox_deploy as pve + + _c, out = pve.run(host, pve.USED_NETS_CMD, 40) + return pve.pick_internal_cidr(out) + def _pve_nat_ready(self, host): """(prêt ?, lignes à dire). La table NAT existe-t-elle sur cet hôte ? @@ -717,8 +731,11 @@ class ProxmoxMenuMixin: raison = self._pve_nat_reason(host) if raison: return "", raison + cidr = self._pve_internal_cidr(host) + if not cidr: + return "", t("No free subnet left for an internal bridge.") uplink = self._pve_uplink() - for cmd in pve.bridge_setup_cmds(uplink=uplink): + for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink): code, sortie = pve.run(host, cmd, 180) if code: lignes = pve.strip_ssh_noise(sortie).strip().splitlines() @@ -741,11 +758,20 @@ class ProxmoxMenuMixin: """ from script.proxmox import proxmox_deploy as pve + host = self._pve_host(ask=False) + # Le réseau est LU sur l'hôte avant d'être proposé : l'annoncer + # 10.10.10.1/24 pour en poser un autre serait mentir sur l'écran même + # où l'on demande l'accord. + cidr = self._pve_internal_cidr(host) if host else pve.INTERNAL_CIDR print(f"\n ⚠ {t('No network bridge on this host.')}") print(f" {t('qm create needs one. Two ways:')}") + if not cidr: + print(f" ✗ {t('No free subnet left for an internal bridge.')}") + print(f" {t('do it myself (bridge-ports , needs console)')}") + return "" print( f" [1] {t('create an internal')} {pve.INTERNAL_BRIDGE}" - f" ({pve.INTERNAL_CIDR}) + NAT — {t('touches no physical NIC')}" + f" ({cidr}) + NAT — {t('touches no physical NIC')}" ) print(f" [2] {t('do it myself (bridge-ports , needs console)')}") if input(t("Choice: ")).strip() != "1": @@ -759,7 +785,6 @@ class ProxmoxMenuMixin: f" ⚠ {t('This moves the host address: do it from a console.')}" ) return "" - host = self._pve_host(ask=False) ok, lignes = self._pve_nat_ready(host) if host else (True, []) if not ok: print() @@ -768,7 +793,7 @@ class ProxmoxMenuMixin: return "" uplink = self._pve_uplink() print(f" {t('uplink for NAT')} : {uplink or t('none')}") - for cmd in pve.bridge_setup_cmds(uplink=uplink): + for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink): code, _o = self._pve_show(cmd, timeout=120) if code: print(f" ✗ {t('Step failed, stopping here.')}") @@ -889,7 +914,13 @@ class ProxmoxMenuMixin: # De quoi créer le pont DEPUIS l'écran, sans invite : le pont # interne ne touche à aucune interface physique. "make_bridge": self._pve_make_internal_bridge, - "internal_bridge": (pve.INTERNAL_BRIDGE, pve.INTERNAL_CIDR), + # Le libellé « ➕ créer un interne vmbr0 (…) » doit annoncer le + # réseau qui sera RÉELLEMENT posé — il dépend de l'hôte. + "internal_bridge": ( + pve.INTERNAL_BRIDGE, + (self._pve_internal_cidr(host) if not ponts else "") + or pve.INTERNAL_CIDR, + ), "build_command": build_command, "branches": self._qemu_branch_list() or ["master"], # La branche du dépôt : c'est elle qu'on déploie le plus souvent. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 2eeee6a..c00adb5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3410,6 +3410,10 @@ TRANSLATIONS = { "fr": "Aucun noyau Proxmox installé : terminer l'installation d'abord.", "en": "No Proxmox kernel installed: finish the install first.", }, + "No free subnet left for an internal bridge.": { + "fr": "Plus aucun réseau libre pour un pont interne.", + "en": "No free subnet left for an internal bridge.", + }, "No network bridge on this host.": { "fr": "Aucun pont réseau sur cet hôte.", "en": "No network bridge on this host.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 1b37447..8285db3 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -241,8 +241,12 @@ class TestLeNoyau(unittest.TestCase): # ne monte jamais. montee = pve.bridge_setup_cmds("vmbr0", "10.10.10.1/24", "enp1s0")[-1] self.assertIn("mkdir -p /run/network", montee) - # Et l'erreur d'ifup n'est plus masquée : c'est elle qui explique. - self.assertNotIn("2>/dev/null", montee) + # Et l'erreur d'IFUP n'est pas masquée : c'est elle qui explique. + # Porté sur l'appel lui-même, et non sur toute la ligne : le repli qui + # suit sonde légitimement (« ip link show », « iptables -C »), et + # interdire « 2>/dev/null » partout lui interdisait d'exister. + ifup = montee[montee.index("ifup ") :].split("||")[0] + self.assertNotIn("2>", ifup) class TestLeDns(unittest.TestCase): @@ -673,5 +677,114 @@ class TestLaTableNat(unittest.TestCase): self.assertIn("uname -r", pve.NAT_CHECK_CMD) +class TestLeReseauDuPontInterne(unittest.TestCase): + """Le pont interne avait une adresse CODÉE EN DUR, 10.10.10.1/24. + + Un Proxmox dans un Proxmox hérite du réseau interne de son parent : la VM + vivait en 10.10.10.152 avec 10.10.10.1 pour PASSERELLE. Lui demander de + poser 10.10.10.1/24 sur son propre pont, c'est prendre l'adresse de sa + passerelle et rendre tout le /24 local — la machine s'isole au milieu de + la commande qui la configure. Vécu : « ifup » n'a jamais rendu la main, et + la VM ne répondait plus ni en ssh ni en ping.""" + + IMBRIQUE = ( + "2: eth0 inet 10.10.10.152/24 brd 10.10.10.255 scope global eth0\n" + "default via 10.10.10.1 dev eth0 onlink\n" + "10.10.10.0/24 dev eth0 proto kernel scope link src 10.10.10.152\n" + ) + + def test_a_nested_host_gets_another_subnet(self): + self.assertNotEqual( + pve.pick_internal_cidr(self.IMBRIQUE), "10.10.10.1/24" + ) + self.assertEqual( + pve.pick_internal_cidr(self.IMBRIQUE), "10.10.20.1/24" + ) + + def test_a_fresh_host_keeps_the_usual_one(self): + vierge = "1: lo inet 127.0.0.1/8 scope host lo\n" + self.assertEqual(pve.pick_internal_cidr(vierge), "10.10.10.1/24") + + def test_a_route_alone_is_enough_to_collide(self): + # Une route sans adresse locale suffit : c'est le cas exact de la + # route par défaut « via 10.10.10.1 ». + seule = "default via 10.10.10.1 dev eth0\n" + self.assertNotEqual(pve.pick_internal_cidr(seule), "10.10.10.1/24") + + def test_a_supernet_rules_out_everything_under_it(self): + # « 10.0.0.0/8 » couvre tous les candidats en 10.x. Un test sur les + # trois premiers octets l'aurait raté. + choisi = pve.pick_internal_cidr("10.0.0.0/8 dev x\n") + self.assertFalse(choisi.startswith("10."), choisi) + + def test_when_nothing_is_free_it_says_so(self): + tout = "\n".join( + c.replace("1/24", "0/24") for c in pve.INTERNAL_CANDIDATES + ) + self.assertEqual(pve.pick_internal_cidr(tout), "") + + def test_the_chosen_subnet_reaches_every_command(self): + cmds = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0") + texte = "\n".join(cmds) + self.assertIn("address 10.10.20.1/24", texte) + self.assertIn("10.10.20.0/24", texte) + self.assertNotIn("10.10.10.", texte) + + +class TestLeRepliQuiNeCoupePasLaLigne(unittest.TestCase): + """« ifreload -a » en repli rechargeait TOUTES les interfaces. + + Y compris celle qui porte la session ssh — et sur une image cloud + l'interface principale est décrite ailleurs (interfaces.d, netplan), donc + ifupdown2 la descend sans la remonter. Le repli monte donc le pont à la + main, sans toucher à rien d'autre.""" + + def test_ifreload_is_gone(self): + texte = "\n".join(pve.bridge_setup_cmds(uplink="eth0")) + self.assertNotIn("ifreload", texte) + + def test_the_fallback_builds_the_bridge_itself(self): + derniere = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0")[ + -1 + ] + self.assertIn("ifup vmbr0 ||", derniere) + self.assertIn("ip link add vmbr0 type bridge", derniere) + self.assertIn("ip addr add 10.10.20.1/24 dev vmbr0", derniere) + self.assertIn("ip link set vmbr0 up", derniere) + + def test_the_masquerade_rule_is_idempotent(self): + # « -C » avant « -A » : rejouée, la commande n'empile pas les règles. + derniere = pve.bridge_setup_cmds(uplink="eth0")[-1] + self.assertIn("iptables -t nat -C POSTROUTING", derniere) + self.assertLess( + derniere.index("-t nat -C"), derniere.index("-t nat -A") + ) + + def test_the_fallback_is_valid_shell(self): + """Exécuté pour de vrai, ip/iptables/ifup bouchonnés. + + Un repli qu'on ne sait pas exécuter s'ouvre le jour où il casse — et + celui-là tourne sur une machine qu'on ne peut plus joindre s'il rate. + """ + import subprocess + + derniere = pve.bridge_setup_cmds(cidr="10.10.20.1/24", uplink="eth0")[ + -1 + ] + bouchons = ( + 'ip() { [ "$1 $2" = "link show" ] && return 1; return 0; }\n' + "iptables() { return 1; }\n" + "ifup() { return 1; }\n" + "mkdir() { :; }\n" + ) + res = subprocess.run( + ["bash", "-c", bouchons + derniere], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(res.stderr, "", res.stderr) + + if __name__ == "__main__": unittest.main(verbosity=1) From 855acd8e61a891334182901a1635c783137f1ac0 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 04:30:48 -0400 Subject: [PATCH 02/26] [FIX] proxmox : pmxcfs sans adresse routable, et le stockage vide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rapporté sur un Proxmox imbriqué. « pvesm » ne parle qu'à travers /etc/pve, monté par pmxcfs ; pmxcfs à terre, la commande répond « Connection refused », la liste est vide, et l'écran s'arrête sur « aucun stockage » — trois étages au-dessus du défaut. pmxcfs ne démarrait pas parce que le nom d'hôte ne résolvait que vers 127.0.1.1, et il cherche une adresse ROUTABLE. L'installation corrige bien /etc/hosts, mais l'image cloud règle « manage_etc_hosts: True » : cloud-init le réécrit à CHAQUE démarrage. C'est le redémarrage désormais automatique qui l'a révélé — l'installation corrigeait, le reboot amorçait le bon noyau, et cloud-init défaisait la correction dans le même mouvement. Un fichier de surcharge le gèle. Deuxième geste manquant : systemd marque pve-cluster « failed » après cinq essais rapprochés et n'y revient JAMAIS seul. Corriger /etc/hosts ne suffisait donc pas ; l'installation relance l'unité et CONSTATE le montage plutôt que de le supposer. Et l'écran nomme maintenant la cause quand il n'a pas de stockage, plutôt que de laisser chercher. Un détail qui aurait fait un faux diagnostic : la sonde de montage n'interroge pas storage.cfg. Ce fichier N'EXISTE PAS sur une installation neuve — Proxmox se contente alors de ses stockages par défaut, et « local » répond parfaitement. Vérifié sur l'hôte : /etc/pve monté, storage.cfg absent, « pvesm status » rendant local avec 25 Go libres. C'est « .version », fichier virtuel de pmxcfs, qui fait foi. --- EN --- Reported on a nested Proxmox. "pvesm" only speaks through /etc/pve, mounted by pmxcfs; with pmxcfs down the command answers "Connection refused", the list is empty, and the screen stops at "no storage" — three floors above the defect. pmxcfs would not start because the hostname resolved only to 127.0.1.1, and it needs a ROUTABLE address. The installer does fix /etc/hosts, but the cloud image sets "manage_etc_hosts: True": cloud-init rewrites it at EVERY boot. The now-automatic reboot is what revealed it — the install fixed it, the reboot booted the right kernel, and cloud-init undid the fix in the same motion. An override file freezes it. Second missing step: systemd marks pve-cluster "failed" after five rapid attempts and never returns to it on its own. Fixing /etc/hosts was therefore not enough; the installer restarts the unit and VERIFIES the mount rather than assuming it. And the screen now names the cause when it has no storage, instead of leaving you to hunt. One detail that would have made a false diagnosis: the mount probe does not ask for storage.cfg. That file DOES NOT EXIST on a fresh install — Proxmox then uses its default storages, and "local" answers perfectly. Verified on the host: /etc/pve mounted, storage.cfg absent, "pvesm status" returning local with 25 GB free. It is ".version", a pmxcfs virtual file, that tells the truth. Assisted-by: Claude Opus 5 (cherry picked from commit 6212853048ba8154f2833744e45076d70bd33c72) --- script/proxmox/install_proxmox.sh | 75 ++++++++++++++++++++++++++ script/proxmox/proxmox_deploy.py | 46 ++++++++++++++++ script/todo/proxmox_menu.py | 46 ++++++++++++++++ script/todo/todo_i18n.py | 24 +++++++++ test/test_proxmox_deploy.py | 90 +++++++++++++++++++++++++++++++ 5 files changed, 281 insertions(+) diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 4bcbd7a..aef37b6 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -127,9 +127,48 @@ host_ip() { return 1 } +# Sans ceci, tout ce que fait fix_hosts est ANNULÉ au prochain démarrage. +# L'image cloud Debian règle « manage_etc_hosts: True » : cloud-init réécrit +# alors /etc/hosts depuis son gabarit à chaque boot, et y remet +# « 127.0.1.1 ». pmxcfs, qui cherche une adresse non-bouclage pour le +# nom d'hôte, ne démarre plus — /etc/pve n'est pas monté, « pvesm » répond +# « Connection refused », et l'écran de déploiement conclut « il manque le +# stockage ». Le vrai défaut est trois étages plus bas. +# +# Vécu, et révélé par le redémarrage désormais automatique : l'installation +# corrigeait /etc/hosts, le reboot amorçait le noyau Proxmox, et cloud-init +# défaisait la correction dans le même mouvement. +# +# Un fichier de surcharge plutôt qu'une édition de cloud.cfg : c'est la voie +# que cloud-init documente, et une mise à jour du paquet ne l'écrase pas. +freeze_cloud_hosts() { + local dossier=/etc/cloud/cloud.cfg.d + local fichier="${dossier}/99-erplibre-hosts.cfg" + [ -d /etc/cloud ] || return 0 + if [ -f "${fichier}" ]; then + say " cloud-init ne touche déjà plus à /etc/hosts" + return 0 + fi + say " cloud-init : gel de /etc/hosts (${fichier})" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} manage_etc_hosts: false" \ + "> ${fichier}" + return 0 + fi + sudo mkdir -p "${dossier}" + printf '%s\n' \ + "# Posé par ERPLibre : Proxmox exige que le nom d'hôte résolve vers" \ + "# une adresse ROUTABLE. cloud-init y remettait 127.0.1.1 à chaque" \ + "# démarrage, et pmxcfs ne démarrait plus." \ + "manage_etc_hosts: false" \ + | sudo tee "${fichier}" >/dev/null + CHANGED=1 +} + fix_hosts() { local ip fqdn short ip="$(host_ip)" || die "aucune adresse IPv4 routable : réseau absent ?" + freeze_cloud_hosts short="$(hostname -s)" fqdn="$(hostname -f 2>/dev/null || echo "${short}")" [ "${fqdn}" = "${short}" ] && fqdn="${short}.local" @@ -159,6 +198,42 @@ fix_hosts() { "« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \ "résout toujours pas vers une adresse routable." say " hostname --ip-address : $(printf '%s ' ${routables})" + revive_pmxcfs +} + +# pmxcfs abandonne après cinq essais rapprochés : systemd marque l'unité +# « failed » et n'y revient JAMAIS de lui-même — « Start request repeated too +# quickly ». Corriger /etc/hosts ne suffit donc pas ; sans ce coup de pouce, +# l'hôte reste sans /etc/pve, donc sans stockage, et l'écran de déploiement +# s'arrête sur « il manque le stockage ». +# +# « reset-failed » d'abord, sinon le démarrage est refusé sans même être tenté. +revive_pmxcfs() { + command -v systemctl >/dev/null 2>&1 || return 0 + [ -e /etc/pve/.version ] && return 0 + say " pve-cluster : /etc/pve n'est pas monté, relance" + if [ "${DRY}" = "1" ]; then + say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed" \ + "pve-cluster && systemctl start pve-cluster" + return 0 + fi + sudo systemctl reset-failed pve-cluster 2>/dev/null || true + if sudo systemctl start pve-cluster 2>&1; then + CHANGED=1 + fi + # Le montage n'est pas instantané : on le CONSTATE plutôt que de le + # supposer, et on le dit quand il n'arrive pas. + local i + for i in 1 2 3 4 5 6 7 8 9 10; do + [ -e /etc/pve/.version ] && break + sleep 1 + done + if [ -e /etc/pve/.version ]; then + say " ${Green}✓${Color_Off} /etc/pve monté" + else + say " ${Yellow}⚠${Color_Off} /etc/pve toujours absent :" \ + "journalctl -u pve-cluster -n 30" + fi } # --- 4. Dépôt et clé -------------------------------------------------------- diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 6893f74..d6eb108 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -282,6 +282,52 @@ def parse_qm_list(text: str) -> list: return out +# De quoi savoir POURQUOI il n'y a aucun stockage, en un aller-retour. +# +# « pvesm » ne parle qu'à travers /etc/pve, un système de fichiers monté par +# pmxcfs. pmxcfs à terre, la commande répond « Connection refused » et la liste +# est vide — l'écran conclut « il manque le stockage » alors que le défaut est +# trois étages plus bas. +CLUSTER_CHECK_CMD = ( + "systemctl is-active pve-cluster 2>/dev/null || true; " + "echo '---ERPLIBRE-PVE-FS---'; " + # « .version » et non « storage.cfg » : ce dernier N'EXISTE PAS sur une + # installation neuve — Proxmox se contente alors de ses stockages par + # défaut, et « local » répond parfaitement. Le tester revenait à déclarer + # /etc/pve absent sur un hôte sain. « .version » est un fichier virtuel de + # pmxcfs : il est là si et seulement si le montage est là. + "test -e /etc/pve/.version && echo MONTE || echo ABSENT; " + "echo '---ERPLIBRE-HOSTNAME-IP---'; " + "hostname --ip-address 2>/dev/null || true" +) + + +def parse_cluster_check(text: str) -> dict: + """{"actif": bool, "monte": bool, "adresses": [...]} depuis + CLUSTER_CHECK_CMD. + + `adresses` sans aucune adresse routable est la cause la plus fréquente : + pmxcfs parcourt les adresses du nom d'hôte jusqu'à en trouver une qui ne + soit pas de bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le + mène dans le mur.""" + brut = strip_ssh_noise(text or "") + tete, _, reste = brut.partition("---ERPLIBRE-PVE-FS---") + milieu, _, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") + adresses = [ + a + for a in queue.split() + if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a) or ":" in a + ] + return { + "actif": "active" in tete and "inactive" not in tete, + "monte": "MONTE" in milieu, + "adresses": adresses, + "routables": [ + a for a in adresses if not a.startswith("127.") and a != "::1" + ], + } + + def parse_storages(text: str) -> list: """Sortie de « pvesm status --content images » -> [{name, type, avail}].""" out = [] diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 9fbc126..65ea910 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -648,6 +648,43 @@ class ProxmoxMenuMixin: parts = (sortie or "").split() return parts[parts.index("dev") + 1] if "dev" in parts else "" + def _pve_cluster_reason(self, host): + """Pourquoi il n'y a AUCUN stockage. Liste vide si tout va bien. + + « Il manque le stockage » est un symptôme, pas une cause : « pvesm » + ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à terre, la + liste est vide et l'écran s'arrête sur le symptôme — le défaut est + trois étages plus bas, et il a fallu lire un journal pour le trouver. + + La cause la plus fréquente sur une image cloud : le nom d'hôte ne + résout que vers 127.0.1.1. pmxcfs cherche une adresse NON-bouclage et + n'en trouve pas. Notre installeur corrige /etc/hosts, mais cloud-init + le réécrit à chaque démarrage — donc la correction ne survivait pas au + redémarrage que nous faisons maintenant nous-mêmes.""" + from script.proxmox import proxmox_deploy as pve + + _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) + etat = pve.parse_cluster_check(out) + if etat["monte"]: + return [] + lignes = [ + f"✗ {t('pve-cluster is down: /etc/pve is not mounted.')}", + f" {t('Without it pvesm answers nothing, hence no storage.')}", + ] + if not etat["routables"]: + lignes += [ + f" {t('The hostname only resolves to')}" + f" {' '.join(etat['adresses']) or '?'}" + f" — {t('pmxcfs needs a routable address.')}", + f" {t('cloud-init rewrites /etc/hosts at every boot.')}", + ] + conseil = t( + "replay install_proxmox.sh on the host: it fixes /etc/hosts" + " and stops cloud-init undoing it." + ) + lignes.append(f"→ {conseil}") + return lignes + def _pve_internal_cidr(self, host): """Réseau du futur pont interne, CHOISI d'après l'hôte. @@ -863,6 +900,12 @@ class ProxmoxMenuMixin: vms = self._pve_vms() _c, out = self._pve_show("pvesm status --content images", quiet=True) stockages = pve.parse_storages(out) + if not stockages: + # AVANT d'ouvrir l'écran : une fois Textual à l'affiche, ces + # lignes n'ont plus d'endroit où aller, et l'écran ne dirait que + # « aucun stockage ». + for ligne in self._pve_cluster_reason(host): + print(f" {ligne}") _c, out = self._pve_show("ip -o link show type bridge", quiet=True) ponts = pve.parse_bridges(out) if not ponts: @@ -1685,6 +1728,9 @@ class ProxmoxMenuMixin: pont = pve.pick_bridge(ponts) if not stockage: print(f"\n ✗ {t('No storage able to hold a VM disk.')}") + # Le symptôme ne suffit pas : dire la CAUSE quand on la connaît. + for ligne in self._pve_cluster_reason(host): + print(f" {ligne}") return if not pont and not dry_run: pont = self._pve_offer_bridge() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index c00adb5..20f5471 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3358,6 +3358,30 @@ TRANSLATIONS = { "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", "en": "Size (+10G to add, 40G for a target): ", }, + "pve-cluster is down: /etc/pve is not mounted.": { + "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", + "en": "pve-cluster is down: /etc/pve is not mounted.", + }, + "Without it pvesm answers nothing, hence no storage.": { + "fr": "Sans lui, pvesm ne répond rien — d'où l'absence de stockage.", + "en": "Without it pvesm answers nothing, hence no storage.", + }, + "The hostname only resolves to": { + "fr": "Le nom d'hôte ne résout que vers", + "en": "The hostname only resolves to", + }, + "pmxcfs needs a routable address.": { + "fr": "pmxcfs exige une adresse routable.", + "en": "pmxcfs needs a routable address.", + }, + "cloud-init rewrites /etc/hosts at every boot.": { + "fr": "cloud-init réécrit /etc/hosts à chaque démarrage.", + "en": "cloud-init rewrites /etc/hosts at every boot.", + }, + "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.": { + "fr": "rejouer install_proxmox.sh sur l'hôte : il corrige /etc/hosts et empêche cloud-init de le défaire.", + "en": "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.", + }, "No storage able to hold a VM disk.": { "fr": "Aucun stockage capable d'héberger un disque de VM.", "en": "No storage able to hold a VM disk.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 8285db3..94077e5 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -786,5 +786,95 @@ class TestLeRepliQuiNeCoupePasLaLigne(unittest.TestCase): self.assertEqual(res.stderr, "", res.stderr) +class TestPourquoiAucunStockage(unittest.TestCase): + """« Il manque le stockage » est un symptôme, pas une cause. + + « pvesm » ne parle qu'à travers /etc/pve, monté par pmxcfs. pmxcfs à + terre, la commande répond « Connection refused », la liste est vide, et + l'écran s'arrête sur le symptôme — le défaut est trois étages plus bas. + + Vécu sur un Proxmox imbriqué : le nom d'hôte ne résolvait que vers + 127.0.1.1, parce que cloud-init réécrit /etc/hosts à CHAQUE démarrage. Le + redémarrage désormais automatique défaisait donc la correction que + l'installation venait de poser.""" + + def _sortie(self, actif, monte, adresses): + return ( + f"{'active' if actif else 'inactive'}\n" + "---ERPLIBRE-PVE-FS---\n" + f"{'MONTE' if monte else 'ABSENT'}\n" + "---ERPLIBRE-HOSTNAME-IP---\n" + f"{' '.join(adresses)}\n" + ) + + def test_a_healthy_host(self): + lu = pve.parse_cluster_check( + self._sortie(True, True, ["10.10.10.152"]) + ) + self.assertTrue(lu["monte"]) + self.assertEqual(lu["routables"], ["10.10.10.152"]) + + def test_the_loopback_only_case(self): + lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"])) + self.assertFalse(lu["monte"]) + self.assertEqual(lu["routables"], []) + self.assertEqual(lu["adresses"], ["127.0.1.1"]) + + def test_the_probe_does_not_ask_for_storage_cfg(self): + """storage.cfg N'EXISTE PAS sur une installation neuve. + + Proxmox se contente alors de ses stockages par défaut, et « local » + répond parfaitement — mesuré sur l'hôte imbriqué, où /etc/pve était + monté sans ce fichier. Le tester revenait à déclarer /etc/pve absent + sur un hôte sain.""" + self.assertNotIn("storage.cfg", pve.CLUSTER_CHECK_CMD) + self.assertIn("/etc/pve/.version", pve.CLUSTER_CHECK_CMD) + + def test_inactive_is_not_read_as_active(self): + # « inactive » contient « active » : la naïveté coûterait un + # diagnostic inversé. + lu = pve.parse_cluster_check(self._sortie(False, False, [])) + self.assertFalse(lu["actif"]) + + +class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): + """Deux gestes que l'installation ne faisait pas, et sans lesquels elle + laissait un hôte inutilisable.""" + + @classmethod + def setUpClass(cls): + from pathlib import Path as P + + cls.src = P("script/proxmox/install_proxmox.sh").read_text( + encoding="utf-8" + ) + + def test_cloud_init_stops_rewriting_etc_hosts(self): + # Sans ce gel, tout ce que fait fix_hosts est ANNULÉ au prochain + # démarrage — celui que nous déclenchons nous-mêmes désormais. + self.assertIn("manage_etc_hosts: false", self.src) + self.assertIn("/etc/cloud/cloud.cfg.d", self.src) + self.assertIn("freeze_cloud_hosts", self.src) + + def test_a_failed_pmxcfs_is_revived(self): + # systemd marque l'unité « failed » après cinq essais rapprochés et + # n'y revient jamais seul : corriger /etc/hosts ne suffit pas. + self.assertIn("reset-failed pve-cluster", self.src) + self.assertIn("start pve-cluster", self.src) + + def test_the_mount_is_verified_not_assumed(self): + self.assertIn("/etc/pve/.version", self.src) + + def test_the_script_is_valid_shell(self): + import subprocess + + res = subprocess.run( + ["bash", "-n", "script/proxmox/install_proxmox.sh"], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, 0, res.stderr) + + if __name__ == "__main__": unittest.main(verbosity=1) From 98c2355ca09805f87826c8d76b7ea30ed38e7f95 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 05:05:39 -0400 Subject: [PATCH 03/26] =?UTF-8?q?[FIX]=20suivi=20:=20relev=C3=A9=20Proxmox?= =?UTF-8?q?=20squelettique,=20index=20par=20nom,=20=C3=A9tat=20terminal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une cause, deux symptômes. « /cluster/resources » est bâti par pvestatd ; celui-ci arrêté, l'hôte rend quand même une entrée par VM, mais SQUELETTIQUE — ni nom, ni mémoire, ni disque, et « status: unknown ». Le relevé était indexé par NOM : l'entrée disparaissait donc, la VM passait pour absente alors que l'hôte venait de la nommer, et trois tours plus tard 🗑. Comme « effacée » est un état TERMINAL, la ligne comptait pour finie — d'où « 1/1 terminées · 00:09 » sur une installation qui tournait. Le relevé est maintenant indexé par VMID, seul identifiant unique d'un hôte Proxmox, et la correspondance vers les noms se fait là où le manifeste est sous les yeux. Une entrée squelettique reste donc une VM présente, avec ce que l'hôte sait d'elle — sa taille occupée, que « du » donne par VMID. Reste à savoir pourquoi pvestatd était mort. Son journal le dit mot pour mot : « ipcc_send_rec failed: Connection refused » — pve-cluster absent, c'est-à-dire la panne /etc/hosts d'hier. Tous les services de Proxmox avaient échoué ensemble, et systemd n'y revient jamais seul. L'installation relançait le seul pve-cluster ; elle relance désormais l'ensemble, pve-cluster d'abord puisqu'il monte /etc/pve. Vérifié sur l'hôte : pvestatd relancé, et les colonnes passent de « - - - » à « 3.4G/4.0G, 2.3G/25G, 2.2G écrit ». --- EN --- One cause, two symptoms. "/cluster/resources" is built by pvestatd; with it stopped the host still returns one entry per VM, but SKELETAL — no name, no memory, no disk, and "status: unknown". Readings were indexed by NAME, so that entry vanished, the VM looked absent although the host had just named it, and three rounds later 🗑. Since "deleted" is a TERMINAL state the row counted as finished — hence "1/1 done · 00:09" on a running install. Readings are now indexed by VMID, a Proxmox host's only unique identifier, and the mapping to names happens where the manifest is at hand. A skeletal entry therefore stays a present VM, with whatever the host does know about it — its used size, which "du" reports per VMID. Why was pvestatd dead? Its journal says it verbatim: "ipcc_send_rec failed: Connection refused" — no pve-cluster, that is yesterday's /etc/hosts fault. All of Proxmox's services had failed together, and systemd never returns to them on its own. The installer restarted pve-cluster alone; it now restarts the whole set, pve-cluster first since it mounts /etc/pve. Verified on the host: pvestatd restarted, and the columns go from "- - -" to "3.4G/4.0G, 2.3G/25G, 2.2G written". Assisted-by: Claude Opus 5 (cherry picked from commit 3fb85f66842d4b0e9d6ae6446691229b31ef725a) --- script/proxmox/install_proxmox.sh | 47 ++++++++---- script/todo/qemu_install_monitor.py | 51 +++++++++---- test/test_proxmox_deploy.py | 25 +++++-- test/test_qemu_monitor_pve.py | 108 +++++++++++++++++++++++++--- 4 files changed, 188 insertions(+), 43 deletions(-) diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index aef37b6..2225365 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -198,29 +198,46 @@ fix_hosts() { "« hostname --ip-address » rend « ${vu:-rien} » : le nom d'hôte ne" \ "résout toujours pas vers une adresse routable." say " hostname --ip-address : $(printf '%s ' ${routables})" - revive_pmxcfs + revive_pve_services } -# pmxcfs abandonne après cinq essais rapprochés : systemd marque l'unité -# « failed » et n'y revient JAMAIS de lui-même — « Start request repeated too -# quickly ». Corriger /etc/hosts ne suffit donc pas ; sans ce coup de pouce, -# l'hôte reste sans /etc/pve, donc sans stockage, et l'écran de déploiement -# s'arrête sur « il manque le stockage ». +# Les services de Proxmox abandonnent après cinq essais rapprochés : systemd +# marque l'unité « failed » et n'y revient JAMAIS de lui-même — « Start request +# repeated too quickly ». Or ils ont TOUS échoué pendant que /etc/hosts était +# faux. Corriger le fichier ne suffit donc pas. # -# « reset-failed » d'abord, sinon le démarrage est refusé sans même être tenté. -revive_pmxcfs() { +# L'ordre compte : pve-cluster d'abord, il monte /etc/pve dont les autres +# dépendent. +# +# pvestatd n'est pas un luxe. C'est lui qui remplit « /cluster/resources » ; +# arrêté, l'hôte rend une entrée SQUELETTIQUE par VM — ni nom, ni mémoire, ni +# disque, et « status: unknown ». Le tableau de bord n'a alors aucune colonne +# vivante, et il a même pris cette entrée pour une VM disparue. +PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy pve-firewall" + +revive_pve_services() { command -v systemctl >/dev/null 2>&1 || return 0 - [ -e /etc/pve/.version ] && return 0 - say " pve-cluster : /etc/pve n'est pas monté, relance" + local unite etat casse="" + for unite in ${PVE_SERVICES}; do + systemctl list-unit-files "${unite}.service" >/dev/null 2>&1 || continue + etat="$(systemctl is-active "${unite}" 2>/dev/null || true)" + [ "${etat}" = "active" ] && continue + casse="${casse} ${unite}" + done + [ -n "${casse}" ] || return 0 + say " services à relancer :${casse}" if [ "${DRY}" = "1" ]; then - say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed" \ - "pve-cluster && systemctl start pve-cluster" + say " ${Yellow}[dry-run]${Color_Off} systemctl reset-failed puis" \ + "start :${casse}" return 0 fi - sudo systemctl reset-failed pve-cluster 2>/dev/null || true - if sudo systemctl start pve-cluster 2>&1; then + for unite in ${casse}; do + sudo systemctl reset-failed "${unite}" 2>/dev/null || true + sudo systemctl start "${unite}" 2>&1 || \ + say " ${Yellow}⚠${Color_Off} ${unite} :" \ + "journalctl -u ${unite} -n 30" CHANGED=1 - fi + done # Le montage n'est pas instantané : on le CONSTATE plutôt que de le # supposer, et on le dit quand il n'arrive pas. local i diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 57b52e4..8c58ba5 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1524,12 +1524,24 @@ def parse_odoo_probe(text: str) -> set: def parse_pvestats(text: str) -> dict: - """Sortie de PVE_STATS_CMD -> {nom: relevé}, même forme que domstats. + """Sortie de PVE_STATS_CMD -> {VMID: relevé}, même forme que domstats. - Même forme exprès : les colonnes, le débit d'écriture et la RAM se - calculent alors sans savoir d'où vient la mesure. Une VM sur un hôte - Proxmox distant n'avait aucune de ces colonnes — elles viennent de virsh, - qui ne sait rien de cet hôte. + Par VMID et non par NOM, et c'est tout le sujet. « /cluster/resources » + est bâti par pvestatd ; celui-ci arrêté, l'hôte rend quand même une entrée + par VM, mais SQUELETTIQUE : + + {"id":"qemu/100","node":"…","status":"unknown","type":"qemu", + "vmid":100} + + Ni nom, ni mémoire, ni disque. Indexée par nom, cette entrée disparaissait + — la VM était donc « absente du relevé » alors que l'hôte venait de la + nommer. Trois tours plus tard : 🗑, état TERMINAL, et le suivi annonçait + « 1/1 terminées » au bout de neuf secondes sur une installation qui + tournait. Le VMID, lui, est toujours là ; c'est d'ailleurs le seul + identifiant unique d'un hôte Proxmox. + + Même forme que domstats exprès : les colonnes, le débit d'écriture et la + RAM se calculent alors sans savoir d'où vient la mesure. """ brut, _, tailles = (text or "").partition("---ERPLIBRE-DU---") try: @@ -1547,14 +1559,15 @@ def parse_pvestats(text: str) -> dict: out = {} maintenant = time.time() for r in ressources if isinstance(ressources, list) else (): - nom = r.get("name") - if not nom: + vmid = int(r.get("vmid") or 0) + if not vmid: continue total = int(r.get("maxdisk") or 0) - utilise = int(r.get("disk") or 0) or occupe.get( - int(r.get("vmid") or 0), 0 - ) - out[nom] = { + utilise = int(r.get("disk") or 0) or occupe.get(vmid, 0) + out[vmid] = { + # Le nom reste DANS le relevé : il ne sert plus de clé, mais il + # aide à lire un journal quand les deux divergent. + "name": r.get("name") or "", "ram_used": int(r.get("mem") or 0), "ram_total": int(r.get("maxmem") or 0), # Le relevé vient d'être fait : il n'est pas périmé, et c'est ce @@ -1678,11 +1691,21 @@ def _read_pvestats(vms, now=None): # analysable. Rien d'autre, et surtout pas le code. if _resources_parsable(sortie): ok = True + # {VMID: relevé} -> {nom du manifeste: relevé}. La correspondance + # se fait ICI, où le manifeste est sous les yeux : lui seul dit + # quel VMID porte quel nom, et l'hôte peut très bien ne pas + # nommer ses VM (pvestatd arrêté). releves = parse_pvestats(sortie) ouverts = parse_odoo_probe(sortie) - for nom, rec in releves.items(): - rec["odoo"] = adresses.get(nom) in ouverts - stats.update(releves) + for vm in vms or (): + pve_info = vm.get("pve") or {} + if pve_info.get("target") != target: + continue + rec = releves.get(int(pve_info.get("vmid") or 0)) + if not rec: + continue + rec["odoo"] = adresses.get(vm["name"]) in ouverts + stats[vm["name"]] = rec _PVE_CACHE.update({"at": maintenant, "stats": stats, "ok": ok}) return dict(stats), ok diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 94077e5..0037cb5 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -856,11 +856,26 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertIn("/etc/cloud/cloud.cfg.d", self.src) self.assertIn("freeze_cloud_hosts", self.src) - def test_a_failed_pmxcfs_is_revived(self): - # systemd marque l'unité « failed » après cinq essais rapprochés et - # n'y revient jamais seul : corriger /etc/hosts ne suffit pas. - self.assertIn("reset-failed pve-cluster", self.src) - self.assertIn("start pve-cluster", self.src) + def test_every_failed_pve_service_is_revived(self): + """systemd marque l'unité « failed » après cinq essais rapprochés et + n'y revient jamais seul : corriger /etc/hosts ne suffit pas. + + Et ils ont TOUS échoué pendant que le fichier était faux — le journal + de pvestatd le dit mot pour mot : « ipcc_send_rec failed: Connection + refused », c'est-à-dire pve-cluster absent. Relancer le seul + pve-cluster laissait pvestatd mort, donc un hôte qui ne nomme même pas + ses VM.""" + self.assertIn("reset-failed", self.src) + for unite in ("pve-cluster", "pvestatd", "pvedaemon", "pveproxy"): + self.assertIn(unite, self.src, unite) + + def test_pve_cluster_comes_first(self): + # Il monte /etc/pve, dont les autres dépendent. + import re + + m = re.search(r'PVE_SERVICES="([^"]+)"', self.src) + self.assertIsNotNone(m) + self.assertEqual(m.group(1).split()[0], "pve-cluster") def test_the_mount_is_verified_not_assumed(self): self.assertIn("/etc/pve/.version", self.src) diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 3bffe23..19f66eb 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -37,9 +37,18 @@ class TestLaLecture(unittest.TestCase): def setUp(self): self.releves = mon.parse_pvestats(REPONSE) - def test_the_vm_is_keyed_by_its_name(self): - # Le suivi raisonne en NOMS : c'est ce que porte le manifeste. - self.assertEqual(list(self.releves), ["pve-suivi"]) + def test_the_vm_is_keyed_by_its_vmid(self): + """Par VMID, et c'est tout le sujet. + + « /cluster/resources » est bâti par pvestatd ; celui-ci arrêté, l'hôte + rend une entrée SQUELETTIQUE — ni nom, ni mémoire, ni disque, et + « status: unknown ». Indexée par nom, elle disparaissait : la VM + passait pour absente du relevé alors que l'hôte venait de la nommer. + Trois tours plus tard, 🗑 — état TERMINAL — et le suivi annonçait + « 1/1 terminées » au bout de neuf secondes. Vécu sur une VM Arch dans + un Proxmox imbriqué.""" + self.assertEqual(list(self.releves), [100]) + self.assertEqual(self.releves[100]["name"], "pve-suivi") def test_the_shape_matches_the_virsh_one(self): # Même forme exprès : `ram_pair`, `WriteWindow` et les colonnes @@ -52,10 +61,10 @@ class TestLaLecture(unittest.TestCase): "disk_used", "disk_total", } - self.assertTrue(attendus <= set(self.releves["pve-suivi"])) + self.assertTrue(attendus <= set(self.releves[100])) def test_the_measures_are_the_ones_the_host_gave(self): - rec = self.releves["pve-suivi"] + rec = self.releves[100] self.assertEqual(rec["ram_used"], 385351680) self.assertEqual(rec["ram_total"], 536870912) self.assertEqual(rec["wr_bytes"], 328233472) @@ -65,13 +74,13 @@ class TestLaLecture(unittest.TestCase): def test_a_zero_disk_falls_back_to_the_real_size(self): # Sur un stockage en fichiers, Proxmox NE CALCULE PAS la taille # occupée et rapporte 0 : la colonne aurait affiché « 0/4G ». - self.assertEqual(self.releves["pve-suivi"]["disk_used"], 4294971392) - self.assertEqual(self.releves["pve-suivi"]["disk_total"], 4294967296) + self.assertEqual(self.releves[100]["disk_used"], 4294971392) + self.assertEqual(self.releves[100]["disk_total"], 4294967296) def test_the_reading_is_fresh_so_the_ram_is_shown(self): # `ram_pair` refuse un relevé périmé : sans horodatage, la RAM d'une # VM distante ne s'afficherait jamais. - rec = self.releves["pve-suivi"] + rec = self.releves[100] self.assertNotEqual(mon.ram_pair(rec, rec["ram_at"]), "-") def test_garbage_yields_nothing_rather_than_raising(self): @@ -336,6 +345,87 @@ class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase): self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src) +class TestUnHoteQuiNeNommePasSesVm(unittest.TestCase): + """pvestatd arrêté, l'hôte rend une entrée SQUELETTIQUE par VM. + + Vécu sur une VM Arch dans un Proxmox imbriqué : + + {"id":"qemu/100","node":"…","status":"unknown","type":"qemu", + "vmid":100} + + Le nom manque, donc la VM passait pour absente du relevé — alors que + l'hôte venait de la nommer. Trois tours plus tard : 🗑, état TERMINAL, et + le suivi annonçait « 1/1 terminées » au bout de neuf secondes sur une + installation qui tournait. Une cause, deux symptômes.""" + + SQUELETTE = ( + '[{"id":"qemu/100","node":"n","status":"unknown","type":"qemu",' + '"vmid":100}]\n' + "---ERPLIBRE-DU---\n" + "2248339456\t/var/lib/vz/images/100/\n" + "---ERPLIBRE-ODOO---\n" + ) + + def _lit(self, sortie, nom="vm-arch", vmid=100): + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vm = { + "name": nom, + "pve": { + "target": "h", + "sudo": "", + "vmid": vmid, + "addr": "1.2.3.4", + }, + } + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(1, sortie) + ): + return mon.read_pvestats_detail([vm], now=50.0) + + def test_the_vm_is_still_found(self): + stats, ok = self._lit(self.SQUELETTE) + self.assertTrue(ok) + self.assertIn("vm-arch", stats, "trouvée par son VMID, pas par un nom") + + def test_an_unknown_status_is_not_a_deletion(self): + stats, _ok = self._lit(self.SQUELETTE) + etat = stats["vm-arch"]["state"] + self.assertEqual(mon.PVE_ETATS.get(etat, "running"), "running") + + def test_what_the_host_does_know_is_kept(self): + # Le « du » est indexé par VMID : la taille occupée survit même quand + # tout le reste manque. + stats, _ok = self._lit(self.SQUELETTE) + self.assertEqual(stats["vm-arch"]["disk_used"], 2248339456) + + def test_a_vmid_of_another_host_is_not_borrowed(self): + # Deux hôtes peuvent avoir un VMID 100. La correspondance ne vaut que + # pour les VM de CET hôte. + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vms = [ + { + "name": "ici", + "pve": {"target": "h", "sudo": "", "vmid": 100}, + }, + { + "name": "ailleurs", + "pve": {"target": "autre", "sudo": "", "vmid": 100}, + }, + ] + + # Le bouchon répond PAR HÔTE : sans cela, la même sortie servirait + # aux deux et le test ne prouverait rien. + def par_hote(info, _cmd, _timeout=40): + if info.get("target") == "h": + return 1, self.SQUELETTE + return 1, "[]\n---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n" + + with mock.patch("script.proxmox.proxmox_deploy.run", par_hote): + stats, _ok = mon.read_pvestats_detail(vms, now=60.0) + self.assertIn("ici", stats) + self.assertNotIn("ailleurs", stats) + + class TestTroisVmSurUnProxmox(unittest.TestCase): """Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une @@ -701,7 +791,7 @@ class TestLeDisque(unittest.TestCase): "1268518912\t/var/lib/vz/images/101/\n" "4294967296\t/var/lib/vz/images/999/\n" ) - rec = mon.parse_pvestats(texte)["vm-a"] + rec = mon.parse_pvestats(texte)[101] self.assertEqual(rec["disk_used"], 1268518912) self.assertEqual(rec["disk_total"], 6442450944) self.assertEqual( From 93b6256dbad0082ce7ddd6f53909aba29b4ecca4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 06:19:21 -0400 Subject: [PATCH 04/26] =?UTF-8?q?[ADD]=20d=C3=A9ploiement=20:=20la=20VM=20?= =?UTF-8?q?clone=20le=20d=C3=A9p=C3=B4t=20distant,=20pas=20ce=20checkout?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Le problème est revenu » — alors qu'il était corrigé la veille. La VM ne reçoit pas le checkout d'ici : elle CLONE la branche depuis le dépôt distant. Tout ce qui tourne dedans — install_proxmox.sh, les scripts d'installation, le Makefile — vient donc de là. Vécu deux fois de suite. Le correctif de /etc/hosts était commité ici, absent du distant : chaque VM déployée ensuite recevait l'ancien script, et le même défaut revenait à l'identique. Rien ne le disait, et il a fallu comparer les deux versions du fichier à la main pour comprendre. Soixante-et-onze commits séparaient les deux. L'écart est donc dit AVANT de déployer, là où l'on peut encore renoncer : le nombre, les trois premiers sujets, et « git push ». Sur les deux voies, car les deux clonent. Une branche que le distant ne connaît pas n'est pas un écart — c'est une question qui ne se pose pas. La dire quand même vaudrait un avertissement à chaque déploiement d'une branche neuve. --- EN --- "The problem came back" — though it had been fixed the day before. The VM does not receive this checkout: it CLONES the branch from the remote. Everything that runs inside it — install_proxmox.sh, the install scripts, the Makefile — comes from there. Twice in a row. The /etc/hosts fix was committed here and absent from the remote: every VM deployed afterwards got the old script, and the same defect returned unchanged. Nothing said so, and it took comparing both versions of the file by hand to understand. Seventy-one commits separated them. The gap is therefore stated BEFORE deploying, where you can still back out: the count, the first three subjects, and "git push". On both paths, since both clone. A branch the remote does not know is not a gap — it is a question that does not arise. Saying it anyway would mean a warning on every deployment of a new branch. Assisted-by: Claude Opus 5 (cherry picked from commit de27be5e736eb6e9bd01efd292e01c3b2231f91a) --- script/todo/proxmox_menu.py | 7 ++++ script/todo/qemu_deploy.py | 6 +++ script/todo/qemu_manage.py | 61 +++++++++++++++++++++++++++++ script/todo/todo_i18n.py | 16 ++++++++ test/test_proxmox_form.py | 76 +++++++++++++++++++++++++++++++++++++ 5 files changed, 166 insertions(+) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 65ea910..2c01844 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1483,6 +1483,13 @@ class ProxmoxMenuMixin: f" ERPLibre : {spec['install'].get('label') or ''}" f" ({spec['install'].get('branch')})" ) + # La VM CLONE la branche depuis le dépôt distant : tout ce qui + # tourne dedans — install_proxmox.sh compris — vient de là, pas + # d'ici. Un correctif non poussé est invisible pour elle. + for ligne in self._qemu_branch_gap_lines( + spec["install"].get("branch") or "" + ): + print(f" {ligne}") return self._is_yes_default_yes( input(f"\n{t('Deploy this VM now? (Y/n): ')}") ) diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 4d8c95a..9ea06f1 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -885,6 +885,12 @@ class QemuDeployMixin: f" {t('Install:')} {t('branch')} {br_txt}, " f"{t('profile')} {lb_txt}, {env}" ) + # La VM ne reçoit pas CE checkout : elle CLONE la branche depuis + # le dépôt distant. Un correctif commité ici et non poussé n'y est + # donc pas, et le défaut « revient » alors qu'il est corrigé — + # vécu deux fois de suite sur install_proxmox.sh. + for ligne in self._qemu_branch_gap_lines(br_txt): + print(f" {ligne}") else: print(f" {t('Install:')} {t('no')}") flavour = spec.get("desktop") diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 50f32b0..04f4694 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -2537,6 +2537,67 @@ class QemuManageMixin: nom = (res.stdout or "").strip() return "" if res.returncode or nom == "HEAD" else nom + @staticmethod + def _qemu_branch_gap(branche): + """Combien de commits LOCAUX manquent à origin/, et lesquels. + + Rend (nombre, [sujets]) — (0, []) quand il n'y a rien à dire, ou quand + la question ne se pose pas (pas de dépôt, pas de distant). + + Pourquoi le déploiement s'en soucie : la VM ne reçoit PAS le checkout + d'ici, elle CLONE la branche depuis le dépôt distant. Tout ce qui + tourne dans la VM — install_proxmox.sh, les scripts d'installation, le + Makefile — vient donc de là. + + Vécu deux fois de suite. Un correctif de install_proxmox.sh, commité + ici, absent du distant : chaque VM déployée ensuite recevait l'ancien + script, et le défaut « revenait » alors qu'il était corrigé. Rien ne + le disait ; il a fallu comparer les deux versions à la main. + """ + if not branche: + return 0, [] + try: + res = subprocess.run( + [ + "git", + "log", + "--oneline", + "--no-decorate", + f"origin/{branche}..HEAD", + ], + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return 0, [] + # Une branche inconnue du distant, ou aucun distant : ce n'est pas un + # écart à signaler, c'est une question qui ne se pose pas. + if res.returncode: + return 0, [] + sujets = [ + ligne.strip() + for ligne in (res.stdout or "").splitlines() + if ligne.strip() + ] + return len(sujets), sujets + + def _qemu_branch_gap_lines(self, branche, limite=3): + """Les lignes à dire avant de déployer, ou [].""" + nombre, sujets = self._qemu_branch_gap(branche) + if not nombre: + return [] + lignes = [ + f"⚠ {t('The VM clones')} origin/{branche}, " + f"{t('not this checkout.')}", + f" {nombre} {t('local commit(s) are missing there:')}", + ] + lignes += [f" {s}" for s in sujets[:limite]] + if nombre > limite: + lignes.append(f" … {nombre - limite} {t('more')}") + lignes.append(f" → git push {t('to deploy your own work.')}") + return lignes + def _qemu_branch_list(self): """Branches distantes d'ERPLibre, triées. Vide si le réseau manque. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 20f5471..73c72b5 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3382,6 +3382,22 @@ TRANSLATIONS = { "fr": "rejouer install_proxmox.sh sur l'hôte : il corrige /etc/hosts et empêche cloud-init de le défaire.", "en": "replay install_proxmox.sh on the host: it fixes /etc/hosts and stops cloud-init undoing it.", }, + "The VM clones": { + "fr": "La VM clone", + "en": "The VM clones", + }, + "not this checkout.": { + "fr": "et non ce checkout.", + "en": "not this checkout.", + }, + "local commit(s) are missing there:": { + "fr": "commit(s) local(aux) y manquent :", + "en": "local commit(s) are missing there:", + }, + "to deploy your own work.": { + "fr": "pour déployer votre propre travail.", + "en": "to deploy your own work.", + }, "No storage able to hold a VM disk.": { "fr": "Aucun stockage capable d'héberger un disque de VM.", "en": "No storage able to hold a VM disk.", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index bccd158..adbc7fc 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -686,6 +686,82 @@ class TestUnParcMixte(unittest.TestCase): self.assertEqual(vu["kw"]["desktop"], "") +class TestLaVmCloneLeDepotDistant(unittest.TestCase): + """« Le problème est revenu » — alors qu'il était corrigé. + + La VM ne reçoit pas le checkout d'ici : elle CLONE la branche depuis le + dépôt DISTANT. Tout ce qui tourne dedans — install_proxmox.sh, les + scripts d'installation, le Makefile — vient donc de là. Un correctif + commité ici et non poussé lui est invisible. + + Vécu deux fois de suite : la correction de /etc/hosts était dans le + checkout depuis la veille, absente du distant, et chaque VM déployée + ensuite recevait l'ancien script. Il a fallu comparer les deux versions à + la main pour le voir. Rien ne le disait.""" + + def _todo(self, sortie, code=0): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + faux = mock.Mock(returncode=code, stdout=sortie) + return todo, faux + + def test_the_gap_is_counted_and_named(self): + todo, faux = self._todo( + "abc1234 [FIX] un correctif\ndef5678 [ADD] autre chose\n" + ) + with mock.patch("subprocess.run", return_value=faux): + nombre, sujets = todo._qemu_branch_gap("develop") + self.assertEqual(nombre, 2) + self.assertIn("[FIX] un correctif", sujets[0]) + + def test_nothing_to_say_when_the_remote_is_up_to_date(self): + todo, faux = self._todo("") + with mock.patch("subprocess.run", return_value=faux): + self.assertEqual(todo._qemu_branch_gap("develop"), (0, [])) + self.assertEqual(todo._qemu_branch_gap_lines("develop"), []) + + def test_an_unknown_remote_branch_is_not_a_gap(self): + # « origin/xyz » inconnu fait échouer git : ce n'est pas un écart à + # signaler, c'est une question qui ne se pose pas. Le dire quand même + # serait un avertissement à chaque déploiement d'une branche neuve. + todo, faux = self._todo("", code=128) + with mock.patch("subprocess.run", return_value=faux): + self.assertEqual(todo._qemu_branch_gap("nouvelle"), (0, [])) + + def test_no_branch_asks_nothing(self): + todo, _faux = self._todo("") + self.assertEqual(todo._qemu_branch_gap(""), (0, [])) + + def test_the_long_list_is_trimmed_but_counted(self): + todo, faux = self._todo( + "\n".join(f"c{i} sujet {i}" for i in range(10)) + ) + with mock.patch("subprocess.run", return_value=faux): + lignes = todo._qemu_branch_gap_lines("develop", limite=2) + texte = " ".join(lignes) + self.assertIn("10", texte, "le nombre TOTAL doit rester lisible") + self.assertIn("8", texte, "et ce qui n'est pas montré, dit") + self.assertIn("git push", texte) + + def test_both_screens_say_it_before_deploying(self): + # L'avertissement ne vaut que là où on peut encore renoncer. + import inspect + + from script.todo.proxmox_menu import ProxmoxMenuMixin + from script.todo.qemu_deploy import QemuDeployMixin + + for fn in ( + ProxmoxMenuMixin._pve_confirm_spec, + QemuDeployMixin._qemu_print_recap, + ): + with self.subTest(fonction=fn.__name__): + self.assertIn("_qemu_branch_gap_lines", inspect.getsource(fn)) + + class TestLePontQuiNeMeneraitNullePart(unittest.TestCase): """Le pont NAT était écrit AVANT qu'on sache si le NAT existe. From e3138bea7e56338ee9bb81821955acd16d52922b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 06:31:30 -0400 Subject: [PATCH 05/26] =?UTF-8?q?[FIX]=20proxmox=20:=20ne=20pas=20d=C3=A9m?= =?UTF-8?q?arrer=20le=20pare-feu=20depuis=20l'ext=C3=A9rieur?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Une révision adversariale de la réparation à distance a rendu un constat que ses TROIS lentilles — réseau, systemd, shell — ont trouvé indépendamment : démarrer pve-firewall peut couper le ssh qui répare. Sa configuration vit dans /var/lib/pve-cluster/config.db, donc elle est invisible tant que /etc/pve n'est pas monté — c'est-à-dire exactement dans l'état qu'on répare. On appliquerait des règles qu'on ne peut pas lire, sur la seule voie d'accès à la machine. Il n'est pas nécessaire au but : le stockage et le suivi demandent pve-cluster et pvestatd, l'interface web pveproxy. Il repartira au prochain démarrage, quand /etc/pve sera monté à temps. Le retirer de la liste coûte donc rien et supprime le seul geste qui pouvait isoler un hôte. Deux autres constats de la même révision, également réels. Le gel de cloud-init gardait sur l'EXISTENCE du fichier. Or « printf … > » le TRONQUE avant d'écrire : une coupure au mauvais moment laisse zéro octet, et la garde annonce « déjà gelé » pour toujours. cloud-init continue de remettre 127.0.1.1 à chaque démarrage et le défaut redevient invisible — celui-là même que ce code existe pour supprimer. La garde porte maintenant sur le CONTENU. Et les adresses de lien-local passaient pour routables. Mesuré : « hostname --ip-address » peut ne rendre QUE des fe80::, et une APIPA en 169.254 passait le seul test « ne commence pas par 127. ». pmxcfs n'a alors rien d'utilisable, mais le diagnostic concluait l'inverse et renvoyait vers journalctl au lieu de /etc/hosts. Enfin « la sonde n'a pas répondu » n'est plus lu comme « rien n'est monté » : un dépassement de délai rend les mêmes vides, et on affirmait une cause qu'on n'avait pas constatée. --- EN --- An adversarial review of the remote repair produced one finding all THREE of its lenses — network, systemd, shell — reached independently: starting pve-firewall can cut the ssh doing the repair. Its configuration lives in /var/lib/pve-cluster/config.db, so it is invisible while /etc/pve is unmounted — exactly the state being repaired. We would apply rules we cannot read, over the machine's only way in. It is not needed for the goal: storage and monitoring need pve-cluster and pvestatd, the web interface pveproxy. It will come back at the next boot, when /etc/pve mounts in time. Removing it from the list costs nothing and removes the one gesture that could isolate a host. Two more findings from the same review, equally real. The cloud-init freeze guarded on the file's EXISTENCE. But "printf … >" TRUNCATES before writing: an ill-timed cut leaves zero bytes, and the guard then reports "already frozen" forever. cloud-init keeps putting 127.0.1.1 back at every boot and the defect becomes invisible again — the very one this code exists to remove. The guard now looks at the CONTENT. And link-local addresses counted as routable. Measured: "hostname --ip-address" can return ONLY fe80:: entries, and an APIPA 169.254 passed the lone "does not start with 127." test. pmxcfs then has nothing usable, yet the diagnosis concluded the opposite and pointed at journalctl instead of /etc/hosts. Finally "the probe did not answer" is no longer read as "nothing is mounted": a timeout returns the same emptiness, and we were asserting a cause we had not measured. Assisted-by: Claude Opus 5 (cherry picked from commit fa9fb729d82e8d1a8e4fb549cc8061c7281b5dcb) --- script/proxmox/install_proxmox.sh | 22 +++++++++- script/proxmox/proxmox_deploy.py | 50 +++++++++++++++------- script/todo/proxmox_menu.py | 9 ++++ script/todo/todo_i18n.py | 4 ++ test/test_proxmox_deploy.py | 70 +++++++++++++++++++++++++++++++ 5 files changed, 138 insertions(+), 17 deletions(-) diff --git a/script/proxmox/install_proxmox.sh b/script/proxmox/install_proxmox.sh index 2225365..9cd1226 100755 --- a/script/proxmox/install_proxmox.sh +++ b/script/proxmox/install_proxmox.sh @@ -145,7 +145,14 @@ freeze_cloud_hosts() { local dossier=/etc/cloud/cloud.cfg.d local fichier="${dossier}/99-erplibre-hosts.cfg" [ -d /etc/cloud ] || return 0 - if [ -f "${fichier}" ]; then + # Sur le CONTENU et non sur l'existence : « printf … > fichier » TRONQUE + # avant d'écrire. Une coupure au mauvais moment laisse un fichier de zéro + # octet, et une garde à l'existence annonce alors « déjà gelé » pour + # toujours — cloud-init continue de remettre 127.0.1.1 à chaque + # démarrage, et le défaut redevient invisible. Une redirection est de + # toute façon idempotente : il n'y a rien à protéger d'autre. + if grep -qE "^[[:space:]]*manage_etc_hosts:[[:space:]]*false" \ + "${fichier}" 2>/dev/null; then say " cloud-init ne touche déjà plus à /etc/hosts" return 0 fi @@ -213,7 +220,18 @@ fix_hosts() { # arrêté, l'hôte rend une entrée SQUELETTIQUE par VM — ni nom, ni mémoire, ni # disque, et « status: unknown ». Le tableau de bord n'a alors aucune colonne # vivante, et il a même pris cette entrée pour une VM disparue. -PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy pve-firewall" +# pve-firewall n'y est PAS, et c'est délibéré. Sa configuration vit dans +# /var/lib/pve-cluster/config.db, donc elle est invisible tant que /etc/pve +# n'est pas monté — c'est-à-dire exactement dans l'état qu'on répare. Le +# démarrer, c'est appliquer des règles qu'on ne peut pas lire sur la seule +# voie d'accès à la machine : ce script tourne au bout d'un ssh, et une VM +# imbriquée n'a pas d'autre porte. Une révision adversariale l'a classé +# « isole l'hôte » par trois lentilles indépendantes. +# +# Il n'est de toute façon pas nécessaire au but : le stockage et le suivi +# demandent pve-cluster et pvestatd, l'interface web pveproxy. Le pare-feu +# repartira au prochain démarrage, quand /etc/pve sera monté à temps. +PVE_SERVICES="pve-cluster pvestatd pvedaemon pveproxy" revive_pve_services() { command -v systemctl >/dev/null 2>&1 || return 0 diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index d6eb108..671c2d8 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -20,6 +20,7 @@ fonction PURE, vérifiable sans hôte Proxmox. Seul `run()` parle au réseau. """ from __future__ import annotations +import ipaddress import json import re import shlex @@ -302,29 +303,48 @@ CLUSTER_CHECK_CMD = ( ) +def _usable_address(adresse: str) -> bool: + """Cette adresse permet-elle à pmxcfs de s'identifier ? + + Ni bouclage, ni LIEN-LOCAL. Le lien-local est le piège : mesuré, + « hostname --ip-address » peut ne rendre QUE des fe80::, et une adresse + APIPA en 169.254 passait le seul test « ne commence pas par 127. ». Dans + les deux cas pmxcfs n'a rien d'utilisable, mais le diagnostic concluait + « le nom résout vers une adresse routable » — et renvoyait vers + journalctl au lieu de /etc/hosts, sur un hôte qu'on ne peut inspecter que + par ssh.""" + try: + adr = ipaddress.ip_address(adresse) + except ValueError: + return False + return not (adr.is_loopback or adr.is_link_local) + + def parse_cluster_check(text: str) -> dict: - """{"actif": bool, "monte": bool, "adresses": [...]} depuis + """{"actif", "monte", "adresses", "routables", "lu"} depuis CLUSTER_CHECK_CMD. - `adresses` sans aucune adresse routable est la cause la plus fréquente : - pmxcfs parcourt les adresses du nom d'hôte jusqu'à en trouver une qui ne - soit pas de bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le - mène dans le mur.""" + `routables` vide est la cause la plus fréquente : pmxcfs parcourt les + adresses du nom d'hôte jusqu'à en trouver une qui ne soit pas de + bouclage, et l'entrée « 127.0.1.1 » de l'image cloud le mène dans + le mur. + + `lu` dit si la sonde a RÉPONDU — les deux sentinelles sont là. Sans lui, + un simple dépassement de délai rendait « monte: False, adresses: [] », et + l'appelant affirmait « le nom d'hôte ne résout que vers ? » sans avoir + rien mesuré. Affirmer une cause qu'on n'a pas constatée est pire que se + taire : cela envoie réécrire /etc/hosts sur une machine peut-être + saine.""" brut = strip_ssh_noise(text or "") - tete, _, reste = brut.partition("---ERPLIBRE-PVE-FS---") - milieu, _, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") - adresses = [ - a - for a in queue.split() - if re.match(r"^\d{1,3}(\.\d{1,3}){3}$", a) or ":" in a - ] + tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---") + milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") + adresses = [a for a in queue.split() if a[:1].isdigit() or ":" in a] return { + "lu": bool(sep1 and sep2), "actif": "active" in tete and "inactive" not in tete, "monte": "MONTE" in milieu, "adresses": adresses, - "routables": [ - a for a in adresses if not a.startswith("127.") and a != "::1" - ], + "routables": [a for a in adresses if _usable_address(a)], } diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 2c01844..7be8e04 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -665,6 +665,15 @@ class ProxmoxMenuMixin: _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) etat = pve.parse_cluster_check(out) + # « La sonde n'a pas répondu » n'est PAS « rien n'est monté ». Un + # dépassement de délai — hostname bloqué sur un DNS injoignable — rend + # les mêmes vides, et on affirmait alors « le nom ne résout que vers + # ? » sans avoir rien mesuré. Affirmer une cause qu'on n'a pas + # constatée est pire que se taire. + if not etat["lu"]: + return [ + f"⚠ {t('The cluster probe did not answer: cause unknown.')}" + ] if etat["monte"]: return [] lignes = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 73c72b5..f76cb1e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3358,6 +3358,10 @@ TRANSLATIONS = { "fr": "Taille (+10G pour ajouter, 40G pour une cible) : ", "en": "Size (+10G to add, 40G for a target): ", }, + "The cluster probe did not answer: cause unknown.": { + "fr": "La sonde du cluster n'a pas répondu : cause inconnue.", + "en": "The cluster probe did not answer: cause unknown.", + }, "pve-cluster is down: /etc/pve is not mounted.": { "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "en": "pve-cluster is down: /etc/pve is not mounted.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 0037cb5..2eef09d 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -814,6 +814,46 @@ class TestPourquoiAucunStockage(unittest.TestCase): self.assertTrue(lu["monte"]) self.assertEqual(lu["routables"], ["10.10.10.152"]) + def test_a_probe_that_did_not_answer_says_so(self): + """« La sonde n'a pas répondu » n'est PAS « rien n'est monté ». + + Un dépassement de délai — hostname bloqué sur un DNS injoignable — + rend les mêmes vides. On affirmait alors « le nom ne résout que vers + ? » sans avoir rien mesuré, ce qui envoyait réécrire /etc/hosts sur + une machine peut-être saine.""" + self.assertFalse(pve.parse_cluster_check("timeout")["lu"]) + self.assertFalse(pve.parse_cluster_check("")["lu"]) + self.assertTrue( + pve.parse_cluster_check(self._sortie(True, True, ["10.0.0.1"]))[ + "lu" + ] + ) + + def test_a_link_local_address_is_not_routable(self): + """Mesuré : « hostname --ip-address » peut ne rendre QUE des fe80::. + + Le seul test « ne commence pas par 127. » les prenait pour routables, + et une APIPA en 169.254 aussi. pmxcfs n'a alors rien d'utilisable, + mais le diagnostic concluait l'inverse — et renvoyait vers journalctl + au lieu de /etc/hosts.""" + for adresses in ( + ["fe80::5054:ff:fecf:bba9", "fe80::fc54:ff:fe79:78a4"], + ["169.254.3.4"], + ["127.0.1.1"], + ): + with self.subTest(adresses=adresses): + lu = pve.parse_cluster_check( + self._sortie(False, False, adresses) + ) + self.assertEqual(lu["routables"], []) + self.assertEqual(lu["adresses"], adresses) + + def test_a_real_address_among_link_locals_still_counts(self): + lu = pve.parse_cluster_check( + self._sortie(True, True, ["10.10.10.152", "fe80::1"]) + ) + self.assertEqual(lu["routables"], ["10.10.10.152"]) + def test_the_loopback_only_case(self): lu = pve.parse_cluster_check(self._sortie(False, False, ["127.0.1.1"])) self.assertFalse(lu["monte"]) @@ -877,6 +917,36 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertIsNotNone(m) self.assertEqual(m.group(1).split()[0], "pve-cluster") + def test_the_firewall_is_never_started_from_outside(self): + """Le seul constat que trois lentilles ont trouvé indépendamment. + + La configuration de pve-firewall vit dans + /var/lib/pve-cluster/config.db : elle est donc INVISIBLE tant que + /etc/pve n'est pas monté — c'est-à-dire exactement dans l'état qu'on + répare. Le démarrer, c'est appliquer des règles qu'on ne peut pas lire + sur la seule voie d'accès à la machine ; ce script tourne au bout d'un + ssh, et une VM imbriquée n'a pas d'autre porte. + + Il n'est pas nécessaire au but : le stockage et le suivi demandent + pve-cluster et pvestatd, l'interface web pveproxy.""" + import re + + m = re.search(r'PVE_SERVICES="([^"]+)"', self.src) + self.assertIsNotNone(m) + self.assertNotIn("pve-firewall", m.group(1).split()) + + def test_the_freeze_is_guarded_on_content(self): + """« printf … > fichier » TRONQUE avant d'écrire. + + Une coupure au mauvais moment laisse zéro octet, et une garde à + l'EXISTENCE annonce « déjà gelé » pour toujours : cloud-init continue + de remettre 127.0.1.1 à chaque démarrage et le défaut redevient + invisible.""" + bloc = self.src[self.src.index("freeze_cloud_hosts() {") :] + bloc = bloc[: bloc.index("\nfix_hosts()")] + self.assertIn("manage_etc_hosts:[[:space:]]*false", bloc) + self.assertNotIn('[ -f "${fichier}" ]', bloc) + def test_the_mount_is_verified_not_assumed(self): self.assertIn("/etc/pve/.version", self.src) From 4bc2fa60975f478ed041cb87366714cbcb3f8a2a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 03:19:58 -0400 Subject: [PATCH 06/26] =?UTF-8?q?[ADD]=20proxmox=20:=20l'=C3=A9cran=20reme?= =?UTF-8?q?t=20pmxcfs=20debout=20lui-m=C3=AAme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le conseil « rejouer install_proxmox.sh sur l'hôte » ne pouvait PAS marcher : la VM clone le dépôt distant, donc sa copie du script est celle du distant — tant que le correctif n'y est pas, celle qui ne corrige rien. Trois hôtes de suite sont tombés dessus, avec le même message inutile. L'écran répare donc : gel de cloud-init, réécriture de /etc/hosts, relance des unités, constat du montage — le pendant exact de l'offre de créer un pont. Écrit, puis ATTAQUÉ par trois lentilles sur le code réel. Ce qu'elles ont mesuré valait la peine. /etc/hosts se réécrivait en DEUX écritures — « sed -i » puis « printf >> » — alors que la docstring promettait l'inverse. Sed refusé et ajout réussi, la ligne 127.0.1.1 survivait EN PREMIER et la nôtre s'ajoutait une fois par tentative ; sed réussi et ajout refusé, l'hôte perdait l'entrée de son nom, et chaque sudo y attendait ensuite le résolveur. C'est maintenant un fichier complet bâti dans un temporaire, VÉRIFIÉ, puis recopié — « cat > » et non « mv », qui remplacerait l'inode et perdrait mode et propriétaire. Le contrôle final s'en remettait à « getent hosts », qui réussit via mDNS même quand rien n'a été écrit — et acceptait les fe80:: que notre propre code rejette. Il relit désormais ce qui a été écrit. awk remplace sed pour filtrer : « print » émet un saut de ligne, donc un /etc/hosts non terminé par un — cloud-init n'en met pas — est normalisé. Sans ça notre ligne se collait à la précédente et le nom du nœud partait sur l'adresse d'une autre machine. Trois autres, du même acabit. Les dépendants de pmxcfs sont relancés eux aussi : actifs pendant la panne, ils échouaient sur ipcc_send_rec, et les laisser donnait une GUI en « communication failure » juste après notre ✓. Un silence du lien n'est plus lu comme une absence de montage. Et l'adresse n'est mise en cause que si pve-cluster a réellement démarré. Les tests exécutent les commandes au lieu de les relire, bouchons capables d'ÉCHOUER : écriture refusée, fichier sans saut de ligne final, tabulations, start qui rate, montage qui disparaît pendant la reconfirmation. Prouvé par mutation — trois HOSTS-KO changés en HOSTS-OK font rougir le test. --- EN --- The advice "replay install_proxmox.sh on the host" could NOT work: the VM clones the remote, so its copy of the script is the remote's — while the fix is not there, the one that fixes nothing. Three hosts in a row hit it with the same useless message. So the screen repairs: freeze cloud-init, rewrite /etc/hosts, restart the units, verify the mount — the exact counterpart of the offer to create a bridge. Written, then ATTACKED by three lenses on the real code. What they measured was worth it. /etc/hosts was rewritten in TWO writes — "sed -i" then "printf >>" — while the docstring promised the opposite. Sed refused and append succeeded: the 127.0.1.1 line survived FIRST and ours was added once per attempt; sed succeeded and append refused: the host lost its own name entry, and every sudo then waited on the resolver. It is now a complete file built in a temporary, VERIFIED, then copied over — "cat >" not "mv", which would replace the inode and lose mode and owner. The final check relied on "getent hosts", which succeeds via mDNS even when nothing was written — and accepted the fe80:: our own code rejects. It now re-reads what was written. awk replaces sed for filtering: "print" emits a newline, so an /etc/hosts with no final one — cloud-init omits it — gets normalised. Without that our line glued onto the previous one and the node's name pointed at another machine's address. Three more of the same kind. pmxcfs's dependents are restarted too: active throughout the outage, they failed on ipcc_send_rec, and leaving them gave a GUI in "communication failure" right after our ✓. A silent link is no longer read as a missing mount. And the address is only blamed if pve-cluster actually started. The tests execute the commands instead of reading them, with stubs able to FAIL: refused write, file with no final newline, tabs, a start that fails, a mount that vanishes during reconfirmation. Proven by mutation — three HOSTS-KO turned into HOSTS-OK make the test go red. Assisted-by: Claude Opus 5 (cherry picked from commit d4f9358c6cb562029cc2ca9eb478d80c6a0a19a4) --- script/proxmox/proxmox_deploy.py | 249 +++++++++++++++++- script/todo/proxmox_menu.py | 231 ++++++++++++++--- script/todo/todo_i18n.py | 60 +++++ test/test_proxmox_deploy.py | 432 +++++++++++++++++++++++++++++++ 4 files changed, 940 insertions(+), 32 deletions(-) diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 671c2d8..ab49347 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -338,7 +338,19 @@ def parse_cluster_check(text: str) -> dict: brut = strip_ssh_noise(text or "") tete, sep1, reste = brut.partition("---ERPLIBRE-PVE-FS---") milieu, sep2, queue = reste.partition("---ERPLIBRE-HOSTNAME-IP---") - adresses = [a for a in queue.split() if a[:1].isdigit() or ":" in a] + # Filtré sur ce qu'EST une adresse, pas sur sa ponctuation. run() colle + # stderr après stdout, donc tout ce que sudo écrit atterrit dans cette + # queue — et « sudo: unable to resolve host pve: … » se produit + # précisément dans la panne qu'on diagnostique. Mesuré : l'écran affichait + # « le nom d'hôte ne résout que vers 127.0.1.1 sudo: pve: ». Il affirmait + # des adresses là où la sonde n'avait rien mesuré. + adresses = [] + for jeton in queue.split(): + try: + ipaddress.ip_address(jeton) + except ValueError: + continue + adresses.append(jeton) return { "lu": bool(sep1 and sep2), "actif": "active" in tete and "inactive" not in tete, @@ -348,6 +360,241 @@ def parse_cluster_check(text: str) -> dict: } +# Marqueur de NOTRE ligne dans /etc/hosts. Il rend la réécriture exactement +# idempotente : on retire ce qui porte la marque, puis on ajoute. Sans lui, la +# garde devait s'indexer sur l'ADRESSE — et en DHCP une adresse qui change +# ajoutait une ligne de plus à chaque passage sans retirer la précédente. +HOSTS_MARK = "erplibre-hosts" + +# Services relancés par la réparation. pve-firewall n'y est PAS : sa +# configuration vit dans /var/lib/pve-cluster/config.db, invisible tant que +# /etc/pve n'est pas monté — c'est-à-dire exactement l'état qu'on répare. Le +# démarrer appliquerait des règles illisibles sur la seule voie d'accès à la +# machine. +# +# rrdcached d'abord : pve-cluster le requiert, et une limite de démarrage +# atteinte sur lui fait échouer pve-cluster sur « dependency » sans que +# reset-failed sur pve-cluster n'y change quoi que ce soit. +PVE_UNITS = ("rrdcached", "pve-cluster", "pvestatd", "pvedaemon", "pveproxy") + + +def ssh_server_ip(text: str) -> str: + """Adresse de l'hôte telle que NOTRE ssh l'atteint, depuis $SSH_CONNECTION. + + « client_ip client_port SERVER_ip server_port » : le troisième champ. C'est + la seule adresse dont on SAIT qu'elle mène à la machine, rebond compris. + + Les candidats habituels se trompent ici. Mesuré sur une Proxmox imbriquée : + « hostname -I » rend « 10.10.10.150 10.10.20.1 », et la seconde est le pont + interne que notre propre code vient de créer. La poser dans /etc/hosts + ferait s'identifier le nœud par une adresse que personne ne joint. + """ + champs = strip_ssh_noise(text or "").split() + return champs[2] if len(champs) >= 4 and _usable_address(champs[2]) else "" + + +# Deux sources pour les noms, dans cet ordre. La seconde est indispensable au +# REJEU : au second passage il n'y a plus de ligne 127.0.1.1 — c'est nous qui +# l'avons retirée — et sans elle un vrai FQDN était remplacé par +# « .local ». La commande n'était donc pas idempotente sur ce qu'elle +# avait elle-même préservé. Attrapé par un test qui la rejoue deux fois. +_NOMS_DEPUIS_LOOPBACK = ( + r"sed -nE 's/^[[:space:]]*127\.0\.1\.1[[:space:]]+([^#]*).*$/\1/p'" +) +_NOMS_DEPUIS_MARQUE = ( + r"sed -nE 's/^[^[:space:]]+[[:space:]]+([^#]*)#[[:space:]]*" + + HOSTS_MARK + + r"[[:space:]]*$/\1/p'" +) +# Normalise les séparateurs. L'installeur Debian écrit /etc/hosts avec des +# TABULATIONS, et le test du nom court cherchait des ESPACES : au rejeu, la +# ligne écrite gagnait un « srv » de plus. +_ROGNE = r"sed -E 's/[[:space:]]+/ /g; s/^ //; s/ $//'" + + +def hosts_repair_cmd(ip: str) -> str: + """UNE écriture ATOMIQUE de /etc/hosts, ou "" sans adresse utilisable. + + La première version promettait « une seule commande » et n'en tenait rien : + « sed -i » puis « printf >> » sont DEUX écritures, sans set -e et sans + retour en arrière. Une attaque adversariale l'a mesuré sur trois états + réels — /etc en lecture seule, fichier rendu immuable par chattr, quota + atteint : + + * sed refusé, ajout réussi -> la ligne 127.0.1.1 survit et reste PREMIÈRE, + donc gagnante, et notre ligne s'ajoute UNE FOIS PAR TENTATIVE. Le + marqueur, censé rendre l'opération idempotente, ne retirait rien puisque + c'est le sed qui portait la suppression. + * sed réussi, ajout refusé -> l'hôte n'a PLUS d'entrée pour son nom. Sur + une machine qu'on ne joint que par ssh, chaque sudo attend ensuite le + résolveur puis répond « unable to resolve host ». C'est exactement l'état + « pire qu'avant » que la docstring prétendait écarter. + + Donc : on construit le fichier ENTIER dans un temporaire du même + répertoire, on vérifie ce qu'il contient, et on ne le recopie qu'ensuite. + « cat > » et non « mv » : le renommage remplace l'inode et perdrait mode, + propriétaire et contexte SELinux de /etc/hosts. + + Bénéfice supplémentaire : « sed » sans -i ajoute le saut de ligne final + manquant. Sans lui, un /etc/hosts non terminé par \\n — cloud-init + « write_files » n'en met pas — voyait notre ligne se coller à la + précédente, et le nom du nœud partait sur l'adresse d'une AUTRE machine. + + POSIX seulement (dash), et aucun « sudo » dedans : c'est wrap_privilege + qui porte le privilège, et sur un hôte root@ il n'enrobe rien. + """ + if not _usable_address(ip): + return "" + tmp = "/etc/hosts.erplibre.$$" + return ( + "short=$(hostname -s); " + f"noms=$({_NOMS_DEPUIS_LOOPBACK} /etc/hosts | head -1 | {_ROGNE}); " + f'[ -n "$noms" ] || noms=$({_NOMS_DEPUIS_MARQUE} /etc/hosts' + f" | head -1 | {_ROGNE}); " + '[ -n "$noms" ] || noms="$short.local $short"; ' + # Le nom court DOIT y être : c'est lui que pmxcfs résout. Le test se + # fait sur des séparateurs NORMALISÉS — l'installeur Debian écrit des + # tabulations, et « case " $noms " in *" $short "* » ne les voyait pas, + # d'où un « srv srv » au rejeu. + 'case " $noms " in *" $short "*) ;; *) noms="$noms $short";; esac; ' + # Le fichier complet d'abord, dans le MÊME répertoire : un temporaire + # ailleurs ne se recopierait pas forcément (montages séparés). + "{ " + # awk et non sed : « print » émet un saut de ligne par + # enregistrement, donc un /etc/hosts non terminé par \n est + # NORMALISÉ. sed, lui, préserve l'absence — vérifié — et notre ligne + # se collait alors à la précédente : le nom du nœud partait sur + # l'adresse d'une autre machine. mawk 1.3.4, celui de Debian, fait + # bien ce qu'on attend. + r"awk '!/^[ \t]*127\.0\.1\.1[ \t]/" + f" && !/#[ \\t]*{HOSTS_MARK}[ \\t]*$/' /etc/hosts" + f" && printf '%s\\t%s\\t# {HOSTS_MARK}\\n' {shlex.quote(ip)} \"$noms\"" + f" ; }} > {tmp} || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; " + # On vérifie le TEMPORAIRE avant de toucher à l'original : notre ligne + # présente une seule fois, et plus aucune 127.0.1.1. + f"vu=$(sed -nE 's/^([^#[:space:]]+)[[:space:]].*#[[:space:]]*" + f"{HOSTS_MARK}[[:space:]]*$/\\1/p' {tmp}); " + f'if [ "$vu" != {shlex.quote(ip)} ] ' + rf"|| grep -qE '^[[:space:]]*127\.0\.1\.1[[:space:]]' {tmp}; then " + f"rm -f {tmp}; echo HOSTS-KO; exit 0; fi; " + # La seule écriture destructive, et elle est la dernière. + f"cat {tmp} > /etc/hosts || {{ rm -f {tmp}; echo HOSTS-KO; exit 0; }}; " + f"rm -f {tmp}; echo HOSTS-OK" + ) + + +def cloud_hosts_freeze_cmd() -> str: + """Empêche cloud-init de réécrire /etc/hosts au prochain démarrage. + + Gardé sur le CONTENU et non sur l'existence : « printf … > » TRONQUE avant + d'écrire, donc une coupure laisse zéro octet et une garde à l'existence + annonce « déjà gelé » pour toujours. Une redirection est de toute façon + idempotente : il n'y a rien d'autre à protéger. + """ + fichier = "/etc/cloud/cloud.cfg.d/99-erplibre-hosts.cfg" + return ( + "[ -d /etc/cloud ] || { echo FREEZE-SANS-OBJET; exit 0; }; " + f"grep -qE '^[[:space:]]*manage_etc_hosts:[[:space:]]*false' {fichier}" + " 2>/dev/null && { echo FREEZE-DEJA; exit 0; }; " + "mkdir -p /etc/cloud/cloud.cfg.d; " + "printf '%s\\n' " + "'# Posé par ERPLibre : pmxcfs exige une adresse routable.' " + "'manage_etc_hosts: false' " + f"> {fichier} && echo FREEZE-OK || echo FREEZE-KO" + ) + + +def pve_unit_cmd(unite: str, remonte: bool = False) -> str: + """Relance UNE unité, sans jamais être fatale. + + Par unité et non toutes ensemble : « systemctl start » BLOQUE jusqu'à + TimeoutStartSec (90 s par défaut), et cinq unités groupées dépassent le + délai de l'appel — on recevrait « timeout » sans savoir laquelle. + + « restart » quand pve-cluster est ACTIF mais /etc/pve absent : le montage + FUSE est alors périmé (pmxcfs tué par l'OOM killer), et « start » sur une + unité active est un no-op qui rend 0 — la réparation ne convergeait jamais + et ne nommait rien. + + Le journal accompagne un échec : c'est la seule façon de dire la cause à + quelqu'un dont le seul accès à l'hôte est cet outil. + """ + u = shlex.quote(unite) + # « active » ne prouve RIEN sur le lien à pmxcfs. Pour pve-cluster c'était + # déjà admis : actif sans /etc/pve, le montage FUSE est périmé et « start » + # est un no-op qui rend 0. Le même raisonnement vaut pour ses dépendants — + # pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en + # échouant sur ipcc_send_rec. Les laisser en place après avoir remonté + # /etc/pve donnait une GUI qui répond « communication failure » juste + # après notre ✓. `remonte` dit que le montage était absent au diagnostic. + if unite == "pve-cluster": + actif = ( + "[ -e /etc/pve/.version ] " + f'&& {{ echo "DEJA {unite}"; exit 0; }}; ' + f"systemctl restart {u}" + ) + elif remonte: + actif = f"systemctl restart {u}" + else: + actif = f'echo "DEJA {unite}"; exit 0' + return ( + f"systemctl list-unit-files {u}.service >/dev/null 2>&1" + f' || {{ echo "SKIP {unite}"; exit 0; }}; ' + f"etat=$(systemctl is-active {u} 2>/dev/null || true); " + f'if [ "$etat" = active ]; then {actif}; else ' + f"systemctl reset-failed {u} 2>/dev/null || true; " + f"systemctl start {u}; fi " + f'|| {{ echo "KO {unite}"; ' + f"journalctl -u {u} -n 20 --no-pager -o cat 2>/dev/null; }}" + ) + + +def mount_wait_cmd(tours: int = 20, repos: int = 5) -> str: + """Attend le montage de /etc/pve, puis le RECONFIRME. + + En une seule commande : une boucle côté Python rouvrirait une connexion + par tour — deux poignées de main à travers un rebond, vingt fois — et si + le chemin vient d'être perdu, tous les tours rendraient « timeout » et on + accuserait pmxcfs de ce qui est une perte de contact. + + Reconfirmé après une pause, parce qu'une seule observation ne prouve rien : + reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui + battait repart pour une salve entière. Le voir monter puis mourir se lit + dans NRestarts, qu'on rend aussi. + """ + return ( + f"i=0; while [ $i -lt {int(tours)} ]; do " + "[ -e /etc/pve/.version ] && break; sleep 1; i=$((i+1)); done; " + "if [ -e /etc/pve/.version ]; then " + f"sleep {int(repos)}; " + "if [ -e /etc/pve/.version ]; then echo MONTE; " + "else echo BATTEMENT; fi; " + "else echo ABSENT; fi; " + "printf 'NRESTARTS %s\\n' " + '"$(systemctl show -p NRestarts --value pve-cluster 2>/dev/null)"' + ) + + +def parse_mount_wait(text: str) -> dict: + """{"verdict": MONTE|BATTEMENT|ABSENT|INCONNU, "relances": int|None}. + + INCONNU quand rien de lisible n'est revenu — délai dépassé, coupure. Ce + n'est pas « absent » : conclure « /etc/pve n'est pas monté » d'une perte + de contact envoie chercher dans journalctl une panne qui n'existe pas. + """ + brut = strip_ssh_noise(text or "") + verdict = "INCONNU" + for mot in ("BATTEMENT", "MONTE", "ABSENT"): + if mot in brut: + verdict = mot + break + trouve = re.search(r"NRESTARTS\s+(\d+)", brut) + return { + "verdict": verdict, + "relances": int(trouve.group(1)) if trouve else None, + } + + def parse_storages(text: str) -> list: """Sortie de « pvesm status --content images » -> [{name, type, avail}].""" out = [] diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 7be8e04..3d86b7f 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -343,10 +343,17 @@ class ProxmoxMenuMixin: if not host: return 255, "" if not quiet: - # La forme RÉELLEMENT envoyée, enrobage sudo compris : une - # commande affichée doit pouvoir être recopiée telle quelle. - reel = pve.wrap_privilege(remote, host.get("sudo") or "") - print(f"\n{t('Will execute:')} ssh {host['target']} {reel}") + # La forme RÉELLEMENT envoyée : enrobage sudo, rebond et port + # compris. Sans « -J », la ligne copiée rendait « no route to + # host » — et c'est justement quand une étape échoue au milieu + # d'une réparation qu'on a besoin de la rejouer à la main. + argv = pve.ssh_argv( + host, pve.wrap_privilege(remote, host.get("sudo") or "") + ) + print( + f"\n{t('Will execute:')} " + + " ".join(shlex.quote(a) for a in argv) + ) code, out = pve.run(host, remote, timeout) if out.strip() and not quiet: print(out.rstrip()) @@ -648,7 +655,29 @@ class ProxmoxMenuMixin: parts = (sortie or "").split() return parts[parts.index("dev") + 1] if "dev" in parts else "" - def _pve_cluster_reason(self, host): + def _pve_cluster_state(self, host): + """L'état du cluster, LU UNE FOIS, plus ce qu'on peut en faire. + + Rend (etat, quoi) où `quoi` vaut "" (rien à proposer), "hosts" (le + résolveur est en cause, une réécriture est justifiée) ou "unites" (le + nom résout déjà, seules les unités sont à terre). + + Séparer les deux décisions, et non les fondre dans une garde unique : + interrompue après la réécriture de /etc/hosts, la réparation laissait + un hôte à un « systemctl start » de fonctionner — et la garde d'avant, + qui sortait dès que « routables » était non vide, refusait alors de le + finir. L'outil savait exactement quoi faire et s'y refusait + définitivement. + """ + from script.proxmox import proxmox_deploy as pve + + _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) + etat = pve.parse_cluster_check(out) + if not etat["lu"] or etat["monte"]: + return etat, "" + return etat, ("unites" if etat["routables"] else "hosts") + + def _pve_cluster_reason(self, host, etat=None, quoi=None): """Pourquoi il n'y a AUCUN stockage. Liste vide si tout va bien. « Il manque le stockage » est un symptôme, pas une cause : « pvesm » @@ -656,20 +685,13 @@ class ProxmoxMenuMixin: liste est vide et l'écran s'arrête sur le symptôme — le défaut est trois étages plus bas, et il a fallu lire un journal pour le trouver. - La cause la plus fréquente sur une image cloud : le nom d'hôte ne - résout que vers 127.0.1.1. pmxcfs cherche une adresse NON-bouclage et - n'en trouve pas. Notre installeur corrige /etc/hosts, mais cloud-init - le réécrit à chaque démarrage — donc la correction ne survivait pas au - redémarrage que nous faisons maintenant nous-mêmes.""" - from script.proxmox import proxmox_deploy as pve - - _c, out = pve.run(host, pve.CLUSTER_CHECK_CMD, 40) - etat = pve.parse_cluster_check(out) - # « La sonde n'a pas répondu » n'est PAS « rien n'est monté ». Un - # dépassement de délai — hostname bloqué sur un DNS injoignable — rend - # les mêmes vides, et on affirmait alors « le nom ne résout que vers - # ? » sans avoir rien mesuré. Affirmer une cause qu'on n'a pas - # constatée est pire que se taire. + `etat`/`quoi` viennent de `_pve_cluster_state` quand l'appelant l'a + déjà interrogé : une seule sonde, et surtout un seul verdict. Sondé + deux fois, on annonçait une réparation que la seconde lecture + refusait ensuite d'offrir — une promesse suivie de rien. + """ + if etat is None: + etat, quoi = self._pve_cluster_state(host) if not etat["lu"]: return [ f"⚠ {t('The cluster probe did not answer: cause unknown.')}" @@ -680,20 +702,147 @@ class ProxmoxMenuMixin: f"✗ {t('pve-cluster is down: /etc/pve is not mounted.')}", f" {t('Without it pvesm answers nothing, hence no storage.')}", ] - if not etat["routables"]: + if quoi == "hosts": lignes += [ f" {t('The hostname only resolves to')}" f" {' '.join(etat['adresses']) or '?'}" f" — {t('pmxcfs needs a routable address.')}", f" {t('cloud-init rewrites /etc/hosts at every boot.')}", ] - conseil = t( - "replay install_proxmox.sh on the host: it fixes /etc/hosts" - " and stops cloud-init undoing it." - ) - lignes.append(f"→ {conseil}") + else: + # Le nom résout déjà : le résolveur n'est PAS en cause, et le dire + # évite d'envoyer réécrire un fichier système pour rien. + lignes.append( + f" {t('The hostname resolves fine: only the units are down.')}" + ) + # La promesse UNIQUEMENT si l'offre suivra. Sinon on disait « cet écran + # peut le réparer » puis plus rien du tout. + lignes.append(f"→ {t('This screen can repair it (see below).')}") return lignes + def _pve_ssh_ip(self, host): + """Adresse par laquelle NOTRE ssh atteint l'hôte, ou "". + + Lue SANS privilège, exprès : « sudo » remet l'environnement à zéro et + efface $SSH_CONNECTION. Cette lecture n'a besoin d'aucun droit. + """ + from script.proxmox import proxmox_deploy as pve + + _c, out = pve.run( + dict(host, sudo=""), 'printf %s "$SSH_CONNECTION"', 20 + ) + return pve.ssh_server_ip(out) + + def _pve_restart_units(self, remonte): + """Relance les unités et rend (pivot_ok, lignes_de_cause). + + `remonte` : /etc/pve était absent, donc les dépendants qui SEMBLENT + actifs parlaient à un pmxcfs mort. Leur état actif ne prouve rien sur + leur lien à pmxcfs — le même raisonnement qui impose un « restart » à + pve-cluster vaut pour eux, et sans cela la GUI répondait + « communication failure » après un ✓. + + La sortie de chaque unité est LUE. pve-cluster est le pivot : les + trois suivantes le requièrent, donc s'il échoue, poursuivre ne produit + que soixante lignes de journal après la vraie cause. + """ + from script.proxmox import proxmox_deploy as pve + + for unite in pve.PVE_UNITS: + _c, out = self._pve_show( + pve.pve_unit_cmd(unite, remonte=remonte), timeout=200 + ) + texte = pve.strip_ssh_noise(out) + if f"KO {unite}" in texte or f"SKIP {unite}" in texte: + if unite == "pve-cluster": + lignes = [ + ligne + for ligne in texte.strip().splitlines() + if ligne.strip() + ] + return False, lignes[-8:] + return True, [] + + def _pve_offer_cluster_fix(self, host, etat=None, quoi=None): + """Propose de remettre pmxcfs debout, et le fait. Rend True si /etc/pve + est monté à la sortie. + + Le pendant de `_pve_offer_bridge`, et pour la même raison : le terminal + est encore à nous, donc c'est ICI qu'on peut poser la question et + montrer ce qu'on exécute. + + Pourquoi le faire au lieu de conseiller : le conseil était « rejouer + install_proxmox.sh sur l'hôte », et il ne pouvait PAS marcher. La VM + clone le dépôt distant, donc sa copie du script est celle du distant — + c'est-à-dire, tant que le correctif n'est pas poussé, celle qui ne + corrige rien. Trois hôtes de suite sont tombés dessus. + """ + from script.proxmox import proxmox_deploy as pve + + if etat is None: + etat, quoi = self._pve_cluster_state(host) + if not quoi: + return bool(etat["monte"]) + print(f"\n {t('Repair it from here?')}") + if quoi == "hosts": + print( + f" [1] {t('freeze cloud-init, fix /etc/hosts, restart pmxcfs')}" + ) + else: + print(f" [1] {t('restart pmxcfs only (the address is fine)')}") + print(f" [0] {t('leave it alone')}") + if input(t("Choice: ")).strip() != "1": + return False + if quoi == "hosts": + ip = self._pve_ssh_ip(host) + if not ip: + print( + f" ✗ {t('Cannot tell which address reaches this host.')}" + ) + return False + print(f" {t('address the node will answer for')} : {ip}") + # Le gel d'abord : sans lui la correction ne survit pas au + # prochain démarrage, et on aurait réparé pour une seule session. + for cmd in ( + pve.cloud_hosts_freeze_cmd(), + pve.hosts_repair_cmd(ip), + ): + code, sortie = self._pve_show(cmd, timeout=60) + if code or "-KO" in pve.strip_ssh_noise(sortie): + print(f" ✗ {t('Step failed, stopping here.')}") + return False + pivot, cause = self._pve_restart_units(remonte=True) + if not pivot: + print(f" ✗ pve-cluster {t('would not start:')}") + for ligne in cause: + print(f" {ligne}") + return False + _c, out = self._pve_show(pve.mount_wait_cmd(), timeout=120) + vu = pve.parse_mount_wait(out) + if vu["verdict"] == "MONTE": + print(f" ✓ /etc/pve {t('mounted')}") + return True + if vu["verdict"] == "INCONNU": + # Un silence du lien n'est PAS une absence de montage : conclure + # l'inverse envoie chercher dans journalctl une panne qui n'existe + # pas. + print(f" ⚠ {t('Cannot tell whether it mounted (link lost).')}") + return False + if vu["verdict"] == "BATTEMENT": + # Monté puis reperdu : le dire, parce qu'un ✓ suivi d'un « pvesm ne + # répond plus » dix secondes après est le pire des deux. + print(f" ⚠ /etc/pve {t('mounted then lost again')}") + else: + print(f" ✗ /etc/pve {t('still absent')}") + # L'adresse n'est mise en cause que quand pve-cluster a DÉMARRÉ et que + # le montage manque quand même : c'est le seul cas où le résolveur + # peut l'expliquer. + if quoi == "hosts": + print( + f" {t('The address written may not be the one pmxcfs needs.')}" + ) + return False + def _pve_internal_cidr(self, host): """Réseau du futur pont interne, CHOISI d'après l'hôte. @@ -911,10 +1060,20 @@ class ProxmoxMenuMixin: stockages = pve.parse_storages(out) if not stockages: # AVANT d'ouvrir l'écran : une fois Textual à l'affiche, ces - # lignes n'ont plus d'endroit où aller, et l'écran ne dirait que - # « aucun stockage ». - for ligne in self._pve_cluster_reason(host): + # lignes n'ont plus d'endroit où aller, l'écran ne dirait que + # « aucun stockage », et surtout il ne pourrait pas POSER la + # question — le terminal est encore à nous ici. + # + # UNE sonde, passée aux deux : sondé deux fois, on annonçait une + # réparation que la seconde lecture refusait ensuite d'offrir. + etat_pve, quoi_pve = self._pve_cluster_state(host) + for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve): print(f" {ligne}") + if self._pve_offer_cluster_fix(host, etat_pve, quoi_pve): + _c, out = self._pve_show( + "pvesm status --content images", quiet=True + ) + stockages = pve.parse_storages(out) _c, out = self._pve_show("ip -o link show type bridge", quiet=True) ponts = pve.parse_bridges(out) if not ponts: @@ -1744,10 +1903,20 @@ class ProxmoxMenuMixin: pont = pve.pick_bridge(ponts) if not stockage: print(f"\n ✗ {t('No storage able to hold a VM disk.')}") - # Le symptôme ne suffit pas : dire la CAUSE quand on la connaît. - for ligne in self._pve_cluster_reason(host): + # Le symptôme ne suffit pas : dire la CAUSE quand on la connaît, + # puis proposer d'y remédier. Une seule sonde pour les deux. + etat_pve, quoi_pve = self._pve_cluster_state(host) + for ligne in self._pve_cluster_reason(host, etat_pve, quoi_pve): print(f" {ligne}") - return + if not self._pve_offer_cluster_fix(host, etat_pve, quoi_pve): + return + _c, out = self._pve_show( + "pvesm status --content images", quiet=True + ) + stockage = pve.pick_storage(pve.parse_storages(out)) + if not stockage: + print(f" ✗ {t('No storage able to hold a VM disk.')}") + return if not pont and not dry_run: pont = self._pve_offer_bridge() if not pont: diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index f76cb1e..213b843 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3362,6 +3362,66 @@ TRANSLATIONS = { "fr": "La sonde du cluster n'a pas répondu : cause inconnue.", "en": "The cluster probe did not answer: cause unknown.", }, + "This screen can repair it (see below).": { + "fr": "Cet écran peut le réparer (voir ci-dessous).", + "en": "This screen can repair it (see below).", + }, + "Repair it from here?": { + "fr": "Le réparer d'ici ?", + "en": "Repair it from here?", + }, + "freeze cloud-init, fix /etc/hosts, restart pmxcfs": { + "fr": "geler cloud-init, corriger /etc/hosts, relancer pmxcfs", + "en": "freeze cloud-init, fix /etc/hosts, restart pmxcfs", + }, + "leave it alone": { + "fr": "ne rien toucher", + "en": "leave it alone", + }, + "Cannot tell which address reaches this host.": { + "fr": "Impossible de savoir quelle adresse atteint cet hôte.", + "en": "Cannot tell which address reaches this host.", + }, + "address the node will answer for": { + "fr": "adresse sous laquelle le nœud répondra", + "en": "address the node will answer for", + }, + "mounted then lost again": { + "fr": "monté puis reperdu", + "en": "mounted then lost again", + }, + "restarts": { + "fr": "relances", + "en": "restarts", + }, + "mounted": { + "fr": "monté", + "en": "mounted", + }, + "The hostname resolves fine: only the units are down.": { + "fr": "Le nom d'hôte résout bien : seules les unités sont à terre.", + "en": "The hostname resolves fine: only the units are down.", + }, + "restart pmxcfs only (the address is fine)": { + "fr": "relancer pmxcfs seulement (l'adresse est bonne)", + "en": "restart pmxcfs only (the address is fine)", + }, + "would not start:": { + "fr": "n'a pas démarré :", + "en": "would not start:", + }, + "Cannot tell whether it mounted (link lost).": { + "fr": "Impossible de constater le montage (lien perdu).", + "en": "Cannot tell whether it mounted (link lost).", + }, + "still absent": { + "fr": "toujours absent", + "en": "still absent", + }, + "The address written may not be the one pmxcfs needs.": { + "fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.", + "en": "The address written may not be the one pmxcfs needs.", + }, "pve-cluster is down: /etc/pve is not mounted.": { "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "en": "pve-cluster is down: /etc/pve is not mounted.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index 2eef09d..1d4deff 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -961,5 +961,437 @@ class TestLInstalleurRendPmxcfsAuMonde(unittest.TestCase): self.assertEqual(res.returncode, 0, res.stderr) +class TestReparerEtcHosts(unittest.TestCase): + """La réécriture de /etc/hosts, EXÉCUTÉE sur de faux fichiers. + + Trois hôtes de suite sont tombés sur la même panne, et le conseil + « rejouer install_proxmox.sh » ne pouvait pas la corriger : la VM clone le + dépôt distant, donc sa copie du script est celle qui ne corrige rien. + L'outil répare donc lui-même — et une réécriture de /etc/hosts sur une + machine qu'on ne joint que par ssh doit être ÉPROUVÉE, pas relue. + + Aucun bouchon de vérification ici : la commande relit elle-même ce qu'elle + a écrit. La première version s'en remettait à « getent hosts $short », qui + réussit via mDNS même quand rien n'a été écrit — et les tests bouchonnaient + getent à « return 0 », donc ils mesuraient le bouchon.""" + + def _joue(self, contenu, court="pve", passages=3, ecrivable=True): + """Rejoue la commande RÉELLE `passages` fois sur un faux /etc/hosts.""" + import os + import subprocess + import tempfile + + d = tempfile.mkdtemp() + hosts = os.path.join(d, "hosts") + with open(hosts, "w", encoding="utf-8") as fh: + fh.write(contenu) + cmd = pve.hosts_repair_cmd("10.10.10.150").replace("/etc/hosts", hosts) + if not ecrivable: + os.chmod(d, 0o500) + verdicts = [] + try: + for _ in range(passages): + res = subprocess.run( + ["sh", "-c", f"hostname() {{ echo {court}; }}; " + cmd], + capture_output=True, + text=True, + ) + verdicts.append(res.stdout.strip()) + finally: + os.chmod(d, 0o700) + with open(hosts, encoding="utf-8") as fh: + brut = fh.read() + restes = [f for f in os.listdir(d) if f != "hosts"] + return { + "lignes": [ligne for ligne in brut.splitlines() if ligne.strip()], + "verdicts": verdicts, + "brut": brut, + "restes": restes, + } + + def test_the_cloud_init_line_is_replaced(self): + vu = self._joue("127.0.1.1 pve pve\n127.0.0.1 localhost\n") + self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3) + self.assertIn("10.10.10.150\tpve pve\t# erplibre-hosts", vu["lignes"]) + self.assertFalse( + [ligne for ligne in vu["lignes"] if ligne.startswith("127.0.1.1")] + ) + + def test_a_refused_write_leaves_the_file_ALONE(self): + """Le constat le plus grave de l'attaque, mesuré sur trois états + réels : /etc en lecture seule, fichier immuable, quota atteint. + + « sed -i » puis « printf >> » étaient DEUX écritures. Sed refusé et + ajout réussi, la ligne 127.0.1.1 survivait EN PREMIER et notre ligne + s'ajoutait une fois par tentative. Sed réussi et ajout refusé, l'hôte + perdait l'entrée de son nom — et sur une machine qu'on ne joint que + par ssh, chaque sudo attend ensuite le résolveur. + + Une seule écriture, la dernière, et elle est vérifiée avant.""" + vu = self._joue( + "127.0.1.1 pve.lan pve\n127.0.0.1 localhost\n", ecrivable=False + ) + self.assertEqual(vu["verdicts"], ["HOSTS-KO"] * 3) + self.assertEqual( + vu["lignes"], ["127.0.1.1 pve.lan pve", "127.0.0.1 localhost"] + ) + self.assertEqual(vu["restes"], [], "aucun temporaire ne doit rester") + + def test_a_file_without_a_final_newline(self): + """cloud-init « write_files » n'en met pas. + + sed PRÉSERVE l'absence — vérifié — et notre ligne se collait à la + précédente : « 192.168.1.9 autre-machine10.10.10.150 pve », donc le + nom du nœud résolvait vers l'adresse d'une AUTRE machine. awk émet un + saut de ligne par enregistrement, donc il normalise.""" + vu = self._joue( + "127.0.0.1 localhost\n127.0.1.1 pve\n192.168.1.9 autre-machine" + ) + self.assertEqual(vu["verdicts"], ["HOSTS-OK"] * 3) + self.assertIn("192.168.1.9 autre-machine", vu["lignes"]) + self.assertIn("10.10.10.150\tpve\t# erplibre-hosts", vu["lignes"]) + self.assertTrue(vu["brut"].endswith("\n")) + + def test_a_real_fqdn_survives_every_pass(self): + """Le défaut que le TROISIÈME passage a révélé. + + Rejouée, la commande ne trouve plus de ligne 127.0.1.1 — c'est elle + qui l'a retirée — et retombait sur « .local ». Un vrai FQDN + était donc remplacé par un nom réservé au mDNS, au deuxième passage, + par la réparation elle-même.""" + vu = self._joue( + "127.0.1.1\tpve.lan.example.com pve\n127.0.0.1 localhost\n" + ) + self.assertIn( + "10.10.10.150\tpve.lan.example.com pve\t# erplibre-hosts", + vu["lignes"], + ) + self.assertNotIn("pve.local", " ".join(vu["lignes"])) + + def test_nothing_accumulates(self): + # En DHCP l'adresse change : sans marqueur, une ligne s'ajoutait à + # chaque passage sans que la précédente soit retirée. + for contenu in ( + "127.0.1.1 pve pve\n", + "10.0.0.9\tpve.lan.example.com pve\t# erplibre-hosts\n", + ): + with self.subTest(depart=contenu.strip()): + vu = self._joue(contenu, passages=4) + marquees = [ + ligne + for ligne in vu["lignes"] + if "erplibre-hosts" in ligne + ] + self.assertEqual(len(marquees), 1, vu["lignes"]) + + def test_tabs_everywhere_do_not_duplicate_the_short_name(self): + """L'installeur Debian écrit /etc/hosts avec des TABULATIONS. + + Le test du nom court cherchait des ESPACES : « pve.example.com\tpve » + ne contenait pas « pve » entouré d'espaces, et le rejeu écrivait + « pve.example.com pve pve ».""" + vu = self._joue( + "127.0.0.1\tlocalhost\n127.0.1.1\tpve.example.com\tpve\n" + ) + self.assertIn( + "10.10.10.150\tpve.example.com pve\t# erplibre-hosts", + vu["lignes"], + ) + + def test_a_trailing_comment_is_stripped(self): + vu = self._joue("127.0.1.1 pve # posé à la main\n") + self.assertEqual(vu["lignes"], ["10.10.10.150\tpve\t# erplibre-hosts"]) + + def test_the_short_name_is_always_there(self): + # C'est lui que pmxcfs résout : une ligne sans lui ne sert à rien. + vu = self._joue("127.0.1.1 autre-nom\n", court="pve") + self.assertIn("pve", vu["lignes"][0].split()) + + def test_an_unusable_address_produces_no_command(self): + for mauvaise in ( + "", + "127.0.0.1", + "fe80::1", + "169.254.3.4", + "pas-une-ip", + ): + with self.subTest(ip=mauvaise): + self.assertEqual(pve.hosts_repair_cmd(mauvaise), "") + + def test_no_sudo_in_the_body(self): + """wrap_privilege porte le privilège, pas le corps. + + Sur un hôte root@ il n'enrobe rien — et un Proxmox installé par l'ISO + n'a pas forcément le paquet sudo : « sh: 1: sudo: not found », code + 127, au milieu d'une réécriture de /etc/hosts.""" + for cmd in ( + pve.hosts_repair_cmd("10.0.0.1"), + pve.cloud_hosts_freeze_cmd(), + pve.mount_wait_cmd(), + ) + tuple(pve.pve_unit_cmd(u) for u in pve.PVE_UNITS): + with self.subTest(cmd=cmd[:40]): + self.assertNotIn("sudo", cmd) + + +class TestGelerCloudInit(unittest.TestCase): + """Le gel EXÉCUTÉ, y compris sur le fichier tronqué à zéro octet.""" + + def _joue(self, etat): + import os + import subprocess + import tempfile + + racine = tempfile.mkdtemp() + dossier = os.path.join(racine, "cloud.cfg.d") + fichier = os.path.join(dossier, "99-erplibre-hosts.cfg") + if etat != "sans-cloud": + os.makedirs(dossier) + if etat == "vide": + open(fichier, "w").close() + elif etat == "gele": + with open(fichier, "w", encoding="utf-8") as fh: + fh.write("manage_etc_hosts: false\n") + cmd = ( + pve.cloud_hosts_freeze_cmd() + .replace("/etc/cloud/cloud.cfg.d", dossier) + .replace( + "/etc/cloud", racine if etat != "sans-cloud" else "/nexistepas" + ) + ) + res = subprocess.run(["sh", "-c", cmd], capture_output=True, text=True) + contenu = "" + if os.path.exists(fichier): + with open(fichier, encoding="utf-8") as fh: + contenu = fh.read() + return res.stdout.strip(), contenu + + def test_a_fresh_host_gets_frozen(self): + verdict, contenu = self._joue("neuf") + self.assertEqual(verdict, "FREEZE-OK") + self.assertIn("manage_etc_hosts: false", contenu) + + def test_an_empty_file_is_rewritten(self): + """Le défaut que la garde à l'EXISTENCE laissait passer. + + « printf … > » TRONQUE avant d'écrire : une coupure laisse zéro octet, + et la garde annonçait « déjà gelé » pour toujours. cloud-init + continuait de remettre 127.0.1.1 à chaque démarrage.""" + verdict, contenu = self._joue("vide") + self.assertEqual(verdict, "FREEZE-OK") + self.assertIn("manage_etc_hosts: false", contenu) + + def test_an_already_frozen_host_is_left_alone(self): + verdict, _c = self._joue("gele") + self.assertEqual(verdict, "FREEZE-DEJA") + + def test_a_host_without_cloud_init_says_so(self): + verdict, _c = self._joue("sans-cloud") + self.assertEqual(verdict, "FREEZE-SANS-OBJET") + + +class TestQuelleAdressePourLeNoeud(unittest.TestCase): + """L'adresse écrite doit être celle par laquelle on JOINT l'hôte. + + Mesuré sur une Proxmox imbriquée : « hostname -I » rend + « 10.10.10.150 10.10.20.1 », et la seconde est le pont interne que notre + propre code vient de créer. La poser ferait s'identifier le nœud par une + adresse que personne ne joint.""" + + def test_the_server_field_of_ssh_connection(self): + self.assertEqual( + pve.ssh_server_ip("10.10.10.1 33580 10.10.10.150 22"), + "10.10.10.150", + ) + + def test_ssh_noise_does_not_shift_the_fields(self): + brut = ( + "Warning: Permanently added 'x' (ED25519) to the list of known" + " hosts.\n10.10.10.1 33580 10.10.10.150 22" + ) + self.assertEqual(pve.ssh_server_ip(brut), "10.10.10.150") + + def test_an_empty_or_short_value_gives_nothing(self): + for brut in ("", "10.0.0.1 22", "n'importe quoi"): + with self.subTest(brut=brut): + self.assertEqual(pve.ssh_server_ip(brut), "") + + def test_a_loopback_server_field_is_refused(self): + # Un tunnel local peut faire de l'hôte « 127.0.0.1 » : l'écrire dans + # /etc/hosts ne réglerait rien. + self.assertEqual(pve.ssh_server_ip("127.0.0.1 5555 127.0.0.1 22"), "") + + +class TestRelancerLesUnites(unittest.TestCase): + """Chaque unité à part, jamais fatale, et le journal quand ça échoue. + + Les bouchons ÉCHOUENT ici. La première version ne faisait jamais rater un + « start » : le journalctl bouchonné n'était donc jamais atteint, et + retirer complètement « reset-failed » de la commande laissait tous les + tests verts.""" + + def _joue(self, unite, etat, monte, existe=True, start_ok=True, **kw): + import os + import subprocess + import tempfile + + temoin = os.path.join(tempfile.mkdtemp(), "version") + if monte: + open(temoin, "w").close() + bouchons = ( + "systemctl() { " + ' case "$1" in ' + f" list-unit-files) return {0 if existe else 1};; " + f" is-active) echo {etat};; " + ' reset-failed) echo "RESET $2";; ' + f' start|restart) echo "STARTED $1 $2"; ' + f" return {0 if start_ok else 1};; " + " esac; }; " + "journalctl() { echo LIGNE-DE-JOURNAL; }; " + ) + cmd = pve.pve_unit_cmd(unite, **kw).replace( + "/etc/pve/.version", temoin + ) + res = subprocess.run( + ["sh", "-c", bouchons + cmd], capture_output=True, text=True + ) + return res.returncode, res.stdout + + def test_an_absent_unit_is_skipped_not_fatal(self): + code, out = self._joue("pveproxy", "failed", False, existe=False) + self.assertEqual(code, 0) + self.assertIn("SKIP pveproxy", out) + + def test_a_failed_unit_is_RESET_then_started(self): + # Le reset débloque la limite de démarrage : sans lui, systemd refuse + # le start sans même le tenter. Son absence doit faire ROUGIR le test. + code, out = self._joue("pvestatd", "failed", False) + self.assertEqual(code, 0) + self.assertIn("RESET pvestatd", out) + self.assertIn("STARTED start", out) + self.assertLess(out.index("RESET"), out.index("STARTED")) + + def test_a_start_that_fails_names_the_unit_and_shows_the_journal(self): + """La seule façon de dire la cause à quelqu'un dont l'unique accès à + l'hôte est cet outil.""" + code, out = self._joue("pve-cluster", "failed", False, start_ok=False) + self.assertEqual(code, 0, "jamais fatale") + self.assertIn("KO pve-cluster", out) + self.assertIn("LIGNE-DE-JOURNAL", out) + + def test_a_stale_mount_gets_a_restart_not_a_start(self): + """« start » sur une unité ACTIVE est un no-op qui rend 0. + + pmxcfs tué par l'OOM killer laisse /etc/pve monté mais mort, l'unité + pouvant rester « active » : la réparation ne convergeait jamais et ne + nommait rien.""" + code, out = self._joue("pve-cluster", "active", False) + self.assertEqual(code, 0) + self.assertIn("STARTED restart", out) + + def test_an_active_unit_with_the_mount_is_left_alone(self): + code, out = self._joue("pve-cluster", "active", True) + self.assertEqual(code, 0) + self.assertIn("DEJA pve-cluster", out) + + def test_the_dependents_are_restarted_when_the_mount_was_absent(self): + """Leur état actif ne prouve rien sur leur lien à pmxcfs. + + pvestatd, pvedaemon et pveproxy tournaient pendant toute la panne, en + échouant sur ipcc_send_rec. Les laisser après avoir remonté /etc/pve + donnait une GUI qui répond « communication failure » juste après le ✓ + de la réparation.""" + for unite in ("pvestatd", "pvedaemon", "pveproxy"): + with self.subTest(unite=unite): + _c, out = self._joue(unite, "active", True, remonte=True) + self.assertIn("STARTED restart", out) + _c, sans = self._joue(unite, "active", True) + self.assertIn(f"DEJA {unite}", sans) + + def test_the_firewall_is_not_in_the_list(self): + # Sa configuration vit dans config.db, invisible tant que /etc/pve + # n'est pas monté : on appliquerait des règles illisibles sur la seule + # voie d'accès à la machine. + self.assertNotIn("pve-firewall", pve.PVE_UNITS) + + def test_rrdcached_comes_before_pve_cluster(self): + # pve-cluster le requiert : une limite atteinte sur rrdcached fait + # échouer pve-cluster sur « dependency », et reset-failed sur + # pve-cluster n'y change rien. + units = list(pve.PVE_UNITS) + self.assertLess(units.index("rrdcached"), units.index("pve-cluster")) + + +class TestConstaterLeMontage(unittest.TestCase): + """Une seule observation ne prouve rien, et un silence n'est pas une + absence.""" + + def test_a_mount_that_holds(self): + lu = pve.parse_mount_wait("MONTE\nNRESTARTS 0\n") + self.assertEqual((lu["verdict"], lu["relances"]), ("MONTE", 0)) + + def test_a_mount_that_flaps_is_not_a_success(self): + # reset-failed vient d'effacer la limite de relance : un pmxcfs qui + # battait repart pour une salve entière, et le ✓ serait suivi d'un + # « pvesm ne répond plus » dix secondes après. + lu = pve.parse_mount_wait("BATTEMENT\nNRESTARTS 4\n") + self.assertEqual((lu["verdict"], lu["relances"]), ("BATTEMENT", 4)) + + def test_silence_is_not_absence(self): + # Conclure « /etc/pve n'est pas monté » d'une perte de contact envoie + # chercher dans journalctl une panne qui n'existe pas. + for brut in ("", "timeout", "ssh: connect to host … port 22"): + with self.subTest(brut=brut): + self.assertEqual( + pve.parse_mount_wait(brut)["verdict"], "INCONNU" + ) + + def test_the_wait_is_a_single_round_trip(self): + cmd = pve.mount_wait_cmd() + self.assertIn("while", cmd) + self.assertIn("sleep", cmd) + self.assertEqual(cmd.count("NRESTARTS"), 1) + + def _attends(self, present, disparait=False): + """Exécute la commande RÉELLE, sentinelle créée puis retirée.""" + import os + import subprocess + import tempfile + + temoin = os.path.join(tempfile.mkdtemp(), "version") + if present: + open(temoin, "w").close() + cmd = pve.mount_wait_cmd(tours=2, repos=1).replace( + "/etc/pve/.version", temoin + ) + if disparait: + # Retiré PENDANT la pause de reconfirmation — la SECONDE, celle + # qui suit « then ». La première est dans la boucle d'attente. + cmd = cmd.replace( + "then sleep 1;", f"then rm -f {temoin}; sleep 1;", 1 + ) + res = subprocess.run( + ["sh", "-c", "systemctl() { echo 3; }; " + cmd], + capture_output=True, + text=True, + ) + return pve.parse_mount_wait(res.stdout) + + def test_a_mount_that_holds_is_measured(self): + self.assertEqual(self._attends(True)["verdict"], "MONTE") + + def test_a_mount_that_disappears_is_a_flap(self): + """La raison d'être de la reconfirmation. + + reset-failed vient d'effacer la limite de relance, donc un pmxcfs qui + battait repart pour une salve entière : vu une fois, il peut mourir + dix secondes après notre ✓.""" + self.assertEqual( + self._attends(True, disparait=True)["verdict"], "BATTEMENT" + ) + + def test_a_mount_that_never_comes_is_absent(self): + self.assertEqual(self._attends(False)["verdict"], "ABSENT") + + if __name__ == "__main__": unittest.main(verbosity=1) From 7199a7cbb21c5b61e4a205b12513b95b265ad6c8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 06:20:52 -0400 Subject: [PATCH 07/26] =?UTF-8?q?[ADD]=20LongTest=20:=20jusqu'=C3=A0=20que?= =?UTF-8?q?l=20=C3=A9tage=20un=20Proxmox=20imbriqu=C3=A9=20tient-il?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la main a trouvé, au quatrième étage, un invité 36 fois plus lent que le temps réel — 583 secondes d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une seconde — puis un noyau gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une fois, sur une machine, n'est pas un chiffre. D'où trois choses. L'algorithme, en fonctions pures. Deux ressources s'épuisent en descendant : la mémoire, chaque étage gardant de quoi faire tourner ses propres démons, et le disque, celui de l'enfant vivant DANS celui du parent. Une troisième se dégrade, et elle borne le vCPU à deux au-delà du premier étage : douze ont gelé le noyau invité, les mêmes deux avançaient. La mémoire n'est PAS bornée — la même VM gelait au même octet avec 9 Go et avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. Le plan est annoncé avant toute création, et jamais au-delà de ce qui tient. Le garde-fou dans l'écran. Il lisait la capacité de l'HÔTE et l'offrait en entier : sur un troisième étage à 14 cœurs, il a proposé 12 vCPU à une VM qui n'a jamais démarré. Le nombre n'était pas absurde pour la machine ; il l'était pour sa profondeur, que l'écran ignorait. Elle se compte maintenant sur la chaîne de ProxyJump — un rebond par étage, et c'est nous qui écrivons ces entrées. Le test long, dans LongTest/ et non dans test/ : le lanceur unitaire doit rester lançable en quelques secondes, partout, y compris sans virtualisation. La descente est uniforme — créer, attendre le ssh, installer, redémarrer et vérifier le noyau, remettre pmxcfs debout, contrôler le stockage — et s'arrête au premier étage qui échoue en NOMMANT l'étape. Il envoie notre install_proxmox.sh par scp plutôt que de laisser la VM cloner le dépôt : c'est notre code qu'on éprouve, et un correctif absent du distant a fait revenir le même défaut sur trois VM. --- EN --- The practicable nesting depth cannot be deduced, only measured. A manual measurement found, at the fourth level, a guest 36 times slower than real time — 583 seconds of wall clock for 16 seconds of guest time, each ACPI line taking a second — then a kernel frozen at the SAME byte whatever the resources. A number obtained once, on one machine, is not a number. Hence three things. The algorithm, in pure functions. Two resources run out going down: memory, each level keeping what its own daemons need, and disk, the child's living INSIDE the parent's. A third degrades, and it caps the vCPU at two beyond the first level: twelve froze the guest kernel, the same two progressed. Memory is NOT capped — the same VM froze at the same byte with 9 GB and with 2 GB, so trimming it would gain nothing and starve the level below. The plan is announced before anything is created, and never beyond what fits. The guard in the screen. It read the HOST's capacity and offered all of it: on a third level with 14 cores it proposed 12 vCPU to a VM that never booted. The number was not absurd for the machine; it was for its depth, which the screen did not know. It is now counted on the ProxyJump chain — one hop per level, and we are the ones writing those entries. The long test, in LongTest/ and not test/: the unit runner must stay runnable in seconds, anywhere, including without virtualisation. The descent is uniform — create, wait for ssh, install, reboot and check the kernel, bring pmxcfs back, check the storage — and stops at the first level that fails, NAMING the step. It sends our install_proxmox.sh over scp instead of letting the VM clone the repository: it is our code being exercised, and a fix absent from the remote made the same defect return on three VMs. Assisted-by: Claude Opus 5 (cherry picked from commit 4f70c461330cac6f46783a60e0f33052a979fa23) --- LongTest/README.base.md | 125 +++++++ LongTest/README.fr.md | 62 ++++ LongTest/README.md | 57 ++++ LongTest/deep_proxmox.py | 609 +++++++++++++++++++++++++++++++++++ script/proxmox/nesting.py | 148 +++++++++ script/todo/longtest_menu.py | 86 +++++ script/todo/proxmox_menu.py | 46 +++ script/todo/todo.py | 31 ++ script/todo/todo_i18n.py | 44 +++ test/test_proxmox_nesting.py | 205 ++++++++++++ test/test_todo_longtest.py | 142 ++++++++ 11 files changed, 1555 insertions(+) create mode 100644 LongTest/README.base.md create mode 100644 LongTest/README.fr.md create mode 100644 LongTest/README.md create mode 100755 LongTest/deep_proxmox.py create mode 100644 script/proxmox/nesting.py create mode 100644 script/todo/longtest_menu.py create mode 100644 test/test_proxmox_nesting.py create mode 100644 test/test_todo_longtest.py diff --git a/LongTest/README.base.md b/LongTest/README.base.md new file mode 100644 index 0000000..20a85f4 --- /dev/null +++ b/LongTest/README.base.md @@ -0,0 +1,125 @@ + + + + + + +# LongTest — tests that create real machines + +These are not unit tests. They create virtual machines, install systems on +them, and take hours. They live here and **not** in `test/`, which the unit +runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds +on any machine, including one without virtualisation. + +Run them from the menu — `TODO › Execute › Test › Long tests` — or directly. + +## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? + +The practicable nesting depth cannot be deduced, only measured. A manual +measurement found, at the fourth level, a guest **36 times slower than real +time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI +line taking a second — then a guest kernel frozen at the **same byte** +whatever the resources. A number obtained once, on one machine, is not a +number: this script redoes it on demand and says exactly where it breaks. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py --detruire # undo it +``` + +The descent is **uniform**. Every level, the first included, goes through the +same six steps: create, wait for ssh, install Proxmox, reboot and check the +kernel, bring pmxcfs back up, check the storage. Only creation differs — +libvirt locally, `qm` afterwards. + +It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone +the repository: it is our code we want to exercise, and the remote is often +behind the checkout — a fix absent from the remote made the same defect "come +back" on three VMs in a row. + +### The resource algorithm + +Two things run out going down, and a third degrades. What runs out is +arithmetic, and `script/proxmox/nesting.py` computes it: + +* **memory** — each level keeps what its own daemons need (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +* **disk** — the child's disk lives *inside* the parent's, which must also + hold its own system. + +What degrades is measured, not assumed: past the second level, vendors +document nothing. Hence one capped number — **2 vCPU** for every nested +level. Twelve vCPU at the fourth level froze the guest kernel in early boot; +the same two progressed. Bringing twelve processors online costs as many +round trips through the whole stack. + +Memory is **not** capped: the same VM froze at the same byte with 9 GB and +with 2 GB, so trimming it would gain nothing and starve the level below. + +The plan is printed **before** anything is created, and the script never +promises a depth it knows will not fit — better to announce six levels and +reach six than to promise ten and die at the seventh without knowing why. + + +# LongTest — des tests qui créent de vraies machines + +Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y +installent des systèmes, et durent des heures. Ils vivent ici et **non** dans +`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh` +doit rester lançable en quelques secondes, sur n'importe quelle machine, y +compris sans virtualisation. + +Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou +directement. + +## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une +mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent +que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps +invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au +**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, +sur une machine, n'est pas un chiffre : ce script le refait à la demande et +dit exactement où ça casse. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py --detruire # défaire +``` + +La descente est **uniforme**. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, `qm` ensuite. + +Il envoie **notre** `install_proxmox.sh` par scp au lieu de laisser la VM +cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant +est souvent en retard sur le checkout — un correctif absent du distant a fait +« revenir » le même défaut sur trois VM de suite. + +### L'algorithme de ressources + +Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui +s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : + +* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le + reste ; +* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit + aussi contenir son propre système. + +Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les +fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour +tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en +tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs +en ligne coûte autant d'allers-retours à travers toute la pile. + +La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et +avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. + +Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script +ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer +six étages et en réussir six que d'en promettre dix et mourir au septième sans +savoir pourquoi. diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md new file mode 100644 index 0000000..46f48d4 --- /dev/null +++ b/LongTest/README.fr.md @@ -0,0 +1,62 @@ + +# LongTest — des tests qui créent de vraies machines + +Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y +installent des systèmes, et durent des heures. Ils vivent ici et **non** dans +`test/`, que le lanceur unitaire balaie : `./script/test/run_unit_test.sh` +doit rester lançable en quelques secondes, sur n'importe quelle machine, y +compris sans virtualisation. + +Ils se lancent depuis le menu — `TODO › Execute › Test › Tests longs` — ou +directement. + +## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une +mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent +que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps +invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au +**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, +sur une machine, n'est pas un chiffre : ce script le refait à la demande et +dit exactement où ça casse. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py --detruire # défaire +``` + +La descente est **uniforme**. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, `qm` ensuite. + +Il envoie **notre** `install_proxmox.sh` par scp au lieu de laisser la VM +cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant +est souvent en retard sur le checkout — un correctif absent du distant a fait +« revenir » le même défaut sur trois VM de suite. + +### L'algorithme de ressources + +Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui +s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : + +* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le + reste ; +* **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit + aussi contenir son propre système. + +Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les +fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour +tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en +tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs +en ligne coûte autant d'allers-retours à travers toute la pile. + +La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et +avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. + +Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script +ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer +six étages et en réussir six que d'en promettre dix et mourir au septième sans +savoir pourquoi. \ No newline at end of file diff --git a/LongTest/README.md b/LongTest/README.md new file mode 100644 index 0000000..3da0560 --- /dev/null +++ b/LongTest/README.md @@ -0,0 +1,57 @@ + +# LongTest — tests that create real machines + +These are not unit tests. They create virtual machines, install systems on +them, and take hours. They live here and **not** in `test/`, which the unit +runner sweeps: `./script/test/run_unit_test.sh` must stay runnable in seconds +on any machine, including one without virtualisation. + +Run them from the menu — `TODO › Execute › Test › Long tests` — or directly. + +## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? + +The practicable nesting depth cannot be deduced, only measured. A manual +measurement found, at the fourth level, a guest **36 times slower than real +time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI +line taking a second — then a guest kernel frozen at the **same byte** +whatever the resources. A number obtained once, on one machine, is not a +number: this script redoes it on demand and says exactly where it breaks. + +``` +./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py --detruire # undo it +``` + +The descent is **uniform**. Every level, the first included, goes through the +same six steps: create, wait for ssh, install Proxmox, reboot and check the +kernel, bring pmxcfs back up, check the storage. Only creation differs — +libvirt locally, `qm` afterwards. + +It sends **our** `install_proxmox.sh` over scp instead of letting the VM clone +the repository: it is our code we want to exercise, and the remote is often +behind the checkout — a fix absent from the remote made the same defect "come +back" on three VMs in a row. + +### The resource algorithm + +Two things run out going down, and a third degrades. What runs out is +arithmetic, and `script/proxmox/nesting.py` computes it: + +* **memory** — each level keeps what its own daemons need (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +* **disk** — the child's disk lives *inside* the parent's, which must also + hold its own system. + +What degrades is measured, not assumed: past the second level, vendors +document nothing. Hence one capped number — **2 vCPU** for every nested +level. Twelve vCPU at the fourth level froze the guest kernel in early boot; +the same two progressed. Bringing twelve processors online costs as many +round trips through the whole stack. + +Memory is **not** capped: the same VM froze at the same byte with 9 GB and +with 2 GB, so trimming it would gain nothing and starve the level below. + +The plan is printed **before** anything is created, and the script never +promises a depth it knows will not fit — better to announce six levels and +reach six than to promise ten and die at the seventh without knowing why. diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py new file mode 100755 index 0000000..1bed45f --- /dev/null +++ b/LongTest/deep_proxmox.py @@ -0,0 +1,609 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? + +Ce n'est pas un test unitaire : il crée de vraies machines et prend des +HEURES. Il vit donc hors de `test/`, que le lanceur unitaire balaie. + +Ce qu'il établit, et pourquoi cela valait un script : la profondeur +d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la +main a montré, au quatrième étage, un invité 36 fois plus lent que le temps +réel — 583 secondes d'horloge pour 16 secondes de temps invité — puis un noyau +gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une +fois, sur une machine, n'est pas un chiffre : ce script le refait à la demande +et dit exactement OÙ ça casse. + +La descente est UNIFORME. Chaque étage, le premier compris, passe par les +mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et +vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la +création diffère — libvirt en local, « qm » ensuite. + +Il envoie NOTRE install_proxmox.sh par scp au lieu de laisser la VM cloner le +dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent +en retard sur le checkout — un correctif absent du distant a fait « revenir » +le même défaut sur trois VM de suite. + + ./LongTest/deep_proxmox.py --depth 10 --dry-run + ./LongTest/deep_proxmox.py --depth 10 + ./LongTest/deep_proxmox.py --detruire # défait ce que la descente a posé +""" + +import argparse +import json +import os +import re +import shlex +import subprocess +import sys +import time + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) + +from script.proxmox import nesting # noqa: E402 +from script.proxmox import proxmox_deploy as pve # noqa: E402 + +# L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe +# SUR une Debian — Proxmox ne publie pas d'image cloud. +DISTRO = "proxmox" +NOM_BASE = "deep-pve" + +# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le +# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent +# que le précédent — c'est précisément ce qu'on mesure. +DELAIS = { + "creation": 1200, + "ssh": 2400, + "install": 7200, + "reboot": 2400, + "reparation": 600, + "controle": 180, +} + + +def dire(msg, journal=None): + ligne = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(ligne, flush=True) + if journal: + with open(journal, "a", encoding="utf-8") as fh: + fh.write(ligne + "\n") + + +def capacite_hote(): + """(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle. + + « available » et non « free » : c'est ce que le noyau promet de rendre sans + mettre la machine à genoux. + """ + coeurs = os.cpu_count() or 2 + ram = 0 + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith("MemAvailable:"): + ram = int(ligne.split()[1]) // 1024 + break + except OSError: + pass + disque = 0 + try: + st = os.statvfs("/var/lib/libvirt/images") + disque = (st.f_bavail * st.f_frsize) // (1024**3) + except OSError: + pass + return coeurs, ram, disque + + +def module_qemu(): + """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" + import importlib.util + + chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py") + spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def cle_publique(): + for nom in ("id_ed25519.pub", "id_rsa.pub"): + chemin = os.path.expanduser(f"~/.ssh/{nom}") + if os.path.exists(chemin): + return chemin + return "" + + +def nom_etage(niveau): + return f"{NOM_BASE}-{niveau}" + + +def alias_etage(niveau, parent_alias): + """« parent+enfant », la convention du dépôt : elle dit où la machine vit + et ne peut rien voler à un homonyme.""" + if not parent_alias: + return nom_etage(niveau) + court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias) + return f"{court}+{nom_etage(niveau)}" + + +class Descente: + """Un étage après l'autre, et ce qu'on en sait.""" + + def __init__(self, plan, journal, dry_run=False): + self.plan = plan + self.journal = journal + self.dry_run = dry_run + self.etages = [] + + def dire(self, msg): + dire(msg, self.journal) + + # ---------------------------------------------------------------- # + # Parler aux machines + # ---------------------------------------------------------------- # + def executer(self, hote, remote, delai, etiquette, montrer=False): + if self.dry_run: + argv = pve.ssh_argv( + hote, pve.wrap_privilege(remote, hote.get("sudo") or "") + ) + print(" " + " ".join(shlex.quote(a) for a in argv)[:200]) + return 0, "" + debut = time.time() + code, sortie = pve.run(hote, remote, delai) + if code or montrer: + self.dire( + f" {etiquette} : code {code}" + f" en {int(time.time() - debut)} s" + ) + if code: + for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]: + self.dire(f" {ligne}") + return code, sortie + + def attendre_ssh(self, hote, delai): + """Attend que la machine réponde. Rend les secondes, ou None. + + Des connexions COURTES successives : cloud-init régénère les clés + d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une + session longue. + """ + if self.dry_run: + return 0 + debut = time.time() + while time.time() - debut < delai: + code, _o = pve.run(hote, "true", 30) + if code == 0: + return int(time.time() - debut) + time.sleep(15) + return None + + # ---------------------------------------------------------------- # + # Les six étapes, les mêmes à chaque étage + # ---------------------------------------------------------------- # + def installer_proxmox(self, hote): + """Envoie NOTRE script et l'exécute. Rend True si Proxmox est posé.""" + local = os.path.join(RACINE, "script/proxmox/install_proxmox.sh") + distant = "/tmp/install_proxmox.sh" + if self.dry_run: + print(f" scp {local} :{distant}") + print(f" sh {distant}") + return True + argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande + cible = argv[-1] + options = argv[1:-1] + res = subprocess.run( + ["scp", "-q"] + options + [local, f"{cible}:{distant}"], + capture_output=True, + text=True, + timeout=300, + ) + if res.returncode: + self.dire(f" ✗ scp : {res.stderr.strip()[:200]}") + return False + code, _o = self.executer( + dict(hote, sudo=""), + f"sh {distant}", + DELAIS["install"], + "install_proxmox.sh", + montrer=True, + ) + return code == 0 + + def redemarrer_et_verifier(self, hote): + """Redémarre, attend le retour, exige le noyau Proxmox. + + Le script pose le noyau sans redémarrer — lancé par ssh, un reboot + couperait sa session et ferait passer l'installation pour un échec. + Sans ce redémarrage, la machine reste sur le noyau cloud de Debian, + dépouillé de tout netfilter : ni pont NAT, ni invité. + """ + if self.dry_run: + print(" reboot puis attente de *-pve dans uname -r") + return True + pve.run(hote, "systemctl reboot", 60) + debut = time.time() + while time.time() - debut < DELAIS["reboot"]: + time.sleep(20) + code, out = pve.run(dict(hote, sudo=""), "uname -r", 30) + noyau = pve.strip_ssh_noise(out).strip() + if code == 0 and "-pve" in noyau: + self.dire( + f" noyau {noyau} après {int(time.time() - debut)} s" + ) + return True + self.dire(" ✗ pas revenue sur un noyau -pve") + return False + + def reparer_pmxcfs(self, hote): + """Gel de cloud-init, /etc/hosts, unités, constat du montage.""" + if self.dry_run: + print(" gel cloud-init + /etc/hosts + unités + montage") + return True + _c, out = pve.run( + dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30 + ) + ip = pve.ssh_server_ip(out) + if not ip: + self.dire(" ✗ adresse d'accès inconnue") + return False + for cmd, etiquette in ( + (pve.cloud_hosts_freeze_cmd(), "gel cloud-init"), + (pve.hosts_repair_cmd(ip), "/etc/hosts"), + ): + code, sortie = self.executer( + hote, cmd, DELAIS["reparation"], etiquette + ) + if code or "-KO" in pve.strip_ssh_noise(sortie): + self.dire(f" ✗ {etiquette}") + return False + for unite in pve.PVE_UNITS: + self.executer( + hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite + ) + _c, out = self.executer( + hote, pve.mount_wait_cmd(), DELAIS["reparation"], "montage" + ) + vu = pve.parse_mount_wait(out) + self.dire(f" /etc/pve : {vu['verdict']}") + return vu["verdict"] == "MONTE" + + def preparer_parent(self, parent): + """Stockage, pont et réseau interne du parent, ou None.""" + _c, out = self.executer( + parent, + "pvesm status --content images", + DELAIS["controle"], + "pvesm", + ) + 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( + parent, "ip -o link show type bridge", DELAIS["controle"], "ponts" + ) + ponts = pve.parse_bridges(out) + if not ponts: + _c, nets = self.executer( + parent, pve.USED_NETS_CMD, DELAIS["controle"], "réseaux" + ) + cidr = pve.pick_internal_cidr(nets) or pve.INTERNAL_CIDR + _c, rt = self.executer( + parent, + "ip -o -4 route show default", + DELAIS["controle"], + "uplink", + ) + trouve = re.search(r"dev\s+(\S+)", rt or "") + uplink = trouve.group(1) if trouve else "" + self.dire(f" pont {cidr}, NAT par {uplink or '—'}") + for cmd in pve.bridge_setup_cmds(cidr=cidr, uplink=uplink): + code, _o = self.executer( + parent, cmd, DELAIS["reparation"], "pont" + ) + if code and not self.dry_run: + return None + ponts = [pve.INTERNAL_BRIDGE] + _c, cfg = self.executer( + parent, + "cat /etc/network/interfaces", + DELAIS["controle"], + "interfaces", + ) + return ( + stockage or "local", + ponts[0], + pve.parse_bridge_config(cfg).get(ponts[0], {}), + ) + + def creer_etage1(self, res): + """Une VM locale, par la CLI QEMU/KVM.""" + nom = nom_etage(1) + argv = [ + os.path.join(RACINE, ".venv.erplibre/bin/python"), + os.path.join(RACINE, "script/qemu/deploy_qemu.py"), + "--distro", + DISTRO, + "--name", + nom, + "--vcpus", + str(res["vcpu"]), + "--memory", + str(res["ram"]), + "--disk-size", + f"{res['disque']}G", + ] + pub = cle_publique() + if pub: + argv += ["--ssh-key", pub] + if self.dry_run: + print(" " + " ".join(shlex.quote(a) for a in argv)) + return nom + res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3) + if res_proc.returncode: + self.dire(" ✗ la CLI QEMU/KVM a échoué") + return None + return nom + + def creer_enfant(self, parent, niveau, res, prepare): + """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).""" + stockage, pont, info_pont = prepare + mod = module_qemu() + version = mod.DISTROS[DISTRO][1] + code_img = mod.DISTROS[DISTRO][0][version][0] + url = mod.image_url(DISTRO, code_img, "amd64", version) + image = mod.default_image_name(DISTRO, code_img, "amd64", version) + _c, out = self.executer( + parent, "qm list", DELAIS["controle"], "qm list" + ) + vmid = pve.next_vmid(pve.parse_qm_list(out)) + ipconfig = pve.ipconfig_for(info_pont, vmid) + adresse = pve.ip_from_ipconfig(ipconfig) + spec = { + "name": nom_etage(niveau), + "storage": stockage, + "image": image, + "memory": res["ram"], + "vcpus": res["vcpu"], + "bridge": pont, + "disk": f"{res['disque']}G", + "user": "erplibre", + "ipconfig": ipconfig, + "sshkey_path": "/root/.ssh/longtest.pub", + "start": True, + } + # La clé publique doit être un FICHIER sur le parent : « --sshkeys » + # n'accepte pas la clé en ligne. + pub = cle_publique() + if pub and not self.dry_run: + with open(pub, encoding="utf-8") as fh: + contenu = fh.read().strip() + self.executer( + parent, + f"mkdir -p /root/.ssh && printf '%s\\n'" + f" {shlex.quote(contenu)} > /root/.ssh/longtest.pub", + DELAIS["controle"], + "clé", + ) + for cmd in [pve.image_fetch_cmd(url, image)] + pve.create_cmds( + vmid, spec + ): + code, _o = self.executer( + parent, cmd, DELAIS["creation"], "qm create" + ) + if code and not self.dry_run: + return None, None + return vmid, adresse + + # ---------------------------------------------------------------- # + # La descente + # ---------------------------------------------------------------- # + def parcourir(self): + parent = None + parent_alias = "" + for res in self.plan["niveaux"]: + niveau = res["niveau"] + debut = time.time() + etage = { + "niveau": niveau, + "ressources": res, + "etape": "creation", + "ok": False, + } + self.dire( + f" ── étage {niveau} : {res['vcpu']} vCPU," + f" {res['ram']} Mo, {res['disque']} Go" + ) + if niveau == 1: + nom = self.creer_etage1(res) + if not nom: + self.etages.append(etage) + break + alias = nom + else: + prepare = self.preparer_parent(parent) + if not prepare: + etage["etape"] = "parent" + self.etages.append(etage) + break + vmid, adresse = self.creer_enfant(parent, niveau, res, prepare) + if vmid is None: + self.etages.append(etage) + break + etage["vmid"] = vmid + alias = alias_etage(niveau, parent_alias) + if not self.dry_run: + if not adresse: + # Sur un pont interne l'adresse est FIXE et dérivée du + # VMID. Vide, c'est que le parent n'a pas de pont + # interne — écrire un alias sans HostName donnerait + # une entrée qui ne mène nulle part. + self.dire(" ✗ pas d'adresse fixe pour l'enfant") + etage["etape"] = "adresse" + self.etages.append(etage) + break + self.ecrire_alias(alias, adresse, parent_alias) + cible = {"target": alias, "sudo": "sudo ", "jump": ""} + etage["alias"] = alias + + etage["etape"] = "ssh" + attente = self.attendre_ssh(cible, DELAIS["ssh"]) + if attente is None: + self.dire(" ✗ jamais joignable en ssh") + self.etages.append(etage) + break + etage["ssh_secondes"] = attente + self.dire(f" ssh après {attente} s") + + for etape, action in ( + ("install", lambda: self.installer_proxmox(cible)), + ("reboot", lambda: self.redemarrer_et_verifier(cible)), + ("pmxcfs", lambda: self.reparer_pmxcfs(cible)), + ): + etage["etape"] = etape + if not action(): + self.etages.append(etage) + return self.rapport(interrompu=True) + + etage["etape"] = "termine" + etage["ok"] = True + etage["secondes"] = int(time.time() - debut) + self.etages.append(etage) + self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") + parent, parent_alias = cible, alias + return self.rapport() + + def ecrire_alias(self, alias, adresse, parent_alias): + """Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent.""" + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + prive = cle_publique()[:-4] if cle_publique() else None + todo._write_ssh_config_entry( + [alias], + "erplibre", + adresse, + proxy_jump=parent_alias or None, + identity_file=prive, + ) + + def rapport(self, interrompu=False): + atteint = sum(1 for e in self.etages if e["ok"]) + print("") + self.dire( + f" profondeur atteinte : {atteint} / {self.plan['demandee']}" + ) + for e in self.etages: + marque = "✓" if e["ok"] else "✗" + detail = f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] + self.dire(f" {marque} étage {e['niveau']:2d} {detail}") + return { + "demandee": self.plan["demandee"], + "atteignable": self.plan["atteignable"], + "atteinte": atteint, + "interrompu": interrompu, + "etages": self.etages, + } + + +def detruire(journal=None): + """Défait ce que la descente a posé, du plus profond au plus haut. + + Du plus profond : détruire un parent d'abord emporterait ses enfants sans + qu'on ait pu les nommer, et laisserait des entrées ssh vers rien. + """ + from script.todo.todo import TODO + + hosts = [h for h in TODO._ssh_config_hosts() if NOM_BASE in h] + hosts.sort(key=lambda h: -h.count("+")) + dire(f" {len(hosts)} entrée(s) ssh à défaire", journal) + for alias in hosts: + bloc = TODO._ssh_config_block(alias) + saut = (bloc or {}).get("proxyjump") + if saut: + parent = {"target": saut, "sudo": "sudo ", "jump": ""} + _c, out = pve.run(parent, "qm list", 120) + for vm in pve.parse_qm_list(out): + if NOM_BASE in (vm.get("name") or ""): + dire(f" qm destroy {vm['vmid']} sur {saut}", journal) + pve.run( + parent, + f"qm stop {vm['vmid']} --skiplock 1 || true;" + f" qm destroy {vm['vmid']} --purge 1", + 300, + ) + for niveau in range(1, 30): + nom = nom_etage(niveau) + if nom in hosts or niveau == 1: + subprocess.run( + ["sudo", "virsh", "destroy", nom], + capture_output=True, + ) + subprocess.run( + [ + "sudo", + "virsh", + "undefine", + nom, + "--nvram", + "--remove-all-storage", + ], + capture_output=True, + ) + dire( + " ✓ défait. Les entrées ssh orphelines : menu de nettoyage.", journal + ) + + +def principal(argv=None): + parseur = argparse.ArgumentParser( + description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?" + ) + parseur.add_argument("--depth", type=int, default=10) + parseur.add_argument("--dry-run", action="store_true") + parseur.add_argument("--detruire", action="store_true") + args = parseur.parse_args(argv) + + journal = os.path.expanduser( + f"~/.erplibre/longtest/deep-pve-{time.strftime('%Y%m%d-%H%M%S')}.log" + ) + os.makedirs(os.path.dirname(journal), exist_ok=True) + if args.detruire: + detruire(journal) + return 0 + + coeurs, ram, disque = capacite_hote() + print( + f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f" {disque} Go de disque" + ) + plan = nesting.nesting_plan(args.depth, coeurs, ram, disque) + print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") + for n in plan["niveaux"]: + print( + f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" + f" {n['disque']:>5} Go" + ) + if plan["arret"]: + print( + f"\n ⚠ demandée {plan['demandee']}, atteignable" + f" {plan['atteignable']} — manque de {plan['arret']}" + ) + if not plan["niveaux"]: + print("\n ✗ pas même un étage ne tient sur cette machine.\n") + return 1 + print(f"\n journal : {journal}") + if args.dry_run: + print(" --dry-run : rien ne sera créé.\n") + descente = Descente(plan, journal, args.dry_run) + rapport = descente.parcourir() + chemin = journal[:-4] + ".json" + with open(chemin, "w", encoding="utf-8") as fh: + json.dump(rapport, fh, indent=2) + print(f"\n rapport : {chemin}\n") + return 0 if rapport["atteinte"] else 1 + + +if __name__ == "__main__": + sys.exit(principal()) diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py new file mode 100644 index 0000000..dc2ec52 --- /dev/null +++ b/script/proxmox/nesting.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Combien d'étages de Proxmox tiennent, et avec quelles ressources. + +Un Proxmox dans un Proxmox dans un Proxmox : chaque étage est un hyperviseur +qui héberge le suivant. Deux choses s'épuisent en descendant, et une troisième +se dégrade. + +Ce qui s'ÉPUISE — et c'est de l'arithmétique : + +* la mémoire. Chaque étage garde de quoi faire tourner ses propres démons + (pve-cluster, pvestatd, pvedaemon, pveproxy) avant de céder le reste ; +* le disque. Le disque de l'enfant vit DANS celui du parent, qui doit aussi + contenir son propre système. + +Ce qui se DÉGRADE — et c'est mesuré, pas supposé. Au quatrième étage, sur un +hôte AMD, une VM tournait 36 fois moins vite que le temps réel : 583 secondes +d'horloge pour 16 secondes de temps invité, chaque ligne d'ACPI prenant une +seconde. Chaque sortie de VM traverse tous les hyperviseurs empilés, et AMD ne +documente l'imbrication qu'à DEUX niveaux. + +Deux nombres viennent de la même mesure, et méritent d'être dits : + +* 12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début de + démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu + de plus. Les mêmes 2 vCPU avançaient. D'où VCPU_IMBRIQUE = 2 : amener douze + processeurs en ligne demande autant d'allers-retours à travers la pile ; +* la même VM s'arrêtait ensuite au MÊME octet — 33 682 432 — quelles que + soient les ressources, dans la réservation des tables ACPI. Ce mur-là n'est + pas une question de taille, et aucun réglage ici ne le déplacera. La + profondeur RÉELLEMENT atteignable se mesure ; ce module ne calcule que ce + qui est arithmétiquement possible. +""" + +# Ce qu'on laisse à la machine physique : elle fait tourner l'orchestrateur, +# le menu TODO, et le premier QEMU. +HOTE_RESERVE_RAM_MO = 4096 +HOTE_RESERVE_DISQUE_GO = 20 + +# Ce qu'un étage garde pour lui avant de céder le reste. La RAM vient de +# l'observation d'un Proxmox imbriqué au repos ; le disque, de la mesure d'un +# système installé (5,6 Go) plus de la place pour écrire. +PVE_RAM_MO = 2048 +PVE_DISQUE_GO = 10 + +# En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place +# d'importer une image cloud. +RAM_MIN_MO = 2048 +DISQUE_MIN_GO = 15 + +# Le premier étage tourne sur la machine physique : il peut être large. Les +# suivants non — voir la mesure dans l'en-tête. +VCPU_NIVEAU1_MAX = 4 +VCPU_IMBRIQUE = 2 + +# Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants. +# On ne refuse pas — on le DIT. +PROFONDEUR_SURE = 2 + + +def nesting_plan( + profondeur: int, + cpu_hote: int, + ram_dispo_mo: int, + disque_libre_go: int, +) -> dict: + """Les ressources de chaque étage, et jusqu'où l'arithmétique va. + + Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` + nomme ce qui a manqué — « ram » ou « disque » — quand la profondeur + demandée n'est pas atteinte, sinon "". + + On ne rend jamais un plan qu'on sait impossible : mieux vaut annoncer six + étages et en réussir six que d'en promettre dix et mourir au septième + sans savoir pourquoi. + """ + # 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 + disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO + niveaux, arret = [], "" + for niveau in range(1, max(1, int(profondeur)) + 1): + if niveau > 1: + ram -= PVE_RAM_MO + disque -= PVE_DISQUE_GO + if ram < RAM_MIN_MO: + arret = "ram" + break + if disque < DISQUE_MIN_GO: + arret = "disque" + break + niveaux.append( + { + "niveau": niveau, + "vcpu": ( + max(1, min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4)) + if niveau == 1 + else VCPU_IMBRIQUE + ), + "ram": ram, + "disque": disque, + } + ) + return { + "demandee": int(profondeur), + "atteignable": len(niveaux), + "niveaux": niveaux, + "arret": arret, + } + + +def depth_from_jumps(jumps: int) -> int: + """Profondeur d'un hôte, comptée depuis sa chaîne de rebonds. + + Un hôte joint sans rebond est au niveau 1 ; chaque ProxyJump ajoute un + étage. C'est la seule mesure dont on dispose de l'extérieur, et elle est + exacte pour les hôtes que nous avons nous-mêmes déployés — c'est nous qui + écrivons ces entrées. + """ + return max(1, int(jumps) + 1) + + +def capped_for_depth(profondeur: int, vcpu: int, ram_mo: int) -> tuple: + """Ressources bornées pour cette profondeur, et pourquoi. + + Rend (vcpu, ram, raison). `raison` vide quand rien n'a été touché. + + Seul le vCPU est borné, et la mesure le dit : la même VM au quatrième + étage gelait au MÊME octet avec 9 Go et avec 2 Go — la mémoire n'est pas + le levier. Douze vCPU, en revanche, gelaient plus tôt et plus dur que + deux. La RAM passe donc telle quelle : la rogner ne gagnerait rien et + priverait l'étage suivant. + + Pourquoi borner au lieu d'avertir seulement : l'écran lit la capacité de + l'HÔTE et l'offre en entier. Sur un troisième étage à 14 cœurs et 9 Go, il + a proposé 12 vCPU — et la VM n'a jamais démarré. Le nombre n'était pas + absurde pour la machine ; il l'était pour sa profondeur. + """ + vcpu, ram_mo = int(vcpu), int(ram_mo) + if profondeur <= PROFONDEUR_SURE or vcpu <= VCPU_IMBRIQUE: + return vcpu, ram_mo, "" + return ( + VCPU_IMBRIQUE, + ram_mo, + f"niveau {int(profondeur)} : {vcpu} vCPU -> {VCPU_IMBRIQUE}", + ) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py new file mode 100644 index 0000000..3d05b2f --- /dev/null +++ b/script/todo/longtest_menu.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les tests LONGS : de vraies machines, des heures. + +Ils vivent dans `LongTest/` et non dans `test/`, et ce n'est pas un rangement +de confort : le lanceur unitaire balaie `test/test_*.py` et doit rester +lançable en quelques secondes, partout. Un test qui crée dix VM n'a rien à y +faire — il le ferait échouer sur toute machine sans virtualisation, et +personne ne l'attendrait. + +Ce menu ne fait que les lancer, en montrant leur sortie en direct : ces +scripts durent des heures, et une sortie capturée jusqu'à la fin ne dirait +rien pendant tout ce temps. +""" + +import os + +import click + +from script.todo.todo_i18n import t + +# Le répertoire des tests longs, à la racine du dépôt. +LONGTEST_DIR = "LongTest" + + +class LongTestMenuMixin: + def _longtest_script(self, nom): + """Chemin d'un test long, ou "" s'il n'est pas là.""" + chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom) + return chemin if os.path.exists(chemin) else "" + + def _longtest_run(self, nom, args=""): + """Lance un test long, sortie en DIRECT. + + En direct parce qu'il dure des heures : capturer sa sortie pour + l'afficher à la fin, c'est ne rien montrer pendant tout ce temps — + et c'est justement la progression étage par étage qui intéresse. + """ + chemin = self._longtest_script(nom) + if not chemin: + print(f" ✗ {t('Script not found:')} {LONGTEST_DIR}/{nom}") + return + cmd = f"./.venv.erplibre/bin/python {chemin}" + if args: + cmd += f" {args}" + print(f"\n{t('Will execute:')} {cmd}") + self.execute.exec_command_live(cmd, source_erplibre=False) + + def prompt_execute_longtest(self): + print(f"⏳ {t('Long tests: real VMs, hours. Not the unit suite.')}") + choices = [ + { + "prompt_description": t( + "Nested Proxmox depth: plan only (dry-run)" + ) + }, + {"prompt_description": t("Nested Proxmox depth: run it")}, + {"prompt_description": t("Undo what the descent created")}, + ] + help_info = self.fill_help_info(choices) + while True: + status = click.prompt(help_info) + print() + if status == "0": + return False + if status == "1": + self._longtest_run( + "deep_proxmox.py", + f"--depth {self._longtest_depth()} --dry-run", + ) + elif status == "2": + # La profondeur est DEMANDÉE : c'est le seul réglage du test, + # et il décide de sa durée — dix étages, c'est une nuit. + self._longtest_run( + "deep_proxmox.py", f"--depth {self._longtest_depth()}" + ) + elif status == "3": + self._longtest_run("deep_proxmox.py", "--detruire") + else: + print(t("Command not found !")) + + def _longtest_depth(self): + """Profondeur demandée. Dix par défaut : c'est ce qu'on veut mesurer.""" + brut = input(f"{t('Depth (default 10): ')}").strip() + return int(brut) if brut.isdigit() and int(brut) > 0 else 10 diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 3d86b7f..f128fc0 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -720,6 +720,46 @@ class ProxmoxMenuMixin: lignes.append(f"→ {t('This screen can repair it (see below).')}") return lignes + def _pve_depth(self, host): + """À quel étage d'imbrication se trouve cet hôte. 1 = machine réelle. + + Comptée sur la chaîne de ProxyJump : un rebond par étage. C'est nous + qui écrivons ces entrées, donc la mesure est exacte pour notre parc. + """ + from script.proxmox import nesting + + return nesting.depth_from_jumps( + self._ssh_jump_depth(host.get("target") or "") + ) + + def _pve_depth_note(self, host, cpu): + """(cpu borné, lignes à dire). Ce que la profondeur impose. + + L'écran lisait la capacité de l'HÔTE et l'offrait en entier. Sur un + troisième étage à 14 cœurs, il a proposé 12 vCPU — et la VM n'a jamais + démarré : même RIP à trois relevés deux minutes d'écart, pas un octet + lu de plus. Le nombre n'était pas absurde pour la machine ; il l'était + pour sa profondeur. + + Un seul levier, le vCPU : la même VM gelait au MÊME octet avec 9 Go et + avec 2 Go, donc rogner la mémoire ne gagnerait rien et priverait + l'étage suivant. + """ + from script.proxmox import nesting + + profondeur = self._pve_depth(host) + borne, _ram, raison = nesting.capped_for_depth(profondeur, cpu, 0) + if profondeur <= nesting.PROFONDEUR_SURE: + return cpu, [] + lignes = [ + f"⚠ {t('Nesting level')} {profondeur} —" + f" {t('vendors document two, not more.')}", + f" {t('Measured at level 4: 36x slower, then a frozen kernel.')}", + ] + if raison: + lignes.append(f" {t('vCPU capped to')} {borne}") + return borne, lignes + def _pve_ssh_ip(self, host): """Adresse par laquelle NOTRE ssh atteint l'hôte, ou "". @@ -1088,6 +1128,12 @@ class ProxmoxMenuMixin: _c, cfg = self._pve_show("cat /etc/network/interfaces", quiet=True) infos = pve.parse_bridge_config(cfg) cpu, ram_libre = self._pve_capacity() + # La profondeur borne ce que l'écran offre. Ici, terminal encore à + # nous : une fois Textual à l'affiche, ces lignes n'auraient nulle + # part où aller. + cpu, notes_profondeur = self._pve_depth_note(host, cpu) + for ligne in notes_profondeur: + print(f" {ligne}") # Le DNS de l'hôte, pour les VM en adresse fixe : sans lui elles # routent mais ne résolvent rien, et « apt update » échoue sans que # rien ne l'explique. Mesuré sur la VM d'essai. diff --git a/script/todo/todo.py b/script/todo/todo.py index 4ac7732..cd9acf1 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -28,6 +28,7 @@ from script.config import config_file from script.execute import execute from script.todo import todo_prefs from script.todo.database_manager import DatabaseManager +from script.todo.longtest_menu import LongTestMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin from script.todo.qemu_access import QemuAccessMixin from script.todo.qemu_deploy import QemuDeployMixin @@ -96,6 +97,7 @@ class TODO( QemuManageMixin, QemuAccessMixin, ProxmoxMenuMixin, + LongTestMenuMixin, ): def __init__(self): self.dir_path = None @@ -1752,6 +1754,30 @@ class TODO( bloc[mots[0].lower()] = mots[1] return bloc or {} + @classmethod + def _ssh_jump_depth(cls, cible, maxi=12): + """Nombre de rebonds pour joindre `cible`, en suivant la chaîne. + + C'est la mesure de PROFONDEUR d'un hôte imbriqué, et la seule dont on + dispose de l'extérieur. Elle est exacte pour les hôtes que nous avons + déployés : c'est nous qui écrivons ces entrées, un ProxyJump par + étage. + + `maxi` borne le parcours : une boucle dans ~/.ssh/config — A qui + rebondit par B qui rebondit par A — tournerait sinon sans fin. + """ + vus, sauts = set(), 0 + courant = cible + while sauts < maxi: + bloc = cls._ssh_config_block(courant) + saut = (bloc or {}).get("proxyjump") + if not saut or saut in vus: + break + vus.add(saut) + courant = saut + sauts += 1 + return sauts + @staticmethod def _ssh_config_user(host): """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". @@ -4134,6 +4160,9 @@ class TODO( {"prompt_description": t("ERPLibre unit tests")}, {"prompt_description": t("Mail unit tests")}, {"prompt_description": t("Analyse unit tests")}, + # Hors de la suite unitaire, et le libellé le dit : ceux-là créent + # de vraies machines et durent des heures. + {"prompt_description": t("Long tests - real VMs, hours")}, ] help_info = self.fill_help_info(choices) @@ -4152,6 +4181,8 @@ class TODO( self.execute_unit_tests("test_mail*.py") elif status == "5": self.execute_unit_tests("test_analyse*.py") + elif status == "6": + self.prompt_execute_longtest() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 213b843..95d1d13 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3422,6 +3422,50 @@ TRANSLATIONS = { "fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.", "en": "The address written may not be the one pmxcfs needs.", }, + "Long tests - real VMs, hours": { + "fr": "⏳ Tests longs - vraies VM, des heures", + "en": "⏳ Long tests - real VMs, hours", + }, + "Long tests: real VMs, hours. Not the unit suite.": { + "fr": "Tests longs : de vraies VM, des heures. Pas la suite unitaire.", + "en": "Long tests: real VMs, hours. Not the unit suite.", + }, + "Nested Proxmox depth: plan only (dry-run)": { + "fr": "Profondeur Proxmox imbriqué : le plan seulement (à blanc)", + "en": "Nested Proxmox depth: plan only (dry-run)", + }, + "Nested Proxmox depth: run it": { + "fr": "Profondeur Proxmox imbriqué : le lancer", + "en": "Nested Proxmox depth: run it", + }, + "Undo what the descent created": { + "fr": "Défaire ce que la descente a créé", + "en": "Undo what the descent created", + }, + "Script not found:": { + "fr": "Script introuvable :", + "en": "Script not found:", + }, + "Depth (default 10): ": { + "fr": "Profondeur (défaut 10) : ", + "en": "Depth (default 10): ", + }, + "Nesting level": { + "fr": "Étage d'imbrication", + "en": "Nesting level", + }, + "vendors document two, not more.": { + "fr": "les fabricants en documentent deux, pas plus.", + "en": "vendors document two, not more.", + }, + "Measured at level 4: 36x slower, then a frozen kernel.": { + "fr": "Mesuré au niveau 4 : 36x plus lent, puis noyau gelé.", + "en": "Measured at level 4: 36x slower, then a frozen kernel.", + }, + "vCPU capped to": { + "fr": "vCPU borné à", + "en": "vCPU capped to", + }, "pve-cluster is down: /etc/pve is not mounted.": { "fr": "pve-cluster est à terre : /etc/pve n'est pas monté.", "en": "pve-cluster is down: /etc/pve is not mounted.", diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py new file mode 100644 index 0000000..1cb7eaf --- /dev/null +++ b/test/test_proxmox_nesting.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Combien d'étages de Proxmox tiennent, et avec quelles ressources. + +L'écran de déploiement lisait la capacité de l'HÔTE et l'offrait en entier. +Sur un troisième étage à 14 cœurs et 9 Go de libre, il a proposé 12 vCPU et +9 Go à une VM qui n'a jamais démarré : même RIP à trois relevés deux minutes +d'écart, pas un octet lu de plus. Le nombre n'était pas absurde pour la +machine ; il l'était pour sa profondeur. +""" + +import sys +import unittest + +sys.argv = ["todo.py"] +from script.proxmox import nesting # noqa: E402 + + +class TestLePlanDesEtages(unittest.TestCase): + """Deux ressources s'épuisent en descendant, et le plan doit le dire + AVANT de créer quoi que ce soit.""" + + # La machine réelle sur laquelle l'algorithme a été réglé. + HOTE = dict(cpu_hote=28, ram_dispo_mo=29549, disque_libre_go=139) + + def test_ten_levels_fit_on_this_machine(self): + plan = nesting.nesting_plan(10, **self.HOTE) + self.assertEqual(plan["atteignable"], 10) + self.assertEqual(plan["arret"], "") + + def test_every_level_shrinks(self): + # Le disque de l'enfant vit DANS celui du parent, qui doit aussi + # contenir son propre système : rien ne peut rester constant. + niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] + for precedent, suivant in zip(niveaux, niveaux[1:]): + self.assertLess(suivant["ram"], precedent["ram"]) + self.assertLess(suivant["disque"], precedent["disque"]) + + def test_the_first_level_may_be_wide_the_others_not(self): + """12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début + de démarrage ; les mêmes 2 vCPU avançaient. Amener douze processeurs + en ligne demande autant d'allers-retours à travers la pile.""" + niveaux = nesting.nesting_plan(4, **self.HOTE)["niveaux"] + self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE - 1) + for n in niveaux[1:]: + self.assertEqual(n["vcpu"], nesting.VCPU_IMBRIQUE) + + def test_running_out_of_ram_is_named(self): + plan = nesting.nesting_plan( + 10, cpu_hote=8, ram_dispo_mo=12288, disque_libre_go=500 + ) + self.assertEqual(plan["arret"], "ram") + self.assertLess(plan["atteignable"], 10) + # Aucun étage sous le plancher : un Proxmox sous 2 Go ne démarre pas + # ses démons. + for n in plan["niveaux"]: + self.assertGreaterEqual(n["ram"], nesting.RAM_MIN_MO) + + def test_running_out_of_disk_is_named(self): + plan = nesting.nesting_plan( + 10, cpu_hote=8, ram_dispo_mo=200000, disque_libre_go=60 + ) + self.assertEqual(plan["arret"], "disque") + for n in plan["niveaux"]: + self.assertGreaterEqual(n["disque"], nesting.DISQUE_MIN_GO) + + def test_a_machine_too_small_for_even_one_level(self): + plan = nesting.nesting_plan( + 3, cpu_hote=2, ram_dispo_mo=4096, disque_libre_go=200 + ) + self.assertEqual(plan["atteignable"], 0) + self.assertEqual(plan["niveaux"], []) + self.assertEqual(plan["arret"], "ram") + + def test_a_plan_is_never_promised_beyond_what_fits(self): + # Mieux vaut annoncer six étages et en réussir six que d'en promettre + # dix et mourir au septième sans savoir pourquoi. + for profondeur in range(1, 13): + plan = nesting.nesting_plan(profondeur, **self.HOTE) + self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) + self.assertLessEqual(plan["atteignable"], profondeur) + + +class TestLaProfondeurDUnHote(unittest.TestCase): + """Comptée depuis la chaîne de rebonds : c'est la seule mesure dont on + dispose de l'extérieur, et elle est exacte pour les hôtes que nous avons + nous-mêmes déployés — c'est nous qui écrivons ces entrées.""" + + def test_no_jump_is_the_first_level(self): + self.assertEqual(nesting.depth_from_jumps(0), 1) + + def test_each_jump_adds_a_level(self): + for sauts, attendu in ((1, 2), (2, 3), (3, 4), (9, 10)): + self.assertEqual(nesting.depth_from_jumps(sauts), attendu) + + +class TestCompterLesRebonds(unittest.TestCase): + """La profondeur se lit dans ~/.ssh/config : un ProxyJump par étage. + + Hermétique — une configuration synthétique. La vraie a été nettoyée entre + deux mesures, et un test qui dépend de la machine qui le lance ne prouve + rien le lendemain.""" + + CONFIG = """ +Host niveau1 + HostName 192.168.1.10 + +Host niveau2 + HostName 10.10.10.150 + ProxyJump niveau1 + +Host niveau3 + HostName 10.10.20.150 + ProxyJump niveau2 + +Host niveau4 + HostName 10.10.10.150 + ProxyJump niveau3 + +Host boucle-a + ProxyJump boucle-b + +Host boucle-b + ProxyJump boucle-a +""" + + def setUp(self): + import os + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.maison = tempfile.mkdtemp() + os.makedirs(os.path.join(self.maison, ".ssh")) + with open( + os.path.join(self.maison, ".ssh/config"), "w", encoding="utf-8" + ) as fh: + fh.write(self.CONFIG) + self._vrai = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.TODO = TODO + + def tearDown(self): + import os + import shutil + + if self._vrai is not None: + os.environ["HOME"] = self._vrai + shutil.rmtree(self.maison, ignore_errors=True) + + def test_each_level_is_counted(self): + for nom, attendu in ( + ("niveau1", 1), + ("niveau2", 2), + ("niveau3", 3), + ("niveau4", 4), + ): + with self.subTest(hote=nom): + sauts = self.TODO._ssh_jump_depth(nom) + self.assertEqual(nesting.depth_from_jumps(sauts), attendu) + + def test_an_unknown_host_is_the_first_level(self): + self.assertEqual(self.TODO._ssh_jump_depth("jamais-vu"), 0) + + def test_a_loop_does_not_spin_forever(self): + # A rebondit par B qui rebondit par A : sans garde, le parcours ne + # s'arrête jamais. + self.assertLessEqual(self.TODO._ssh_jump_depth("boucle-a"), 2) + + +class TestBornerCeQueLEcranOffre(unittest.TestCase): + def test_the_first_two_levels_are_left_alone(self): + # L'imbrication à deux niveaux est documentée par les fabricants : on + # n'a rien à corriger là. + for profondeur in (1, 2): + self.assertEqual( + nesting.capped_for_depth(profondeur, 12, 9216), + (12, 9216, ""), + ) + + def test_beyond_that_the_vcpu_is_capped_and_said(self): + vcpu, ram, raison = nesting.capped_for_depth(3, 12, 9216) + self.assertEqual(vcpu, nesting.VCPU_IMBRIQUE) + self.assertTrue(raison) + self.assertIn("12", raison) + + def test_the_ram_is_never_touched(self): + """La même VM gelait au MÊME octet avec 9 Go et avec 2 Go : la + mémoire n'est pas le levier. La rogner ne gagnerait rien et priverait + l'étage suivant.""" + for profondeur in (1, 3, 8): + _v, ram, _r = nesting.capped_for_depth(profondeur, 12, 9216) + self.assertEqual(ram, 9216) + + def test_a_modest_request_is_not_reported_as_capped(self): + # Rien n'a bougé : ne rien dire. Un avertissement à chaque + # déploiement finit par ne plus être lu. + self.assertEqual(nesting.capped_for_depth(5, 2, 4096), (2, 4096, "")) + self.assertEqual(nesting.capped_for_depth(5, 1, 4096), (1, 4096, "")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py new file mode 100644 index 0000000..0202412 --- /dev/null +++ b/test/test_todo_longtest.py @@ -0,0 +1,142 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Les tests LONGS : qu'ils existent, qu'ils annoncent, et qu'ils ne +polluent pas la suite unitaire. + +Un test qui crée dix VM n'a rien à faire dans `test/` : le lanceur unitaire +doit rester lançable en quelques secondes, partout, y compris sur une machine +sans virtualisation. Ce fichier-ci vérifie la frontière, et que l'essai à +blanc du test long dit quelque chose sans rien créer. +""" + +import os +import subprocess +import sys +import unittest + +sys.argv = ["todo.py"] +from script.todo.todo import TODO # noqa: E402 + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PYTHON = os.path.join(RACINE, ".venv.erplibre/bin/python") + + +class TestLaFrontiere(unittest.TestCase): + """LongTest est hors de portée du lanceur unitaire, et ce n'est pas un + rangement de confort.""" + + def test_the_unit_runner_does_not_sweep_LongTest(self): + with open( + os.path.join(RACINE, "script/test/run_unit_test.sh"), + encoding="utf-8", + ) as fh: + lanceur = fh.read() + # Le lanceur ne liste que des fichiers de test/ : rien qui parte de + # LongTest, sinon la suite unitaire créerait des VM. + self.assertNotIn("LongTest", lanceur) + + def test_the_naming_rule_is_written_where_it_is_read(self): + # Un fichier hors préfixe tombe dans le même silence qu'un fichier + # absent : douze tests écrits, jamais lancés. + with open( + os.path.join(RACINE, "script/test/run_unit_test.sh"), + encoding="utf-8", + ) as fh: + self.assertIn("NOMMER UN NOUVEAU FICHIER", fh.read()) + + def test_the_script_is_executable_and_documented(self): + script = os.path.join(RACINE, "LongTest/deep_proxmox.py") + self.assertTrue(os.access(script, os.X_OK), "doit être exécutable") + # La doc est un .base.md : un .md généré se perd au prochain + # « make doc_markdown ». + self.assertTrue( + os.path.exists(os.path.join(RACINE, "LongTest/README.base.md")) + ) + + +class TestLEssaiABlanc(unittest.TestCase): + """L'essai à blanc annonce le plan et n'exécute RIEN. + + C'est ce qui rend un test de plusieurs heures relisable avant de le + lancer : on voit les ressources de chaque étage et les commandes, sans + créer une machine.""" + + @classmethod + def setUpClass(cls): + cls.res = subprocess.run( + [ + PYTHON, + os.path.join(RACINE, "LongTest/deep_proxmox.py"), + "--depth", + "4", + "--dry-run", + ], + capture_output=True, + text=True, + timeout=180, + cwd=RACINE, + env=dict(os.environ, PYTHONPATH=RACINE), + ) + + def test_it_exits_cleanly(self): + self.assertEqual(self.res.returncode, 0, self.res.stderr[-800:]) + + def test_it_announces_the_plan_before_anything(self): + sortie = self.res.stdout + self.assertIn("étage", sortie) + # Quatre étages demandés, quatre lignes de plan. + for niveau in ("1", "2", "3", "4"): + self.assertIn(niveau, sortie) + self.assertIn("dry-run", sortie) + + def test_it_shows_the_commands_it_would_send(self): + # Une étape affichée est une étape rejouable à la main : c'est ainsi + # que les pannes de ce module ont été diagnostiquées. + self.assertIn("qm create", self.res.stdout) + self.assertIn("install_proxmox.sh", self.res.stdout) + + def test_the_first_level_is_wide_and_the_others_are_not(self): + # 12 vCPU au quatrième étage ont gelé un noyau invité ; deux + # avançaient. + # Par expression exacte : la ligne « machine : … Mo … Go » du haut + # contient les mêmes unités et décalait l'index d'un cran. + import re + + plan = re.findall( + r"^\s+(\d+)\s+(\d+)\s+(\d+) Mo\s+(\d+) Go\s*$", + self.res.stdout, + re.M, + ) + self.assertEqual(len(plan), 4, plan) + niveaux = {int(n): int(v) for n, v, _r, _d in plan} + self.assertGreater(niveaux[1], 1, "le premier étage peut être large") + for niveau in (2, 3, 4): + self.assertEqual(niveaux[niveau], 2, f"étage {niveau}") + + +class TestLeMenu(unittest.TestCase): + def test_the_mixin_is_wired_into_TODO(self): + todo = TODO.__new__(TODO) + self.assertTrue(hasattr(todo, "prompt_execute_longtest")) + + def test_the_script_is_found_from_the_repository_root(self): + todo = TODO.__new__(TODO) + ancien = os.getcwd() + try: + os.chdir(RACINE) + self.assertTrue(todo._longtest_script("deep_proxmox.py")) + self.assertFalse(todo._longtest_script("nexiste-pas.py")) + finally: + os.chdir(ancien) + + def test_the_test_menu_offers_it(self): + import inspect + + src = inspect.getsource(TODO.prompt_execute_test) + self.assertIn("prompt_execute_longtest", src) + self.assertIn("Long tests", src) + + +if __name__ == "__main__": + unittest.main(verbosity=2) From 9bce1a85035c93c898e20f6949de4cc02d390be1 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 26 Aug 2026 07:00:30 -0400 Subject: [PATCH 08/26] [FIX] LongTest : sh au lieu de bash, et --detruire trop large MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Attaqué par trois lentilles sur le code écrit, avant de le lancer pour de vrai. Deux fautes valaient à elles seules l'exercice. Il n'aurait JAMAIS fonctionné. L'installeur était lancé par « sh », or il porte « set -euo pipefail » et un shebang bash : sur Debian /bin/sh est dash, qui répond « set: Illegal option -o pipefail » et sort à la PREMIÈRE ligne. Chaque étage aurait échoué sur l'installation, à tous les coups. Et « --detruire » pouvait emporter une machine étrangère. Il prenait toute entrée ssh dont le nom CONTENAIT « deep-pve », puis sur son rebond détruisait toute VM dont le nom contenait « deep-pve » — une « deep-pve-lab » de production tombait dedans, et « --purge » emporte les disques. Son tri « du plus profond au plus haut » comptait les « + » de l'alias, or alias_etage remplace le « + » du parent par un « - » : chaque alias en portait exactement UN, le tri ne triait rien, et la destruction partait du plus HAUT — le disque du parent emportait ses enfants sans qu'on les ait nommés. Il ignorait « --dry-run », ne lisait aucun code de retour, concluait « ✓ défait », et le menu le lançait d'une touche. Il ne détruit plus que ce que le RAPPORT nomme : un couple (parent, VMID) par étage, du plus profond d'après le niveau lu, égalité stricte du nom, arrêt CONSTATÉ avant destruction, codes de retour lus, et une confirmation par « OUI » après la liste. Six autres constats, tous réels. Le redémarrage se prouve par btime et non par le seul noyau — rejoué sur un étage déjà installé, on validait un redémarrage qui n'avait pas eu lieu, exactement le piège corrigé la semaine dernière dans le suivi. La sonde de disponibilité ne demande plus sudo, sinon un sudo lent se lisait « jamais joignable en ssh ». Les délais suivent la profondeur : le script existe pour mesurer un ralentissement de 36x, et un plafond fixe déclarait échouée une installation qui avançait. L'adresse fixe est contrôlée AVANT de télécharger une image et de démarrer une VM. Le DNS de l'hôte suit la spec, sinon apt meurt sans rien expliquer. Et l'essai à blanc ne prétend plus avoir atteint quoi que ce soit — son rapport était indiscernable d'une réussite, JSON compris. L'algorithme aussi : profondeur 0 rendait un plan d'UN étage, donc « --depth 0 » créait une VM ; et sur un hôte de quatre cœurs le premier étage recevait UN vCPU quand son invité en recevait deux — un parent plus étroit que son enfant. Les tests mordent, prouvé par mutation : remplacer le calcul du premier étage par la valeur imbriquée les laissait verts. --- EN --- Attacked by three lenses on the written code, before running it for real. Two faults alone justified the exercise. It would NEVER have worked. The installer was run by "sh", yet it carries "set -euo pipefail" and a bash shebang: on Debian /bin/sh is dash, which answers "set: Illegal option -o pipefail" and exits on the FIRST line. Every level would have failed at install, every time. And "--detruire" could take a stranger's machine. It took every ssh entry whose name CONTAINED "deep-pve", then on its jump host destroyed every VM whose name contained "deep-pve" — a production "deep-pve-lab" fell in, and "--purge" takes the disks. Its "deepest first" sort counted the "+" in the alias, yet alias_etage replaces the parent's "+" with a "-": every alias had exactly ONE, the sort sorted nothing, and destruction started from the TOP — the parent's disk took its children with it, unnamed. It ignored "--dry-run", read no return code, concluded "✓ done", and the menu fired it on one key. It now destroys only what the REPORT names: a (parent, VMID) pair per level, deepest first by the recorded level, strict name equality, shutdown VERIFIED before destruction, return codes read, and a "OUI" confirmation after the list. Six more findings, all real. The reboot is proven by btime, not by the kernel alone — replayed on an already-installed level, we validated a reboot that never happened, exactly the trap fixed last week in the monitor. The liveness probe no longer asks for sudo, or a slow sudo read as "never reachable by ssh". Timeouts follow the depth: the script exists to measure a 36x slowdown, and a fixed ceiling declared failed an install that was progressing. The static address is checked BEFORE downloading an image and starting a VM. The host's DNS follows the spec, or apt dies explaining nothing. And the dry run no longer claims to have reached anything — its report was indistinguishable from a success, JSON included. The algorithm too: depth 0 returned a ONE-level plan, so "--depth 0" created a VM; and on a four-core host the first level got ONE vCPU while its guest got two — a parent narrower than its child. The tests bite, proven by mutation: replacing the first level's computation with the nested value left them green. Assisted-by: Claude Opus 5 (cherry picked from commit 64b8e5063bd7f420cdeb27b88f94043190b5ecd4) --- LongTest/deep_proxmox.py | 430 ++++++++++++++++++++++++++++------- script/proxmox/nesting.py | 15 +- script/todo/longtest_menu.py | 8 +- script/todo/todo_i18n.py | 4 + test/test_proxmox_nesting.py | 36 ++- test/test_todo_longtest.py | 160 ++++++++++++- 6 files changed, 551 insertions(+), 102 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 1bed45f..2348805 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -135,10 +135,28 @@ class Descente: self.journal = journal self.dry_run = dry_run self.etages = [] + self.interrompu = False + self.niveau_courant = 1 def dire(self, msg): dire(msg, self.journal) + def delai(self, etape): + """Le délai de cette étape, à l'étage courant. + + Constant, il contredisait la raison d'être du script : au quatrième + étage un invité tournait 36 fois moins vite. Une installation de dix + minutes au premier étage en demande des heures au quatrième, et le + plafond fixe la déclarait échouée — en concluant à un mur + d'imbrication là où il n'y avait qu'un délai trop court. + + Le facteur est CARRÉ et borné : chaque étage ajoute une couche + d'hyperviseur à traverser, mais un facteur illimité rendrait un + échec réel indiscernable d'une attente sans fin. + """ + facteur = min(max(1, self.niveau_courant), 5) ** 2 + return DELAIS[etape] * facteur + # ---------------------------------------------------------------- # # Parler aux machines # ---------------------------------------------------------------- # @@ -171,13 +189,26 @@ class Descente: if self.dry_run: return 0 debut = time.time() + # SANS privilège : wrap_privilege transformerait « true » en + # « sudo sh -c true », et un sudo qui réclame un mot de passe — le + # temps que cloud-init écrive /etc/sudoers.d — se lisait « jamais + # joignable en ssh ». Le transport marchait ; c'est le diagnostic qui + # était faux. + sonde = dict(hote, sudo="") while time.time() - debut < delai: - code, _o = pve.run(hote, "true", 30) + code, _o = pve.run(sonde, "true", 60) if code == 0: return int(time.time() - debut) time.sleep(15) return None + def sudo_pret(self, hote): + """sudo répond-il sans mot de passe ? Nommé à part de ssh.""" + if self.dry_run: + return True + code, _o = pve.run(hote, "true", 60) + return code == 0 + # ---------------------------------------------------------------- # # Les six étapes, les mêmes à chaque étage # ---------------------------------------------------------------- # @@ -187,7 +218,7 @@ class Descente: distant = "/tmp/install_proxmox.sh" if self.dry_run: print(f" scp {local} :{distant}") - print(f" sh {distant}") + print(f" bash {distant}") return True argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande cible = argv[-1] @@ -201,10 +232,14 @@ class Descente: if res.returncode: self.dire(f" ✗ scp : {res.stderr.strip()[:200]}") return False + # « bash » et non « sh » : le script porte « set -euo pipefail » et un + # shebang bash. Sur Debian /bin/sh est dash, qui répond « set: Illegal + # option -o pipefail » et sort à la PREMIÈRE ligne — vérifié. Chaque + # étage aurait échoué sur l'installation, à tous les coups. code, _o = self.executer( dict(hote, sudo=""), - f"sh {distant}", - DELAIS["install"], + f"bash {distant}", + self.delai("install"), "install_proxmox.sh", montrer=True, ) @@ -219,19 +254,36 @@ class Descente: dépouillé de tout netfilter : ni pont NAT, ni invité. """ if self.dry_run: - print(" reboot puis attente de *-pve dans uname -r") + print(" reboot, puis btime changé ET *-pve dans uname -r") return True + # L'instant de démarrage AVANT : le noyau seul ne prouve rien. Rejoué + # sur un étage déjà installé, le script est idempotent et ne redémarre + # pas ; vingt secondes après l'ordre, sshd répond encore et la machine + # tourne DÉJÀ sur -pve. On validait donc un redémarrage qui n'avait pas + # eu lieu, et l'étape suivante tombait sur une machine en train de + # s'éteindre — avec un diagnostic sans rapport. Même piège que celui + # corrigé dans le suivi d'installation, refait ici. + _c, out = pve.run(dict(hote, sudo=""), "stat -c %Y /proc/1", 60) + avant = pve.strip_ssh_noise(out).strip() pve.run(hote, "systemctl reboot", 60) debut = time.time() - while time.time() - debut < DELAIS["reboot"]: + while time.time() - debut < self.delai("reboot"): time.sleep(20) - code, out = pve.run(dict(hote, sudo=""), "uname -r", 30) - noyau = pve.strip_ssh_noise(out).strip() - if code == 0 and "-pve" in noyau: - self.dire( - f" noyau {noyau} après {int(time.time() - debut)} s" - ) - return True + code, out = pve.run( + dict(hote, sudo=""), "uname -r; stat -c %Y /proc/1", 60 + ) + lignes = pve.strip_ssh_noise(out).strip().splitlines() + if code or len(lignes) < 2: + continue + noyau, apres = lignes[0].strip(), lignes[-1].strip() + if "-pve" not in noyau: + continue + if avant and apres == avant: + continue # elle n'a pas encore redémarré + self.dire( + f" noyau {noyau} après {int(time.time() - debut)} s" + ) + return True self.dire(" ✗ pas revenue sur un noyau -pve") return False @@ -252,7 +304,7 @@ class Descente: (pve.hosts_repair_cmd(ip), "/etc/hosts"), ): code, sortie = self.executer( - hote, cmd, DELAIS["reparation"], etiquette + hote, cmd, self.delai("reparation"), etiquette ) if code or "-KO" in pve.strip_ssh_noise(sortie): self.dire(f" ✗ {etiquette}") @@ -262,7 +314,7 @@ class Descente: hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite ) _c, out = self.executer( - hote, pve.mount_wait_cmd(), DELAIS["reparation"], "montage" + hote, pve.mount_wait_cmd(), self.delai("reparation"), "montage" ) vu = pve.parse_mount_wait(out) self.dire(f" /etc/pve : {vu['verdict']}") @@ -281,12 +333,15 @@ class Descente: self.dire(" ✗ aucun stockage sur le parent") return None _c, out = self.executer( - parent, "ip -o link show type bridge", DELAIS["controle"], "ponts" + parent, + "ip -o link show type bridge", + self.delai("controle"), + "ponts", ) ponts = pve.parse_bridges(out) if not ponts: _c, nets = self.executer( - parent, pve.USED_NETS_CMD, DELAIS["controle"], "réseaux" + parent, pve.USED_NETS_CMD, self.delai("controle"), "réseaux" ) cidr = pve.pick_internal_cidr(nets) or pve.INTERNAL_CIDR _c, rt = self.executer( @@ -311,10 +366,18 @@ class Descente: DELAIS["controle"], "interfaces", ) + # Le DNS de l'hôte. « --ipconfig0 » ne le porte PAS : une VM en + # adresse fixe route mais ne résout rien, et install_proxmox.sh meurt + # sur « apt update » sans que rien ne l'explique. Le rapport imputerait + # à l'installation ce qui est un défaut de résolveur. + _c, resolv = self.executer( + parent, pve.RESOLV_CMD, self.delai("controle"), "resolv" + ) return ( stockage or "local", ponts[0], pve.parse_bridge_config(cfg).get(ponts[0], {}), + pve.parse_nameservers(resolv), ) def creer_etage1(self, res): @@ -348,18 +411,34 @@ class Descente: def creer_enfant(self, parent, niveau, res, prepare): """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None).""" - stockage, pont, info_pont = prepare + stockage, pont, info_pont, dns = prepare mod = module_qemu() version = mod.DISTROS[DISTRO][1] code_img = mod.DISTROS[DISTRO][0][version][0] url = mod.image_url(DISTRO, code_img, "amd64", version) image = mod.default_image_name(DISTRO, code_img, "amd64", version) _c, out = self.executer( - parent, "qm list", DELAIS["controle"], "qm list" + parent, "qm list", self.delai("controle"), "qm list" ) vmid = pve.next_vmid(pve.parse_qm_list(out)) ipconfig = pve.ipconfig_for(info_pont, vmid) adresse = pve.ip_from_ipconfig(ipconfig) + # AVANT de télécharger l'image et de démarrer quoi que ce soit : sur un + # pont relié au LAN, ipconfig_for rend « ip=dhcp » et l'adresse est + # vide. Le contrôle venait après la création : on laissait une VM + # allumée, un disque alloué, et une machine que --detruire ne + # connaissait pas. + if not adresse and self.dry_run: + # En essai à blanc on n'a rien lu du parent : conclure « pas de + # pont interne » serait une affirmation tirée d'une mesure qui + # n'a pas eu lieu. On prend une adresse plausible pour dérouler + # le plan jusqu'au bout. + adresse = "10.10.10.150" + ipconfig = f"ip={adresse}/24,gw=10.10.10.1" + if not adresse: + self.dire(" ✗ pas d'adresse fixe : le parent n'a pas de") + self.dire(" pont interne, et l'enfant serait injoignable") + return None, None spec = { "name": nom_etage(niveau), "storage": stockage, @@ -371,6 +450,7 @@ class Descente: "user": "erplibre", "ipconfig": ipconfig, "sshkey_path": "/root/.ssh/longtest.pub", + "nameservers": dns, "start": True, } # La clé publique doit être un FICHIER sur le parent : « --sshkeys » @@ -390,7 +470,7 @@ class Descente: vmid, spec ): code, _o = self.executer( - parent, cmd, DELAIS["creation"], "qm create" + parent, cmd, self.delai("creation"), "qm create" ) if code and not self.dry_run: return None, None @@ -404,6 +484,7 @@ class Descente: parent_alias = "" for res in self.plan["niveaux"]: niveau = res["niveau"] + self.niveau_courant = niveau debut = time.time() etage = { "niveau": niveau, @@ -419,6 +500,7 @@ class Descente: nom = self.creer_etage1(res) if not nom: self.etages.append(etage) + self.interrompu = True break alias = nom else: @@ -426,32 +508,31 @@ class Descente: if not prepare: etage["etape"] = "parent" self.etages.append(etage) + self.interrompu = True break vmid, adresse = self.creer_enfant(parent, niveau, res, prepare) if vmid is None: self.etages.append(etage) + self.interrompu = True break etage["vmid"] = vmid + # Le parent est noté AVANT tout autre contrôle : c'est le seul + # enregistrement de ce qu'on vient de créer, et --detruire s'en + # sert. Sans lui, une VM abandonnée juste après « qm create » + # n'était nommée nulle part. + etage["parent_alias"] = parent_alias alias = alias_etage(niveau, parent_alias) if not self.dry_run: - if not adresse: - # Sur un pont interne l'adresse est FIXE et dérivée du - # VMID. Vide, c'est que le parent n'a pas de pont - # interne — écrire un alias sans HostName donnerait - # une entrée qui ne mène nulle part. - self.dire(" ✗ pas d'adresse fixe pour l'enfant") - etage["etape"] = "adresse" - self.etages.append(etage) - break self.ecrire_alias(alias, adresse, parent_alias) cible = {"target": alias, "sudo": "sudo ", "jump": ""} etage["alias"] = alias etage["etape"] = "ssh" - attente = self.attendre_ssh(cible, DELAIS["ssh"]) + attente = self.attendre_ssh(cible, self.delai("ssh")) if attente is None: self.dire(" ✗ jamais joignable en ssh") self.etages.append(etage) + self.interrompu = True break etage["ssh_secondes"] = attente self.dire(f" ssh après {attente} s") @@ -466,13 +547,16 @@ class Descente: self.etages.append(etage) return self.rapport(interrompu=True) - etage["etape"] = "termine" - etage["ok"] = True + # En dry-run, aucune étape n'a été mesurée : les marquer + # « atteintes » produisait un rapport indiscernable d'une vraie + # réussite, JSON compris, et un code de sortie 0. + etage["etape"] = "plan" if self.dry_run else "termine" + etage["ok"] = not self.dry_run etage["secondes"] = int(time.time() - debut) self.etages.append(etage) self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") parent, parent_alias = cible, alias - return self.rapport() + return self.rapport(interrompu=self.interrompu) def ecrire_alias(self, alias, adresse, parent_alias): """Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent.""" @@ -491,69 +575,236 @@ class Descente: def rapport(self, interrompu=False): atteint = sum(1 for e in self.etages if e["ok"]) print("") - self.dire( - f" profondeur atteinte : {atteint} / {self.plan['demandee']}" - ) + if self.dry_run: + self.dire( + f" plan annoncé sur {len(self.etages)} étage(s) —" + " rien n'a été créé" + ) + else: + self.dire( + f" profondeur atteinte : {atteint}" + f" / {self.plan['demandee']}" + ) for e in self.etages: - marque = "✓" if e["ok"] else "✗" - detail = f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] + if self.dry_run: + marque, detail = "·", "plan" + else: + marque = "✓" if e["ok"] else "✗" + detail = ( + f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] + ) self.dire(f" {marque} étage {e['niveau']:2d} {detail}") return { "demandee": self.plan["demandee"], "atteignable": self.plan["atteignable"], "atteinte": atteint, "interrompu": interrompu, + # Sans ce champ, un rapport d'essai à blanc se lisait comme une + # descente réussie — et « --detruire » s'en servait. + "dry_run": self.dry_run, "etages": self.etages, } -def detruire(journal=None): - """Défait ce que la descente a posé, du plus profond au plus haut. +def dernier_rapport(): + """Le rapport JSON le plus récent, ou {}. - Du plus profond : détruire un parent d'abord emporterait ses enfants sans - qu'on ait pu les nommer, et laisserait des entrées ssh vers rien. + C'est le SEUL enregistrement de ce que la descente a créé : un couple + (alias du parent, VMID) par étage. Détruire d'après lui, et non d'après + les noms, est toute la différence entre défaire son propre travail et + effacer une machine qui se trouve porter un nom voisin. """ - from script.todo.todo import TODO + dossier = os.path.expanduser("~/.erplibre/longtest") + try: + fichiers = sorted( + f for f in os.listdir(dossier) if f.endswith(".json") + ) + except OSError: + return {} + for nom in reversed(fichiers): + try: + with open(os.path.join(dossier, nom), encoding="utf-8") as fh: + rapport = json.load(fh) + except (OSError, ValueError): + continue + if rapport.get("dry_run"): + continue # un plan n'a rien créé + rapport["fichier"] = os.path.join(dossier, nom) + return rapport + return {} - hosts = [h for h in TODO._ssh_config_hosts() if NOM_BASE in h] - hosts.sort(key=lambda h: -h.count("+")) - dire(f" {len(hosts)} entrée(s) ssh à défaire", journal) - for alias in hosts: - bloc = TODO._ssh_config_block(alias) - saut = (bloc or {}).get("proxyjump") - if saut: - parent = {"target": saut, "sudo": "sudo ", "jump": ""} - _c, out = pve.run(parent, "qm list", 120) - for vm in pve.parse_qm_list(out): - if NOM_BASE in (vm.get("name") or ""): - dire(f" qm destroy {vm['vmid']} sur {saut}", journal) - pve.run( - parent, - f"qm stop {vm['vmid']} --skiplock 1 || true;" - f" qm destroy {vm['vmid']} --purge 1", - 300, - ) - for niveau in range(1, 30): - nom = nom_etage(niveau) - if nom in hosts or niveau == 1: - subprocess.run( - ["sudo", "virsh", "destroy", nom], - capture_output=True, - ) - subprocess.run( - [ - "sudo", - "virsh", - "undefine", - nom, - "--nvram", - "--remove-all-storage", - ], - capture_output=True, - ) - dire( - " ✓ défait. Les entrées ssh orphelines : menu de nettoyage.", journal + +def a_defaire(rapport): + """[(niveau, parent_alias, vmid, nom)] du plus PROFOND au plus haut. + + Trié sur le niveau LU dans le rapport, pas déduit du nom. La version + d'avant comptait les « + » de l'alias — or `alias_etage` remplace le « + » + du parent par un « - », donc chaque alias en portait exactement UN et le + tri ne triait rien. La destruction partait du plus HAUT : « qm destroy + --purge » sur l'étage 2 emportait le disque contenant les étages 3 et + suivants, sans les avoir arrêtés ni nommés. + """ + etages = [ + e + for e in (rapport.get("etages") or []) + if e.get("vmid") and e.get("parent_alias") + ] + etages.sort(key=lambda e: -int(e["niveau"])) + return [ + ( + int(e["niveau"]), + e["parent_alias"], + int(e["vmid"]), + nom_etage(int(e["niveau"])), + ) + for e in etages + ] + + +def detruire_une(parent_alias, vmid, nom, journal): + """Arrête puis détruit UNE VM, par son VMID. Rend True si elle a disparu. + + Par le VMID et par égalité stricte du nom : un filtre par sous-chaîne + aurait pris une « deep-pve-lab » de production, et « --purge » emporte les + disques ET les entrées de sauvegarde. + + L'arrêt est CONSTATÉ avant la destruction : « qm stop » rend la main dès + que la tâche est lancée, et sur un hyperviseur imbriqué mesuré 36 fois + plus lent, « qm destroy » arrivait alors que la VM tournait encore et + refusait avec « VM is running ». + """ + parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""} + code, out = pve.run(parent, "qm list", 180) + if code: + dire(f" ✗ {parent_alias} injoignable : rien touché", journal) + return False + presentes = { + int(v["vmid"]): (v.get("name") or "") for v in pve.parse_qm_list(out) + } + if vmid not in presentes: + dire(f" — {vmid} déjà absente de {parent_alias}", journal) + return True + if presentes[vmid] != nom: + dire( + f" ✗ {vmid} sur {parent_alias} s'appelle" + f" « {presentes[vmid]} », pas « {nom} » : rien touché", + journal, + ) + return False + pve.run(parent, f"qm stop {vmid} --skiplock 1 || true", 300) + for _ in range(20): + _c, etat = pve.run(parent, f"qm status {vmid}", 120) + if "stopped" in pve.strip_ssh_noise(etat): + break + time.sleep(6) + code, out = pve.run(parent, f"qm destroy {vmid} --purge 1", 600) + if code: + dire(f" ✗ qm destroy {vmid} : code {code}", journal) + for ligne in pve.strip_ssh_noise(out).strip().splitlines()[-3:]: + dire(f" {ligne}", journal) + return False + dire(f" ✓ {nom} ({vmid}) sur {parent_alias}", journal) + return True + + +def detruire_etage1(journal, dry_run=False): + """Le domaine libvirt du premier étage — le SEUL qui en soit un. + + La boucle d'avant tournait sur trente niveaux avec une condition morte, et + sa branche « niveau == 1 » était vraie même quand la descente n'avait + jamais rien créé : « virsh undefine --remove-all-storage » partait alors + sur un domaine qui pouvait être n'importe quoi, sortie capturée, sans un + mot. + """ + nom = nom_etage(1) + existe = subprocess.run( + ["sudo", "-n", "virsh", "dominfo", nom], + capture_output=True, + text=True, ) + if existe.returncode: + dire(f" — {nom} : aucun domaine libvirt", journal) + return True + if dry_run: + dire( + f" [à blanc] virsh undefine {nom} --remove-all-storage", journal + ) + return True + subprocess.run( + ["sudo", "virsh", "destroy", nom], capture_output=True, text=True + ) + res = subprocess.run( + [ + "sudo", + "virsh", + "undefine", + nom, + "--nvram", + "--remove-all-storage", + ], + capture_output=True, + text=True, + ) + if res.returncode: + dire( + f" ✗ virsh undefine {nom} : {res.stderr.strip()[:160]}", journal + ) + return False + dire(f" ✓ {nom} (libvirt)", journal) + return True + + +def detruire(journal=None, dry_run=False): + """Défait ce que le DERNIER rapport dit avoir créé, du plus profond. + + Rien d'autre. La version d'avant prenait toute entrée ~/.ssh/config dont + le nom contenait « deep-pve », puis sur son rebond détruisait toute VM + dont le nom contenait « deep-pve » — une machine de labo appelée + « deep-pve-lab » sur un hyperviseur de production tombait dedans. + """ + rapport = dernier_rapport() + if not rapport: + dire(" aucun rapport de descente : rien à défaire.", journal) + dire( + " (les entrées ~/.ssh/config orphelines : menu de nettoyage)", + journal, + ) + return 0 + liste = a_defaire(rapport) + dire(f" rapport : {rapport.get('fichier')}", journal) + dire(f" {len(liste)} VM imbriquée(s) + l'étage 1 :", journal) + for niveau, parent_alias, vmid, nom in liste: + dire( + f" étage {niveau:2d} {nom} ({vmid}) sur {parent_alias}", + journal, + ) + dire(f" étage 1 {nom_etage(1)} (libvirt)", journal) + if dry_run: + dire("\n --dry-run : rien ne sera détruit.", journal) + return 0 + # Une confirmation, parce que « --purge » emporte les disques et que le + # menu lançait cette option d'une seule touche. + reponse = input("\n Détruire tout cela ? (tapez OUI) : ").strip() + if reponse != "OUI": + dire(" annulé.", journal) + return 1 + faits = sum( + 1 + for niveau, parent_alias, vmid, nom in liste + if detruire_une(parent_alias, vmid, nom, journal) + ) + if not detruire_etage1(journal): + faits -= 1 + dire( + f"\n {faits} / {len(liste) + 1} défait(s)." + + ( + "" + if faits == len(liste) + 1 + else " ⚠ il reste des machines : voir plus haut." + ), + journal, + ) + return 0 if faits == len(liste) + 1 else 1 def principal(argv=None): @@ -570,8 +821,9 @@ def principal(argv=None): ) os.makedirs(os.path.dirname(journal), exist_ok=True) if args.detruire: - detruire(journal) - return 0 + # « --dry-run » était ignoré ici : la prudence naturelle avant une + # destruction détruisait pour de vrai. + return detruire(journal, dry_run=args.dry_run) coeurs, ram, disque = capacite_hote() print( @@ -591,6 +843,9 @@ def principal(argv=None): f" {plan['atteignable']} — manque de {plan['arret']}" ) if not plan["niveaux"]: + if args.depth < 1: + print(f"\n profondeur demandée : {args.depth} — rien à faire.\n") + return 0 print("\n ✗ pas même un étage ne tient sur cette machine.\n") return 1 print(f"\n journal : {journal}") @@ -598,11 +853,16 @@ def principal(argv=None): print(" --dry-run : rien ne sera créé.\n") descente = Descente(plan, journal, args.dry_run) rapport = descente.parcourir() - chemin = journal[:-4] + ".json" + chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") with open(chemin, "w", encoding="utf-8") as fh: json.dump(rapport, fh, indent=2) print(f"\n rapport : {chemin}\n") - return 0 if rapport["atteinte"] else 1 + # En essai à blanc, c'est le PLAN qui est complet ou non — aucune + # profondeur n'a été atteinte. Hors essai, « non nul » ne suffisait pas : + # une descente morte au deuxième étage sur dix rendait 0. + if args.dry_run: + return 0 if rapport["atteignable"] == rapport["demandee"] else 1 + return 0 if rapport["atteinte"] == rapport["demandee"] else 1 if __name__ == "__main__": diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index dc2ec52..b17b429 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -81,7 +81,10 @@ def nesting_plan( ram = ((int(ram_dispo_mo) - HOTE_RESERVE_RAM_MO) // 1024) * 1024 disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO niveaux, arret = [], "" - for niveau in range(1, max(1, int(profondeur)) + 1): + # « max(1, …) » forçait un tour : profondeur 0 rendait un plan d'UN + # étage, et « --depth 0 » créait donc une VM. range(1, 1) est déjà vide, + # et un plan vide est la bonne réponse à une demande vide. + for niveau in range(1, int(profondeur) + 1): if niveau > 1: ram -= PVE_RAM_MO disque -= PVE_DISQUE_GO @@ -94,8 +97,16 @@ def nesting_plan( niveaux.append( { "niveau": niveau, + # Le plancher est VCPU_IMBRIQUE et non 1 : sur un hôte de + # quatre cœurs, « // 4 » donnait UN vCPU au premier étage — + # l'hyperviseur parent — alors que son invité en recevait + # deux. Un parent plus étroit que son enfant est absurde, et + # c'est tout l'inverse de ce que ce module raconte. "vcpu": ( - max(1, min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4)) + max( + VCPU_IMBRIQUE, + min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4), + ) if niveau == 1 else VCPU_IMBRIQUE ), diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 3d05b2f..4fe7b04 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -76,7 +76,13 @@ class LongTestMenuMixin: "deep_proxmox.py", f"--depth {self._longtest_depth()}" ) elif status == "3": - self._longtest_run("deep_proxmox.py", "--detruire") + # Le script demande « OUI » avant de détruire, mais il liste + # d'abord : on lui fait faire cette liste À BLANC pour que le + # choix « 3 » d'une touche ne mène pas directement à un + # « qm destroy --purge ». + self._longtest_run("deep_proxmox.py", "--detruire --dry-run") + if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): + self._longtest_run("deep_proxmox.py", "--detruire") else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 95d1d13..b292f80 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3422,6 +3422,10 @@ TRANSLATIONS = { "fr": "L'adresse écrite n'est peut-être pas celle qu'il faut à pmxcfs.", "en": "The address written may not be the one pmxcfs needs.", }, + "Destroy all that? (y/N): ": { + "fr": "Détruire tout cela ? (o/N) : ", + "en": "Destroy all that? (y/N): ", + }, "Long tests - real VMs, hours": { "fr": "⏳ Tests longs - vraies VM, des heures", "en": "⏳ Long tests - real VMs, hours", diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 1cb7eaf..cf976ea 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -42,10 +42,29 @@ class TestLePlanDesEtages(unittest.TestCase): de démarrage ; les mêmes 2 vCPU avançaient. Amener douze processeurs en ligne demande autant d'allers-retours à travers la pile.""" niveaux = nesting.nesting_plan(4, **self.HOTE)["niveaux"] - self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE - 1) + # STRICTEMENT plus grand. « > VCPU_IMBRIQUE - 1 » était satisfait par + # la valeur imbriquée elle-même : remplacer tout le calcul du premier + # étage par VCPU_IMBRIQUE laissait les tests verts, donc cpu_hote + # n'était couvert par rien. + self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE) for n in niveaux[1:]: self.assertEqual(n["vcpu"], nesting.VCPU_IMBRIQUE) + def test_a_parent_is_never_narrower_than_its_child(self): + """Sur un hôte de quatre cœurs, « // 4 » donnait UN vCPU au premier + étage — l'hyperviseur — alors que son invité en recevait deux.""" + for coeurs in (2, 4, 8, 12, 28): + with self.subTest(coeurs=coeurs): + niveaux = nesting.nesting_plan( + 3, + cpu_hote=coeurs, + ram_dispo_mo=32768, + disque_libre_go=300, + )["niveaux"] + self.assertGreaterEqual( + niveaux[0]["vcpu"], niveaux[1]["vcpu"], f"{coeurs} cœurs" + ) + def test_running_out_of_ram_is_named(self): plan = nesting.nesting_plan( 10, cpu_hote=8, ram_dispo_mo=12288, disque_libre_go=500 @@ -65,6 +84,13 @@ class TestLePlanDesEtages(unittest.TestCase): for n in plan["niveaux"]: self.assertGreaterEqual(n["disque"], nesting.DISQUE_MIN_GO) + def test_a_depth_of_zero_asks_for_nothing(self): + for profondeur in (0, -1, -7): + with self.subTest(profondeur=profondeur): + plan = nesting.nesting_plan(profondeur, **self.HOTE) + self.assertEqual(plan["niveaux"], []) + self.assertEqual(plan["atteignable"], 0) + def test_a_machine_too_small_for_even_one_level(self): plan = nesting.nesting_plan( 3, cpu_hote=2, ram_dispo_mo=4096, disque_libre_go=200 @@ -76,10 +102,14 @@ class TestLePlanDesEtages(unittest.TestCase): def test_a_plan_is_never_promised_beyond_what_fits(self): # Mieux vaut annoncer six étages et en réussir six que d'en promettre # dix et mourir au septième sans savoir pourquoi. - for profondeur in range(1, 13): + # Depuis 0 et depuis les négatifs : « max(1, …) » forçait un tour, + # donc « --depth 0 » rendait un plan d'UN étage et créait une VM. + for profondeur in range(-2, 13): plan = nesting.nesting_plan(profondeur, **self.HOTE) self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) - self.assertLessEqual(plan["atteignable"], profondeur) + # « max(0, …) » : une demande négative ne peut pas donner un + # nombre d'étages négatif, elle donne zéro. + self.assertLessEqual(plan["atteignable"], max(0, profondeur)) class TestLaProfondeurDUnHote(unittest.TestCase): diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 0202412..9c27cb6 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -36,14 +36,22 @@ class TestLaFrontiere(unittest.TestCase): # LongTest, sinon la suite unitaire créerait des VM. self.assertNotIn("LongTest", lanceur) - def test_the_naming_rule_is_written_where_it_is_read(self): - # Un fichier hors préfixe tombe dans le même silence qu'un fichier - # absent : douze tests écrits, jamais lancés. + def test_the_runner_only_looks_under_test(self): + """Le lanceur balaie TOUT test/test_*.py depuis qu'une liste de + préfixes a laissé 2400 tests hors de la suite. + + La frontière n'est donc plus un nom mais un RÉPERTOIRE : ce qui doit + rester hors de la suite doit vivre ailleurs que dans test/. C'est + exactement pourquoi LongTest est à la racine.""" with open( os.path.join(RACINE, "script/test/run_unit_test.sh"), encoding="utf-8", ) as fh: - self.assertIn("NOMMER UN NOUVEAU FICHIER", fh.read()) + lanceur = fh.read() + self.assertIn("test/test_*.py", lanceur) + # Aucun chemin du lanceur ne sort de test/ : sinon LongTest y + # entrerait par la porte de service. + self.assertNotIn("LongTest", lanceur) def test_the_script_is_executable_and_documented(self): script = os.path.join(RACINE, "LongTest/deep_proxmox.py") @@ -64,6 +72,11 @@ class TestLEssaiABlanc(unittest.TestCase): @classmethod def setUpClass(cls): + import tempfile + + # HOME temporaire : la suite unitaire tourne souvent, et elle n'a pas + # à semer un rapport dans ~/.erplibre à chaque passage. + cls.maison = tempfile.mkdtemp() cls.res = subprocess.run( [ PYTHON, @@ -76,19 +89,34 @@ class TestLEssaiABlanc(unittest.TestCase): text=True, timeout=180, cwd=RACINE, - env=dict(os.environ, PYTHONPATH=RACINE), + env=dict(os.environ, PYTHONPATH=RACINE, HOME=cls.maison), ) + @classmethod + def tearDownClass(cls): + import shutil + + shutil.rmtree(cls.maison, ignore_errors=True) + def test_it_exits_cleanly(self): self.assertEqual(self.res.returncode, 0, self.res.stderr[-800:]) def test_it_announces_the_plan_before_anything(self): - sortie = self.res.stdout - self.assertIn("étage", sortie) - # Quatre étages demandés, quatre lignes de plan. - for niveau in ("1", "2", "3", "4"): - self.assertIn(niveau, sortie) - self.assertIn("dry-run", sortie) + """Les LIGNES du plan, pas les chiffres. + + La version d'avant cherchait « 1 », « 2 », « 3 », « 4 » dans la + sortie : l'en-tête « 28 cœurs, 29128 Mo, 138 Go » et l'horodatage du + journal les fournissent tous. Elle passait même à --depth 1, avec une + seule ligne de plan — elle ne prouvait rien.""" + import re + + plan = re.findall( + r"^\s+(\d+)\s+(\d+)\s+(\d+) Mo\s+(\d+) Go\s*$", + self.res.stdout, + re.M, + ) + self.assertEqual([int(p[0]) for p in plan], [1, 2, 3, 4]) + self.assertIn("dry-run", self.res.stdout) def test_it_shows_the_commands_it_would_send(self): # Une étape affichée est une étape rejouable à la main : c'est ainsi @@ -96,6 +124,43 @@ class TestLEssaiABlanc(unittest.TestCase): self.assertIn("qm create", self.res.stdout) self.assertIn("install_proxmox.sh", self.res.stdout) + def test_the_installer_is_run_by_bash_not_sh(self): + """Le script porte « set -euo pipefail » et un shebang bash. + + Sur Debian /bin/sh est dash, qui répond « set: Illegal option -o + pipefail » et sort à la PREMIÈRE ligne — vérifié. Lancé par sh, chaque + étage aurait échoué sur l'installation, à tous les coups.""" + # Sur la LIGNE, pas dans le texte : « bash /tmp/… » contient + # « sh /tmp/… », donc un assertNotIn naïf échouait sur lui-même. + lignes = [ + ligne.strip() + for ligne in self.res.stdout.splitlines() + if "install_proxmox.sh" in ligne + and not ligne.strip().startswith("scp") + ] + self.assertTrue(lignes) + for ligne in lignes: + self.assertTrue( + ligne.startswith("bash "), f"lancé par autre chose : {ligne}" + ) + + 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.""" + import glob + import json + + fichiers = glob.glob( + os.path.join(self.maison, ".erplibre/longtest/*.json") + ) + self.assertEqual(len(fichiers), 1, fichiers) + self.assertIn("dryrun", fichiers[0]) + with open(fichiers[0], encoding="utf-8") as fh: + rapport = json.load(fh) + self.assertTrue(rapport["dry_run"]) + self.assertEqual(rapport["atteinte"], 0) + self.assertTrue(all(not e["ok"] for e in rapport["etages"])) + def test_the_first_level_is_wide_and_the_others_are_not(self): # 12 vCPU au quatrième étage ont gelé un noyau invité ; deux # avançaient. @@ -115,6 +180,79 @@ class TestLEssaiABlanc(unittest.TestCase): self.assertEqual(niveaux[niveau], 2, f"étage {niveau}") +class TestDefaireSansEffacerAutreChose(unittest.TestCase): + """« --detruire » effaçait par SOUS-CHAÎNE de nom, dans le mauvais ordre, + sans confirmation et sans honorer --dry-run. + + Quatre défauts trouvés en attaquant le code écrit, chacun capable + d'emporter une machine qui n'appartient pas au test. « qm destroy --purge » + emporte les disques ET les entrées de sauvegarde.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + + def test_the_deepest_level_goes_first(self): + """Le tri comptait les « + » de l'alias — or alias_etage remplace le + « + » du parent par un « - », donc chaque alias en portait + exactement UN. Le tri ne triait rien, et la destruction partait du + plus HAUT : « qm destroy --purge » sur l'étage 2 emportait le disque + contenant les étages 3 et suivants.""" + rapport = { + "etages": [ + {"niveau": 2, "vmid": 100, "parent_alias": "a"}, + {"niveau": 4, "vmid": 100, "parent_alias": "c"}, + {"niveau": 3, "vmid": 100, "parent_alias": "b"}, + ] + } + niveaux = [n for n, _p, _v, _nom in self.dp.a_defaire(rapport)] + self.assertEqual(niveaux, [4, 3, 2]) + + def test_a_level_without_a_vmid_is_not_guessed(self): + # Un étage abandonné avant « qm create » n'a rien créé : ne rien + # inventer à sa place. + rapport = {"etages": [{"niveau": 2}, {"niveau": 3, "vmid": 101}]} + self.assertEqual(len(self.dp.a_defaire(rapport)), 0) + + def test_the_alias_chain_really_flattens_the_plus(self): + # La cause du tri mort, énoncée pour qu'on ne la réintroduise pas. + alias, precedent = "deep-pve-1", "deep-pve-1" + for niveau in (2, 3, 4): + alias = self.dp.alias_etage(niveau, precedent) + precedent = alias + self.assertEqual(alias.count("+"), 1, alias) + + def test_an_exact_name_is_required(self): + """Le filtre était « NOM_BASE in name » : une VM de labo appelée + « deep-pve-lab » sur un hyperviseur de production tombait dedans.""" + import inspect + + src = inspect.getsource(self.dp.detruire_une) + self.assertIn("!= nom", src) + self.assertNotIn("in presentes[vmid]", src) + + def test_dry_run_reports_are_never_used_to_destroy(self): + """Un rapport d'essai à blanc n'a rien créé : s'en servir ferait + détruire d'après un plan.""" + import inspect + + src = inspect.getsource(self.dp.dernier_rapport) + self.assertIn('rapport.get("dry_run")', src) + + def test_destruction_honours_dry_run_and_asks(self): + import inspect + + src = inspect.getsource(self.dp.detruire) + self.assertIn("dry_run", src) + # Une confirmation explicite, pas un « o/N » : le menu lançait cette + # option d'une seule touche. + self.assertIn("OUI", src) + principal = inspect.getsource(self.dp.principal) + self.assertIn("dry_run=args.dry_run", principal) + + class TestLeMenu(unittest.TestCase): def test_the_mixin_is_wired_into_TODO(self): todo = TODO.__new__(TODO) From adf0f275d445f5fdaf1c9ed747e4074adff4aa31 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 04:27:50 -0400 Subject: [PATCH 09/26] =?UTF-8?q?[FIX]=20proxmox=20:=20apt-daily=20tient?= =?UTF-8?q?=20le=20verrou=20au=20d=C3=A9marrage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- LongTest/deep_proxmox.py | 17 +++++++++ script/proxmox/install_proxmox.sh | 29 ++++++++++++++- script/proxmox/nesting.py | 9 ++++- test/test_proxmox_deploy.py | 62 +++++++++++++++++++++++++++++++ test/test_proxmox_nesting.py | 25 +++++++++++++ test/test_todo_longtest.py | 19 ++++++++++ 6 files changed, 158 insertions(+), 3 deletions(-) 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.""" From 667842b8328735308576fe617b030d9f24ef21a4 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 04:44:46 -0400 Subject: [PATCH 10/26] =?UTF-8?q?[FIX]=20imbrication=20:=20la=20descente?= =?UTF-8?q?=20a=20r=C3=A9fut=C3=A9=20ce=20qu'on=20croyait=20mesur=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La doc et l'en-tête de l'algorithme affirmaient qu'au quatrième étage le noyau invité gelait « au même octet quelles que soient les ressources ». Lancer la descente l'a réfuté : son propre quatrième étage, à 2 vCPU, a démarré, s'est installé, et a écrit des gigaoctets. La VM examinée à la main en avait douze. Ce n'était donc pas un plafond d'imbrication mais un plafond de PARALLÉLISME sous imbrication — précisément ce que l'algorithme borne, et qui cesse ainsi d'être une supposition. Le « même octet », par ailleurs, ne voulait rien dire de ce qu'on lui faisait dire : 33 682 432 octets, c'est 32 Mio, la taille des fichiers d'amorçage. Retirer de la mémoire ne le déplaçait pas parce qu'il ne dépendait pas de la mémoire, pas parce qu'un mur absolu s'y trouvait. La conclusion — ne pas borner la RAM — reste juste ; sa justification était fausse. Une affirmation fausse dans la documentation est pire que pas de documentation : elle décide à la place du lecteur. Les deux passages disent maintenant ce qui a été mesuré, sur quoi, et ce que la descente a montré ensuite. --- EN --- The documentation and the algorithm's header claimed that at the fourth level the guest kernel froze "at the same byte whatever the resources". Running the descent refuted it: its own fourth level, at 2 vCPU, booted, installed, and wrote gigabytes. The VM examined by hand had twelve. So it was not a nesting ceiling but a PARALLELISM ceiling under nesting — exactly what the algorithm caps, which thereby stops being a guess. The "same byte", moreover, did not mean what it was made to mean: 33,682,432 bytes is 32 MiB, the size of the boot files. Removing memory did not move it because it did not depend on memory, not because an absolute wall sat there. The conclusion — do not cap RAM — still holds; its justification was wrong. A false claim in documentation is worse than no documentation: it decides in the reader's place. Both passages now say what was measured, on what, and what the descent showed afterwards. Assisted-by: Claude Opus 5 (cherry picked from commit b8c53eaf104f6891703e71b540c76ffd5994a2cb) --- LongTest/README.base.md | 61 ++++++++++++++++++++++++++++----------- LongTest/README.fr.md | 32 ++++++++++++++------ LongTest/README.md | 29 ++++++++++++++----- script/proxmox/nesting.py | 27 ++++++++++------- 4 files changed, 105 insertions(+), 44 deletions(-) diff --git a/LongTest/README.base.md b/LongTest/README.base.md index 20a85f4..c928bc5 100644 --- a/LongTest/README.base.md +++ b/LongTest/README.base.md @@ -15,12 +15,22 @@ Run them from the menu — `TODO › Execute › Test › Long tests` — or dir ## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? -The practicable nesting depth cannot be deduced, only measured. A manual -measurement found, at the fourth level, a guest **36 times slower than real -time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI -line taking a second — then a guest kernel frozen at the **same byte** -whatever the resources. A number obtained once, on one machine, is not a -number: this script redoes it on demand and says exactly where it breaks. +The practicable nesting depth cannot be deduced, only measured — and one +measurement is not a measurement. + +A manual look at one fourth-level VM found a guest **36 times slower than real +time** (583 seconds of wall clock for 16 seconds of guest time, each ACPI line +taking a second) and then a frozen kernel: identical RIP across three samples +two minutes apart, and **not one byte written** to disk. + +Running this script **refuted the conclusion drawn from it**. Its own +fourth-level VM — 2 vCPU where the manual one had 12 — booted, installed, and +wrote gigabytes. What looked like a nesting ceiling was a *parallelism* +ceiling under nesting. That is exactly what the algorithm caps, and this is +how it stopped being a guess. + +Which is the point of the script: a number obtained once, on one machine, in +one chain, is an anecdote. ``` ./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created @@ -54,8 +64,11 @@ level. Twelve vCPU at the fourth level froze the guest kernel in early boot; the same two progressed. Bringing twelve processors online costs as many round trips through the whole stack. -Memory is **not** capped: the same VM froze at the same byte with 9 GB and -with 2 GB, so trimming it would gain nothing and starve the level below. +Memory is **not** capped. On that one manual VM, dropping it from 9 GB to +2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply +the size of the boot files. Memory was not the lever; the vCPU count was. And +trimming memory would starve the level below, which needs it to host the +next. The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and @@ -75,13 +88,24 @@ directement. ## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? -La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une -mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent -que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps -invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au -**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, -sur une machine, n'est pas un chiffre : ce script le refait à la demande et -dit exactement où ça casse. +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure — et +une mesure n'est pas une mesure. + +Un examen à la main d'UNE VM du quatrième étage a trouvé un invité **36 fois +plus lent que le temps réel** (583 secondes d'horloge pour 16 secondes de +temps invité, chaque ligne d'ACPI prenant une seconde), puis un noyau gelé : +même RIP à trois relevés deux minutes d'écart, et **pas un octet écrit** sur +le disque. + +Lancer ce script a **réfuté la conclusion qu'on en avait tirée**. Sa propre VM +du quatrième étage — 2 vCPU là où celle de la main en avait 12 — a démarré, +s'est installée, et a écrit des gigaoctets. Ce qui ressemblait à un plafond +d'imbrication était un plafond de *parallélisme* sous imbrication. C'est +précisément ce que l'algorithme borne, et c'est ainsi qu'il a cessé d'être une +supposition. + +D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, +est une anecdote. ``` ./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé @@ -116,8 +140,11 @@ tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invit tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs en ligne coûte autant d'allers-retours à travers toute la pile. -La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et -avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. +La mémoire n'est **pas** bornée. Sur cette unique VM examinée à la main, la +faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu +les mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. +La mémoire n'était pas le levier ; le nombre de vCPU l'était. Et la rogner +priverait l'étage du dessous, qui en a besoin pour héberger le suivant. Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md index 46f48d4..75f6436 100644 --- a/LongTest/README.fr.md +++ b/LongTest/README.fr.md @@ -12,13 +12,24 @@ directement. ## deep_proxmox.py — jusqu'à quel étage un Proxmox dans un Proxmox tient-il ? -La profondeur d'imbrication praticable ne se déduit pas, elle se mesure. Une -mesure à la main a trouvé, au quatrième étage, un invité **36 fois plus lent -que le temps réel** — 583 secondes d'horloge pour 16 secondes de temps -invité, chaque ligne d'ACPI prenant une seconde — puis un noyau invité gelé au -**même octet** quelles que soient les ressources. Un chiffre obtenu une fois, -sur une machine, n'est pas un chiffre : ce script le refait à la demande et -dit exactement où ça casse. +La profondeur d'imbrication praticable ne se déduit pas, elle se mesure — et +une mesure n'est pas une mesure. + +Un examen à la main d'UNE VM du quatrième étage a trouvé un invité **36 fois +plus lent que le temps réel** (583 secondes d'horloge pour 16 secondes de +temps invité, chaque ligne d'ACPI prenant une seconde), puis un noyau gelé : +même RIP à trois relevés deux minutes d'écart, et **pas un octet écrit** sur +le disque. + +Lancer ce script a **réfuté la conclusion qu'on en avait tirée**. Sa propre VM +du quatrième étage — 2 vCPU là où celle de la main en avait 12 — a démarré, +s'est installée, et a écrit des gigaoctets. Ce qui ressemblait à un plafond +d'imbrication était un plafond de *parallélisme* sous imbrication. C'est +précisément ce que l'algorithme borne, et c'est ainsi qu'il a cessé d'être une +supposition. + +D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, +est une anecdote. ``` ./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé @@ -53,8 +64,11 @@ tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invit tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs en ligne coûte autant d'allers-retours à travers toute la pile. -La mémoire n'est **pas** bornée : la même VM gelait au même octet avec 9 Go et -avec 2 Go, donc la rogner ne gagnerait rien et priverait l'étage du dessous. +La mémoire n'est **pas** bornée. Sur cette unique VM examinée à la main, la +faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu +les mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. +La mémoire n'était pas le levier ; le nombre de vCPU l'était. Et la rogner +priverait l'étage du dessous, qui en a besoin pour héberger le suivant. Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer diff --git a/LongTest/README.md b/LongTest/README.md index 3da0560..03ec8c3 100644 --- a/LongTest/README.md +++ b/LongTest/README.md @@ -10,12 +10,22 @@ Run them from the menu — `TODO › Execute › Test › Long tests` — or dir ## deep_proxmox.py — how deep does Proxmox-in-Proxmox go? -The practicable nesting depth cannot be deduced, only measured. A manual -measurement found, at the fourth level, a guest **36 times slower than real -time** — 583 seconds of wall clock for 16 seconds of guest time, each ACPI -line taking a second — then a guest kernel frozen at the **same byte** -whatever the resources. A number obtained once, on one machine, is not a -number: this script redoes it on demand and says exactly where it breaks. +The practicable nesting depth cannot be deduced, only measured — and one +measurement is not a measurement. + +A manual look at one fourth-level VM found a guest **36 times slower than real +time** (583 seconds of wall clock for 16 seconds of guest time, each ACPI line +taking a second) and then a frozen kernel: identical RIP across three samples +two minutes apart, and **not one byte written** to disk. + +Running this script **refuted the conclusion drawn from it**. Its own +fourth-level VM — 2 vCPU where the manual one had 12 — booted, installed, and +wrote gigabytes. What looked like a nesting ceiling was a *parallelism* +ceiling under nesting. That is exactly what the algorithm caps, and this is +how it stopped being a guess. + +Which is the point of the script: a number obtained once, on one machine, in +one chain, is an anecdote. ``` ./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created @@ -49,8 +59,11 @@ level. Twelve vCPU at the fourth level froze the guest kernel in early boot; the same two progressed. Bringing twelve processors online costs as many round trips through the whole stack. -Memory is **not** capped: the same VM froze at the same byte with 9 GB and -with 2 GB, so trimming it would gain nothing and starve the level below. +Memory is **not** capped. On that one manual VM, dropping it from 9 GB to +2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply +the size of the boot files. Memory was not the lever; the vCPU count was. And +trimming memory would starve the level below, which needs it to host the +next. The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 26bb504..1092a74 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -26,11 +26,16 @@ Deux nombres viennent de la même mesure, et méritent d'être dits : démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu de plus. Les mêmes 2 vCPU avançaient. D'où VCPU_IMBRIQUE = 2 : amener douze processeurs en ligne demande autant d'allers-retours à travers la pile ; -* la même VM s'arrêtait ensuite au MÊME octet — 33 682 432 — quelles que - soient les ressources, dans la réservation des tables ACPI. Ce mur-là n'est - pas une question de taille, et aucun réglage ici ne le déplacera. La - profondeur RÉELLEMENT atteignable se mesure ; ce module ne calcule que ce - qui est arithmétiquement possible. +* cette VM-là s'arrêtait après avoir lu 33 682 432 octets — 32 Mio, soit + simplement la taille de ses fichiers d'amorçage — et le chiffre ne bougeait + pas quand on lui retirait de la mémoire. La mémoire n'était donc pas le + levier, et c'est pourquoi ce module n'en borne pas. + +Une descente complète a ensuite RÉFUTÉ ce qu'on avait conclu de la première : +son quatrième étage, à 2 vCPU, a démarré, s'est installé, et a écrit des +gigaoctets. Le plafond était celui du parallélisme sous imbrication, pas celui +de l'imbrication. La profondeur RÉELLEMENT atteignable se mesure — LongTest la +mesure ; ce module ne calcule que ce qui est arithmétiquement possible. """ # Ce qu'on laisse à la machine physique : elle fait tourner l'orchestrateur, @@ -143,11 +148,13 @@ def capped_for_depth(profondeur: int, vcpu: int, ram_mo: int) -> tuple: Rend (vcpu, ram, raison). `raison` vide quand rien n'a été touché. - Seul le vCPU est borné, et la mesure le dit : la même VM au quatrième - étage gelait au MÊME octet avec 9 Go et avec 2 Go — la mémoire n'est pas - le levier. Douze vCPU, en revanche, gelaient plus tôt et plus dur que - deux. La RAM passe donc telle quelle : la rogner ne gagnerait rien et - priverait l'étage suivant. + Seul le vCPU est borné, et la mesure le dit : sur la VM examinée au + quatrième étage, passer de 9 Go à 2 Go n'a rien déplacé — elle s'arrêtait + après les mêmes 32 Mio, la taille de ses fichiers d'amorçage. Douze vCPU, + en revanche, gelaient là où deux avançaient, et une descente complète a + fini par franchir cet étage à 2 vCPU. La RAM passe donc telle quelle : la + rogner ne gagnerait rien et priverait l'étage suivant, qui en a besoin + pour héberger le sien. Pourquoi borner au lieu d'avertir seulement : l'écran lit la capacité de l'HÔTE et l'offre en entier. Sur un troisième étage à 14 cœurs et 9 Go, il From 469a5fde9edef9f1f675aebd7adabbf6638dcb22 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 05:14:43 -0400 Subject: [PATCH 11/26] =?UTF-8?q?[FIX]=20imbrication=20:=20dimensionner=20?= =?UTF-8?q?les=20=C3=A9tages=20depuis=20le=20bas,=20vCPU=20compris?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le plan cédait à l'enfant ce que le parent pouvait céder. Descente réelle à dix étages : l'étage 4 a reçu 44 Go et 2 vCPU sur un hôte qui en avait 2 — cent pour cent de surengagement, à chaque étage. Son installation dure 2 h 52 et n'est pas finie, contre 793 s pour l'étage 3. Extrapolé, le dixième demandait des années. Le plus profond reçoit désormais ce qu'un Proxmox de test demande, et chaque parent ajoute son seul surcoût : un vCPU, 2 Gio, 10 Go. Dix étages tiennent sur 11 vCPU et 22 Go au premier, contre 50 Go avant. Corrige aussi l'explication du gel à 12 vCPU : cette VM avait douze vCPU sur un hôte qui en avait deux. C'est le surengagement qui gèle, pas le douze. --- EN --- The plan handed the child whatever the parent could spare. Real ten-level descent: level 4 got 44 GB and 2 vCPU on a host that had 2 — a hundred percent overcommit, at every level. Its install has run 2h52 and is not done, against 793 s for level 3. Extrapolated, the tenth wanted years. The deepest level now gets what a test Proxmox asks for, and each parent adds its own overhead only: one vCPU, 2 GiB, 10 GB. Ten levels fit in 11 vCPU and 22 GB at the first, against 50 GB before. Also corrects the account of the 12-vCPU freeze: that VM had twelve vCPU on a host with two. Overcommit freezes, not the twelve. Assisted-by: claude-opus-5 (cherry picked from commit 7cda84bf391ee3ed36c5953ca4b86df44bd09e3b) --- LongTest/README.base.md | 97 +++++++++++++-------- LongTest/README.fr.md | 50 +++++++---- LongTest/README.md | 47 +++++++---- script/proxmox/nesting.py | 159 +++++++++++++++++++++++------------ test/test_proxmox_nesting.py | 138 +++++++++++++----------------- 5 files changed, 294 insertions(+), 197 deletions(-) diff --git a/LongTest/README.base.md b/LongTest/README.base.md index c928bc5..d280936 100644 --- a/LongTest/README.base.md +++ b/LongTest/README.base.md @@ -48,27 +48,42 @@ the repository: it is our code we want to exercise, and the remote is often behind the checkout — a fix absent from the remote made the same defect "come back" on three VMs in a row. -### The resource algorithm +### The resource algorithm — sized from the bottom up -Two things run out going down, and a third degrades. What runs out is -arithmetic, and `script/proxmox/nesting.py` computes it: +The first version handed down whatever the parent could spare, and a real +descent showed what that costs. Level 4 ended up with 44 GB of memory and +2 vCPU **on a host that had 2** — a hundred percent overcommit, at every +level, with the hypervisor itself to serve on top. Its install ran past two +and a half hours against thirteen minutes for level 3, and extrapolating that +ratio gave five years for the tenth. -* **memory** — each level keeps what its own daemons need (`pve-cluster`, - `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +So the direction is reversed. The deepest level gets what a test Proxmox +actually asks for — 4 GB of memory, 25 GB of disk, 2 vCPU — and every parent +above it adds its own overhead and nothing else: one vCPU, 2 GiB, 10 GB. A +ten-level descent therefore asks its first level for 11 vCPU, 22 GB and +115 GB, where handing resources down wanted 50 GB of memory for the same +depth. + +Three budgets can bound the depth, and `script/proxmox/nesting.py` names the +one that ran out: + +* **memory** — every level must run its own daemons (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child; * **disk** — the child's disk lives *inside* the parent's, which must also - hold its own system. + hold its own system; +* **processor** — each level wants one vCPU more than its child, so ten levels + ask eleven of the first. Half the physical cores is the ceiling: the + orchestrator runs on that machine too. -What degrades is measured, not assumed: past the second level, vendors -document nothing. Hence one capped number — **2 vCPU** for every nested -level. Twelve vCPU at the fourth level froze the guest kernel in early boot; -the same two progressed. Bringing twelve processors online costs as many -round trips through the whole stack. +That third budget is measured, not assumed. Twelve vCPU at the fourth level +froze the guest kernel in early boot — same instruction pointer at three +readings two minutes apart — while two progressed. The number was not the +culprit: that VM had twelve vCPU on a host with two, six times wider than its +own machine. Overcommit freezes, not the twelve. -Memory is **not** capped. On that one manual VM, dropping it from 9 GB to -2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply -the size of the boot files. Memory was not the lever; the vCPU count was. And -trimming memory would starve the level below, which needs it to host the -next. +Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB +moved nothing — it stopped after reading the same 32 MiB, which is simply the +size of the boot files. The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and @@ -123,28 +138,44 @@ cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent en retard sur le checkout — un correctif absent du distant a fait « revenir » le même défaut sur trois VM de suite. -### L'algorithme de ressources +### L'algorithme de ressources — dimensionné depuis le bas -Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui -s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : +La première version cédait à l'enfant ce que le parent pouvait céder, et une +descente réelle a montré ce que cela coûte. L'étage 4 se retrouvait avec 44 Go +de mémoire et 2 vCPU **sur un hôte qui en avait 2** — cent pour cent de +surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dessus. +Son installation dépassait deux heures et demie contre treize minutes pour +l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième. -* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons - (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le - reste ; +Le sens est donc inversé. Le plus profond reçoit ce qu'un Proxmox de test +demande vraiment — 4 Go de mémoire, 25 Go de disque, 2 vCPU — et chaque parent +au-dessus ajoute son propre surcoût, rien d'autre : un vCPU, 2 Gio, 10 Go. Une +descente à dix étages demande ainsi 11 vCPU, 22 Go et 115 Go à son premier +étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la même +profondeur. + +Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py` +nomme celui qui a manqué : + +* **la mémoire** — chaque étage doit faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) *et* héberger son + enfant ; * **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit - aussi contenir son propre système. + aussi contenir son propre système ; +* **le processeur** — chaque étage en veut un de plus que son enfant, donc dix + étages en demandent onze au premier. La moitié des cœurs physiques est le + plafond : l'orchestrateur tourne sur cette machine lui aussi. -Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les -fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour -tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en -tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs -en ligne coûte autant d'allers-retours à travers toute la pile. +Ce troisième budget est mesuré, pas supposé. Douze vCPU au quatrième étage ont +gelé le noyau invité en tout début de démarrage — même pointeur d'instruction à +trois relevés, deux minutes d'écart — quand deux avançaient. Le nombre n'était +pas le fautif : cette VM avait douze vCPU sur un hôte qui en avait deux, six +fois plus large que sa propre machine. C'est le surengagement qui gèle, pas le +douze. -La mémoire n'est **pas** bornée. Sur cette unique VM examinée à la main, la -faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu -les mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. -La mémoire n'était pas le levier ; le nombre de vCPU l'était. Et la rogner -priverait l'étage du dessous, qui en a besoin pour héberger le suivant. +La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire +passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les +mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md index 75f6436..9c1b051 100644 --- a/LongTest/README.fr.md +++ b/LongTest/README.fr.md @@ -47,28 +47,44 @@ cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent en retard sur le checkout — un correctif absent du distant a fait « revenir » le même défaut sur trois VM de suite. -### L'algorithme de ressources +### L'algorithme de ressources — dimensionné depuis le bas -Deux choses s'épuisent en descendant, et une troisième se dégrade. Ce qui -s'épuise est de l'arithmétique, et `script/proxmox/nesting.py` la calcule : +La première version cédait à l'enfant ce que le parent pouvait céder, et une +descente réelle a montré ce que cela coûte. L'étage 4 se retrouvait avec 44 Go +de mémoire et 2 vCPU **sur un hôte qui en avait 2** — cent pour cent de +surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dessus. +Son installation dépassait deux heures et demie contre treize minutes pour +l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième. -* **la mémoire** — chaque étage garde de quoi faire tourner ses propres démons - (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) avant de céder le - reste ; +Le sens est donc inversé. Le plus profond reçoit ce qu'un Proxmox de test +demande vraiment — 4 Go de mémoire, 25 Go de disque, 2 vCPU — et chaque parent +au-dessus ajoute son propre surcoût, rien d'autre : un vCPU, 2 Gio, 10 Go. Une +descente à dix étages demande ainsi 11 vCPU, 22 Go et 115 Go à son premier +étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la même +profondeur. + +Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py` +nomme celui qui a manqué : + +* **la mémoire** — chaque étage doit faire tourner ses propres démons + (`pve-cluster`, `pvestatd`, `pvedaemon`, `pveproxy`) *et* héberger son + enfant ; * **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit - aussi contenir son propre système. + aussi contenir son propre système ; +* **le processeur** — chaque étage en veut un de plus que son enfant, donc dix + étages en demandent onze au premier. La moitié des cœurs physiques est le + plafond : l'orchestrateur tourne sur cette machine lui aussi. -Ce qui se dégrade est mesuré, pas supposé : au-delà du deuxième étage, les -fabricants ne documentent rien. D'où un seul nombre borné — **2 vCPU** pour -tout étage imbriqué. Douze vCPU au quatrième étage ont gelé le noyau invité en -tout début de démarrage ; les mêmes deux avançaient. Amener douze processeurs -en ligne coûte autant d'allers-retours à travers toute la pile. +Ce troisième budget est mesuré, pas supposé. Douze vCPU au quatrième étage ont +gelé le noyau invité en tout début de démarrage — même pointeur d'instruction à +trois relevés, deux minutes d'écart — quand deux avançaient. Le nombre n'était +pas le fautif : cette VM avait douze vCPU sur un hôte qui en avait deux, six +fois plus large que sa propre machine. C'est le surengagement qui gèle, pas le +douze. -La mémoire n'est **pas** bornée. Sur cette unique VM examinée à la main, la -faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu -les mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. -La mémoire n'était pas le levier ; le nombre de vCPU l'était. Et la rogner -priverait l'étage du dessous, qui en a besoin pour héberger le suivant. +La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire +passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les +mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer diff --git a/LongTest/README.md b/LongTest/README.md index 03ec8c3..ef5fb45 100644 --- a/LongTest/README.md +++ b/LongTest/README.md @@ -43,27 +43,42 @@ the repository: it is our code we want to exercise, and the remote is often behind the checkout — a fix absent from the remote made the same defect "come back" on three VMs in a row. -### The resource algorithm +### The resource algorithm — sized from the bottom up -Two things run out going down, and a third degrades. What runs out is -arithmetic, and `script/proxmox/nesting.py` computes it: +The first version handed down whatever the parent could spare, and a real +descent showed what that costs. Level 4 ended up with 44 GB of memory and +2 vCPU **on a host that had 2** — a hundred percent overcommit, at every +level, with the hypervisor itself to serve on top. Its install ran past two +and a half hours against thirteen minutes for level 3, and extrapolating that +ratio gave five years for the tenth. -* **memory** — each level keeps what its own daemons need (`pve-cluster`, - `pvestatd`, `pvedaemon`, `pveproxy`) before handing the rest down; +So the direction is reversed. The deepest level gets what a test Proxmox +actually asks for — 4 GB of memory, 25 GB of disk, 2 vCPU — and every parent +above it adds its own overhead and nothing else: one vCPU, 2 GiB, 10 GB. A +ten-level descent therefore asks its first level for 11 vCPU, 22 GB and +115 GB, where handing resources down wanted 50 GB of memory for the same +depth. + +Three budgets can bound the depth, and `script/proxmox/nesting.py` names the +one that ran out: + +* **memory** — every level must run its own daemons (`pve-cluster`, + `pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child; * **disk** — the child's disk lives *inside* the parent's, which must also - hold its own system. + hold its own system; +* **processor** — each level wants one vCPU more than its child, so ten levels + ask eleven of the first. Half the physical cores is the ceiling: the + orchestrator runs on that machine too. -What degrades is measured, not assumed: past the second level, vendors -document nothing. Hence one capped number — **2 vCPU** for every nested -level. Twelve vCPU at the fourth level froze the guest kernel in early boot; -the same two progressed. Bringing twelve processors online costs as many -round trips through the whole stack. +That third budget is measured, not assumed. Twelve vCPU at the fourth level +froze the guest kernel in early boot — same instruction pointer at three +readings two minutes apart — while two progressed. The number was not the +culprit: that VM had twelve vCPU on a host with two, six times wider than its +own machine. Overcommit freezes, not the twelve. -Memory is **not** capped. On that one manual VM, dropping it from 9 GB to -2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply -the size of the boot files. Memory was not the lever; the vCPU count was. And -trimming memory would starve the level below, which needs it to host the -next. +Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB +moved nothing — it stopped after reading the same 32 MiB, which is simply the +size of the boot files. The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 1092a74..dd8595e 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -24,8 +24,11 @@ Deux nombres viennent de la même mesure, et méritent d'être dits : * 12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début de démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu - de plus. Les mêmes 2 vCPU avançaient. D'où VCPU_IMBRIQUE = 2 : amener douze - processeurs en ligne demande autant d'allers-retours à travers la pile ; + de plus. Les mêmes 2 vCPU avançaient. Le nombre n'était pas le fautif : cette + VM avait douze vCPU sur un hôte qui en avait DEUX, six fois plus large que sa + propre machine. C'est le surengagement qui gèle, pas le douze — d'où le + dimensionnement par étage plus bas, qui donne à chaque parent un vCPU de plus + qu'à son enfant ; * cette VM-là s'arrêtait après avoir lu 33 682 432 octets — 32 Mio, soit simplement la taille de ses fichiers d'amorçage — et le chiffre ne bougeait pas quand on lui retirait de la mémoire. La mémoire n'était donc pas le @@ -47,21 +50,49 @@ 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 -# l'observation d'un Proxmox imbriqué au repos ; le disque, de la mesure d'un -# système installé (5,6 Go) plus de la place pour écrire. +# Ce qu'un étage garde pour LUI, en plus de ce qu'il cède à son enfant. La +# RAM vient de l'observation d'un Proxmox imbriqué au repos ; le disque, de la +# mesure d'un système installé (5,6 Go) plus de la place pour écrire. PVE_RAM_MO = 2048 PVE_DISQUE_GO = 10 +# Ce dont le PLUS PROFOND a besoin — et c'est de là qu'on part. +# +# Le dimensionnement allait d'abord de haut en bas : chaque étage recevait tout +# ce que son parent pouvait céder. Mesuré sur une descente réelle, l'étage 4 se +# retrouvait avec 44 Go — onze millions de pages à cartographier, chaque défaut +# traversant les quatre hyperviseurs empilés. Son installation dépassait deux +# heures et demie là où l'étage 3 mettait treize minutes, et l'extrapolation +# donnait cinq ANS pour le dixième étage. +# +# On part donc du bas : le plus profond reçoit ce qu'un Proxmox de test demande +# vraiment, et chaque parent ajoute seulement son propre surcoût. Pour dix +# étages, le premier a besoin de 4 + 9×2 = 22 Go au lieu de cinquante — et +# chaque étage est PETIT, donc rapide. +PVE_RAM_CIBLE_MO = 4096 +PVE_DISQUE_CIBLE_GO = 25 + # En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place # d'importer une image cloud. RAM_MIN_MO = 2048 DISQUE_MIN_GO = 15 -# Le premier étage tourne sur la machine physique : il peut être large. Les -# suivants non — voir la mesure dans l'en-tête. -VCPU_NIVEAU1_MAX = 4 +# Le processeur se dimensionne DEPUIS LE BAS lui aussi, et pour la même +# raison que la mémoire — mais celle-là s'est vue à l'usage. +# +# Avec deux vCPU à chaque étage imbriqué, l'étage 3 avait deux vCPU pour +# héberger un invité qui en demandait deux : cent pour cent de surengagement, +# et l'hyperviseur lui-même à servir par-dessus. À chaque étage. Mesuré sur une +# descente réelle : une seconde VM démarrée au quatrième étage a lu DEUX +# KILO-OCTETS en onze minutes, affamée par l'installation qui tournait à côté. +# +# Chaque étage reçoit donc UN vCPU de plus que son enfant : le plus profond en +# a deux, son parent trois, et ainsi de suite. Le premier étage d'une descente +# à dix en demande onze — sur vingt-huit cœurs réels, cela passe. VCPU_IMBRIQUE = 2 +# Ce qu'on accepte de prendre à la machine physique : la moitié de ses cœurs. +# L'orchestrateur tourne dessus, et la suite de tests aussi. +VCPU_HOTE_PART = 2 # Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants. # On ne refuse pas — on le DIT. @@ -74,59 +105,81 @@ def nesting_plan( ram_dispo_mo: int, disque_libre_go: int, ) -> dict: - """Les ressources de chaque étage, et jusqu'où l'arithmétique va. + """Les ressources de chaque étage, dimensionnées DEPUIS LE BAS. - Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` - nomme ce qui a manqué — « ram » ou « disque » — quand la profondeur - demandée n'est pas atteinte, sinon "". + Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` nomme + ce qui a manqué — « ram » ou « disque » — quand la profondeur demandée + n'est pas atteinte, sinon "". + + Depuis le bas, et c'est tout le sujet. De haut en bas, chaque étage + recevait ce que son parent pouvait céder : mesuré, l'étage 4 se retrouvait + avec 44 Go de RAM et son installation dépassait deux heures et demie + contre treize minutes pour l'étage 3. Sous pagination imbriquée, un gros + invité coûte cher à cartographier, et le coût se multiplie par étage. + + Le plus profond reçoit donc ce qu'un Proxmox de test demande, et chaque + parent ajoute son surcoût — rien de plus. Une descente à dix étages + demande alors 22 Go au premier au lieu de cinquante, et chaque étage est + petit. On ne rend jamais un plan qu'on sait impossible : mieux vaut annoncer six - étages et en réussir six que d'en promettre dix et mourir au septième - sans savoir pourquoi. + étages et en réussir six que d'en promettre dix et mourir au septième sans + savoir pourquoi. """ - # 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. 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 - # étage, et « --depth 0 » créait donc une VM. range(1, 1) est déjà vide, - # et un plan vide est la bonne réponse à une demande vide. - for niveau in range(1, int(profondeur) + 1): - if niveau > 1: - ram -= PVE_RAM_MO - disque -= PVE_DISQUE_GO - if ram < RAM_MIN_MO: - arret = "ram" - break - if disque < DISQUE_MIN_GO: - arret = "disque" - break - niveaux.append( - { - "niveau": niveau, - # Le plancher est VCPU_IMBRIQUE et non 1 : sur un hôte de - # quatre cœurs, « // 4 » donnait UN vCPU au premier étage — - # l'hyperviseur parent — alors que son invité en recevait - # deux. Un parent plus étroit que son enfant est absurde, et - # c'est tout l'inverse de ce que ce module raconte. - "vcpu": ( - max( - VCPU_IMBRIQUE, - min(VCPU_NIVEAU1_MAX, int(cpu_hote) // 4), - ) - if niveau == 1 - else VCPU_IMBRIQUE - ), - "ram": ram, - "disque": disque, - } + budget_ram = ((int(ram_dispo_mo) - reserve) // 1024) * 1024 + budget_disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO + + def besoin(d): + """Ce que le PREMIER étage doit avoir pour qu'une descente de `d` + étages tienne : la cible du bas, plus un surcoût par étage au-dessus. + + Le processeur en fait partie : chaque étage en veut un de plus que son + enfant, donc le premier en veut VCPU_IMBRIQUE + d - 1. Sans cette + condition, on annonçait dix étages sur une machine à quatre cœurs. + """ + return ( + PVE_RAM_CIBLE_MO + (d - 1) * PVE_RAM_MO, + PVE_DISQUE_CIBLE_GO + (d - 1) * PVE_DISQUE_GO, + VCPU_IMBRIQUE + d - 1, ) + + budget_vcpu = max(VCPU_IMBRIQUE, int(cpu_hote) // VCPU_HOTE_PART) + atteignable, arret = 0, "" + for d in range(max(0, int(profondeur)), 0, -1): + ram1, disque1, vcpu1 = besoin(d) + if ( + ram1 <= budget_ram + and disque1 <= budget_disque + and vcpu1 <= budget_vcpu + ): + atteignable = d + break + if atteignable < int(profondeur): + # Nommer CE qui a manqué, à la profondeur demandée. + ram1, disque1, vcpu1 = besoin(max(1, int(profondeur))) + if ram1 > budget_ram: + arret = "ram" + elif disque1 > budget_disque: + arret = "disque" + else: + arret = "vcpu" + niveaux = [ + { + "niveau": niveau, + # UN de plus que son enfant. Un parent aussi étroit que son + # enfant, c'est cent pour cent de surengagement — et l'hyperviseur + # à servir en plus. + "vcpu": VCPU_IMBRIQUE + (atteignable - niveau), + "ram": PVE_RAM_CIBLE_MO + (atteignable - niveau) * PVE_RAM_MO, + "disque": PVE_DISQUE_CIBLE_GO + + (atteignable - niveau) * PVE_DISQUE_GO, + } + for niveau in range(1, atteignable + 1) + ] return { "demandee": int(profondeur), - "atteignable": len(niveaux), + "atteignable": atteignable, "niveaux": niveaux, "arret": arret, } diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 3da0362..72f9271 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -18,97 +18,96 @@ from script.proxmox import nesting # noqa: E402 class TestLePlanDesEtages(unittest.TestCase): - """Deux ressources s'épuisent en descendant, et le plan doit le dire - AVANT de créer quoi que ce soit.""" + """Le plan se dimensionne DEPUIS LE BAS, et c'est une correction. + + De haut en bas, chaque étage recevait ce que son parent pouvait céder. + Mesuré sur une descente réelle : l'étage 4 se retrouvait avec 44 Go de RAM + et deux vCPU sur un hôte qui en avait deux — cent pour cent de + surengagement, à chaque étage. Son installation dépassait deux heures et + demie contre treize minutes pour l'étage 3, et l'extrapolation donnait cinq + ANS pour le dixième. + + Le plus profond reçoit donc ce qu'un Proxmox de test demande, et chaque + parent ajoute son propre surcoût — un vCPU, deux gibioctets, dix + gigaoctets. Rien de plus.""" # La machine réelle sur laquelle l'algorithme a été réglé. - HOTE = dict(cpu_hote=28, ram_dispo_mo=29549, disque_libre_go=139) + HOTE = dict(cpu_hote=28, ram_dispo_mo=58000, disque_libre_go=165) def test_ten_levels_fit_on_this_machine(self): plan = nesting.nesting_plan(10, **self.HOTE) self.assertEqual(plan["atteignable"], 10) self.assertEqual(plan["arret"], "") - def test_every_level_shrinks(self): - # Le disque de l'enfant vit DANS celui du parent, qui doit aussi - # contenir son propre système : rien ne peut rester constant. - niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] - for precedent, suivant in zip(niveaux, niveaux[1:]): - self.assertLess(suivant["ram"], precedent["ram"]) - self.assertLess(suivant["disque"], precedent["disque"]) + def test_the_deepest_level_gets_exactly_the_target(self): + """C'est de là qu'on part : ce qu'un Proxmox de test demande, pas ce + qui reste.""" + plan = nesting.nesting_plan(10, **self.HOTE) + fond = plan["niveaux"][-1] + self.assertEqual(fond["ram"], nesting.PVE_RAM_CIBLE_MO) + self.assertEqual(fond["disque"], nesting.PVE_DISQUE_CIBLE_GO) + self.assertEqual(fond["vcpu"], nesting.VCPU_IMBRIQUE) - def test_the_first_level_may_be_wide_the_others_not(self): - """12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début - de démarrage ; les mêmes 2 vCPU avançaient. Amener douze processeurs - en ligne demande autant d'allers-retours à travers la pile.""" - niveaux = nesting.nesting_plan(4, **self.HOTE)["niveaux"] - # STRICTEMENT plus grand. « > VCPU_IMBRIQUE - 1 » était satisfait par - # la valeur imbriquée elle-même : remplacer tout le calcul du premier - # étage par VCPU_IMBRIQUE laissait les tests verts, donc cpu_hote - # n'était couvert par rien. - self.assertGreater(niveaux[0]["vcpu"], nesting.VCPU_IMBRIQUE) - for n in niveaux[1:]: - self.assertEqual(n["vcpu"], nesting.VCPU_IMBRIQUE) + def test_each_parent_adds_exactly_its_own_overhead(self): + # Ni plus ni moins : un parent plus large que nécessaire ralentit tout + # ce qu'il héberge, un parent trop juste ne le fait pas tourner. + niveaux = nesting.nesting_plan(8, **self.HOTE)["niveaux"] + for parent, enfant in zip(niveaux, niveaux[1:]): + self.assertEqual(parent["ram"] - enfant["ram"], nesting.PVE_RAM_MO) + self.assertEqual( + parent["disque"] - enfant["disque"], nesting.PVE_DISQUE_GO + ) + self.assertEqual(parent["vcpu"] - enfant["vcpu"], 1) def test_a_parent_is_never_narrower_than_its_child(self): - """Sur un hôte de quatre cœurs, « // 4 » donnait UN vCPU au premier - étage — l'hyperviseur — alors que son invité en recevait deux.""" - for coeurs in (2, 4, 8, 12, 28): + """Deux vCPU hébergeant deux vCPU, c'est cent pour cent de + surengagement — et l'hyperviseur à servir en plus. Mesuré : une VM + démarrée au quatrième étage a lu DEUX KILO-OCTETS en onze minutes, + affamée par l'installation qui tournait à côté.""" + for coeurs in (4, 8, 12, 28): with self.subTest(coeurs=coeurs): niveaux = nesting.nesting_plan( - 3, + 6, cpu_hote=coeurs, - ram_dispo_mo=32768, - disque_libre_go=300, + ram_dispo_mo=64000, + disque_libre_go=400, )["niveaux"] - self.assertGreaterEqual( - niveaux[0]["vcpu"], niveaux[1]["vcpu"], f"{coeurs} cœurs" - ) + for parent, enfant in zip(niveaux, niveaux[1:]): + self.assertGreater(parent["vcpu"], enfant["vcpu"]) + self.assertGreater(parent["ram"], enfant["ram"]) + self.assertGreater(parent["disque"], enfant["disque"]) + + def test_the_cpu_budget_can_bound_the_depth(self): + """Sur une petite machine, c'est le PROCESSEUR qui borne, pas la + mémoire : chaque étage en veut un de plus que son enfant, donc dix + étages demandent onze vCPU au premier.""" + plan = nesting.nesting_plan( + 10, cpu_hote=8, ram_dispo_mo=64000, disque_libre_go=400 + ) + self.assertEqual(plan["arret"], "vcpu") + self.assertLess(plan["atteignable"], 10) + # Et le premier étage ne dépasse pas la part concédée à l'hôte. + self.assertLessEqual( + plan["niveaux"][0]["vcpu"], 8 // nesting.VCPU_HOTE_PART + ) def test_running_out_of_ram_is_named(self): plan = nesting.nesting_plan( - 10, cpu_hote=8, ram_dispo_mo=12288, disque_libre_go=500 + 10, cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500 ) self.assertEqual(plan["arret"], "ram") self.assertLess(plan["atteignable"], 10) - # Aucun étage sous le plancher : un Proxmox sous 2 Go ne démarre pas - # ses démons. for n in plan["niveaux"]: self.assertGreaterEqual(n["ram"], nesting.RAM_MIN_MO) def test_running_out_of_disk_is_named(self): plan = nesting.nesting_plan( - 10, cpu_hote=8, ram_dispo_mo=200000, disque_libre_go=60 + 10, cpu_hote=28, ram_dispo_mo=200000, disque_libre_go=60 ) self.assertEqual(plan["arret"], "disque") 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): @@ -122,34 +121,17 @@ class TestLePlanDesEtages(unittest.TestCase): ) self.assertEqual(plan["atteignable"], 0) self.assertEqual(plan["niveaux"], []) - self.assertEqual(plan["arret"], "ram") + self.assertTrue(plan["arret"]) def test_a_plan_is_never_promised_beyond_what_fits(self): # Mieux vaut annoncer six étages et en réussir six que d'en promettre # dix et mourir au septième sans savoir pourquoi. - # Depuis 0 et depuis les négatifs : « max(1, …) » forçait un tour, - # donc « --depth 0 » rendait un plan d'UN étage et créait une VM. for profondeur in range(-2, 13): plan = nesting.nesting_plan(profondeur, **self.HOTE) self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) - # « max(0, …) » : une demande négative ne peut pas donner un - # nombre d'étages négatif, elle donne zéro. self.assertLessEqual(plan["atteignable"], max(0, profondeur)) -class TestLaProfondeurDUnHote(unittest.TestCase): - """Comptée depuis la chaîne de rebonds : c'est la seule mesure dont on - dispose de l'extérieur, et elle est exacte pour les hôtes que nous avons - nous-mêmes déployés — c'est nous qui écrivons ces entrées.""" - - def test_no_jump_is_the_first_level(self): - self.assertEqual(nesting.depth_from_jumps(0), 1) - - def test_each_jump_adds_a_level(self): - for sauts, attendu in ((1, 2), (2, 3), (3, 4), (9, 10)): - self.assertEqual(nesting.depth_from_jumps(sauts), attendu) - - class TestCompterLesRebonds(unittest.TestCase): """La profondeur se lit dans ~/.ssh/config : un ProxyJump par étage. From d0a06c03b77ff28dbc92750389dfc73e4689a8d8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 05:39:08 -0400 Subject: [PATCH 12/26] =?UTF-8?q?[FIX]=20LongTest=20:=20rapport=20=C3=A9cr?= =?UTF-8?q?it=20VM=20par=20VM,=20retrait=20ssh=20sans=20bloc=20nu?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Descente de dix étages arrêtée pendant l'installation du quatrième : quatre machines réelles restaient, et « --detruire » répondait « aucun rapport : rien à défaire ». Le rapport ne s'écrivait qu'à la fin, donc le seul enregistrement du couple (alias du parent, VMID) mourait avec le processus. Il fallait les retrouver par leur NOM, ce que tout ce fichier s'applique à éviter. En nettoyant à la main, second défaut : appelé sans nom à écrire — un retrait pur, légitime, les machines n'existant plus — _write_ssh_config_entry écrivait « Host » NU suivi d'un « HostName » vide dans le ~/.ssh/config réel, puis mourait sur IndexError en annonçant l'ajout. Constaté, puis retiré du fichier. Les deux correctifs meurent sous mutation : 3 tests et 2 tests. --- EN --- A ten-level descent stopped during the fourth level's install: four real machines were left, and "--detruire" answered "no report: nothing to undo". The report was only written at the end, so the sole record of the (parent alias, VMID) pair died with the process. They had to be found by NAME, which this whole file works to avoid. Cleaning up by hand surfaced a second defect: called with no name to write — a pure removal, legitimate since the machines are gone — _write_ssh_config_entry wrote a BARE "Host" followed by an empty "HostName" into the real ~/.ssh/config, then died on IndexError while announcing the addition. Observed, then removed. Both fixes die under mutation: 3 tests and 2 tests. Assisted-by: claude-opus-5 (cherry picked from commit e7c8b540c630b33c6eb1b1a2f51c01497e0b3ca0) --- LongTest/deep_proxmox.py | 69 +++++++++++++++---- script/todo/todo.py | 12 ++++ script/todo/todo_i18n.py | 4 ++ test/test_proxmox_form.py | 42 ++++++++++++ test/test_todo_longtest.py | 132 +++++++++++++++++++++++++++++++++++-- 5 files changed, 238 insertions(+), 21 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 9bd0d26..4f5fc94 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -130,9 +130,10 @@ def alias_etage(niveau, parent_alias): class Descente: """Un étage après l'autre, et ce qu'on en sait.""" - def __init__(self, plan, journal, dry_run=False): + def __init__(self, plan, journal, dry_run=False, chemin_json=None): self.plan = plan self.journal = journal + self.chemin_json = chemin_json self.dry_run = dry_run self.etages = [] self.interrompu = False @@ -520,6 +521,8 @@ class Descente: self.interrompu = True break alias = nom + # Le domaine libvirt existe : le rapport doit exister aussi. + self._sauver(etage) else: prepare = self.preparer_parent(parent) if not prepare: @@ -538,6 +541,7 @@ class Descente: # sert. Sans lui, une VM abandonnée juste après « qm create » # n'était nommée nulle part. etage["parent_alias"] = parent_alias + self._sauver(etage) alias = alias_etage(niveau, parent_alias) if not self.dry_run: self.ecrire_alias(alias, adresse, parent_alias) @@ -560,6 +564,7 @@ class Descente: ("pmxcfs", lambda: self.reparer_pmxcfs(cible)), ): etage["etape"] = etape + self._sauver(etage) if not action(): self.etages.append(etage) return self.rapport(interrompu=True) @@ -571,6 +576,7 @@ class Descente: etage["ok"] = not self.dry_run etage["secondes"] = int(time.time() - debut) self.etages.append(etage) + self._sauver() self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") parent, parent_alias = cible, alias return self.rapport(interrompu=self.interrompu) @@ -589,8 +595,52 @@ class Descente: identity_file=prive, ) + def _etat(self, interrompu, en_cours=None): + """Le rapport, à cet instant. `en_cours` : l'étage pas encore rangé.""" + etages = list(self.etages) + if en_cours is not None and en_cours not in etages: + etages.append(en_cours) + return { + "demandee": self.plan["demandee"], + "atteignable": self.plan["atteignable"], + "atteinte": sum(1 for e in etages if e.get("ok")), + "interrompu": interrompu, + # Sans ce champ, un rapport d'essai à blanc se lisait comme une + # descente réussie — et « --detruire » s'en servait. + "dry_run": self.dry_run, + "etages": etages, + } + + def _sauver(self, en_cours=None): + """Écrit le rapport PARTIEL, dès qu'une VM existe. + + Il ne s'écrivait qu'à la fin. Une descente tuée au quatrième étage — + c'est arrivé — laissait quatre machines réelles et « --detruire » + répondait « aucun rapport : rien à défaire » : le seul enregistrement + du couple (alias du parent, VMID) mourait avec le processus. Il fallait + alors les retrouver et les détruire à la main, c'est-à-dire par leur + nom, ce que tout le reste de ce fichier s'applique à ne pas faire. + + Marqué « interrompu » jusqu'au bout : un rapport partiel ne doit jamais + se lire comme une descente terminée. + """ + if self.dry_run or not self.chemin_json: + return + temporaire = self.chemin_json + ".tmp" + try: + with open(temporaire, "w", encoding="utf-8") as fh: + json.dump( + self._etat(interrompu=True, en_cours=en_cours), + fh, + indent=2, + ) + os.replace(temporaire, self.chemin_json) + except OSError as err: + self.dire(f" ⚠ rapport non écrit : {err}") + def rapport(self, interrompu=False): - atteint = sum(1 for e in self.etages if e["ok"]) + etat = self._etat(interrompu) + atteint = etat["atteinte"] print("") if self.dry_run: self.dire( @@ -611,16 +661,7 @@ class Descente: f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] ) self.dire(f" {marque} étage {e['niveau']:2d} {detail}") - return { - "demandee": self.plan["demandee"], - "atteignable": self.plan["atteignable"], - "atteinte": atteint, - "interrompu": interrompu, - # Sans ce champ, un rapport d'essai à blanc se lisait comme une - # descente réussie — et « --detruire » s'en servait. - "dry_run": self.dry_run, - "etages": self.etages, - } + return etat def dernier_rapport(): @@ -868,9 +909,9 @@ def principal(argv=None): print(f"\n journal : {journal}") if args.dry_run: print(" --dry-run : rien ne sera créé.\n") - descente = Descente(plan, journal, args.dry_run) - rapport = descente.parcourir() chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") + descente = Descente(plan, journal, args.dry_run, chemin) + rapport = descente.parcourir() with open(chemin, "w", encoding="utf-8") as fh: json.dump(rapport, fh, indent=2) print(f"\n rapport : {chemin}\n") diff --git a/script/todo/todo.py b/script/todo/todo.py index cd9acf1..2a2dd43 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1468,6 +1468,18 @@ class TODO( existing = self._ssh_config_drop_hosts( existing, names + [n for n in also_drop if n not in names] ).rstrip("\n") + if not names: + # Retirer sans réécrire est un appel légitime : les machines + # n'existent plus. Sans ce retour, un « Host » NU était écrit dans + # le ~/.ssh/config de l'utilisateur — un bloc sans nom, suivi d'un + # « HostName » vide, qui s'applique alors à rien et brouille la + # lecture du fichier. + with open(cfg, "w", encoding="utf-8") as fh: + fh.write(existing + "\n" if existing else "") + os.chmod(cfg, 0o600) + retires = ", ".join(also_drop) + print(f"🗑 {t('Removed from ~/.ssh/config:')} {retires}") + return block = ( f"Host {' '.join(names)}\n" f" HostName {ip}\n" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index b292f80..d94bf33 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -278,6 +278,10 @@ TRANSLATIONS = { "fr": "Ajouté à ~/.ssh/config :", "en": "Added to ~/.ssh/config:", }, + "Removed from ~/.ssh/config:": { + "fr": "Retiré de ~/.ssh/config :", + "en": "Removed from ~/.ssh/config:", + }, "SSH address input method": { "fr": "Méthode de saisie de l'adresse SSH", "en": "SSH address input method", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index adbc7fc..7276470 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -1057,6 +1057,48 @@ class TestLAncienNomSEnVa(unittest.TestCase): ligne.rstrip() for ligne in fh if ligne.startswith("Host ") ] + def test_dropping_the_last_entry_writes_no_nameless_block(self): + """Retirer sans réécrire est un appel légitime : les machines + n'existent plus. + + Constaté dans le vrai ~/.ssh/config de l'utilisateur : l'appel écrivait + « Host » NU, suivi d'un « HostName » vide, puis mourait sur un + IndexError en annonçant l'ajout. Le bloc sans nom s'applique à rien et + brouille la lecture du fichier.""" + import os + + self.todo._write_ssh_config_entry( + ["deep-1"], "erplibre", "10.10.10.150" + ) + self.todo._write_ssh_config_entry( + ["deep-2"], "erplibre", "10.10.10.151", proxy_jump="deep-1" + ) + self.todo._write_ssh_config_entry( + [], "erplibre", "", also_drop=("deep-1", "deep-2") + ) + self.assertEqual(self._hosts(), []) + with open( + os.path.join(self.maison, ".ssh/config"), encoding="utf-8" + ) as fh: + reste = fh.read() + self.assertNotIn("Host", reste) + self.assertNotIn("HostName", reste) + # Et le fichier garde ses droits : ssh refuse un config trop ouvert. + self.assertEqual( + oct(os.stat(os.path.join(self.maison, ".ssh/config")).st_mode)[ + -3: + ], + "600", + ) + + def test_dropping_one_entry_leaves_the_others_untouched(self): + for nom, ip in (("garde-a", "10.0.0.1"), ("part", "10.0.0.2")): + self.todo._write_ssh_config_entry([nom], "erplibre", ip) + self.todo._write_ssh_config_entry( + [], "erplibre", "", also_drop=("part",) + ) + self.assertEqual(self._hosts(), ["Host garde-a"]) + def test_the_old_short_entry_is_retired(self): # L'état d'avant : une entrée écrite sous l'ancienne convention. self.todo._write_ssh_config_entry( diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 7ad9c73..5be410b 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -10,9 +10,14 @@ sans virtualisation. Ce fichier-ci vérifie la frontière, et que l'essai à blanc du test long dit quelque chose sans rien créer. """ +import contextlib +import io +import json import os +import shutil import subprocess import sys +import tempfile import unittest sys.argv = ["todo.py"] @@ -180,9 +185,14 @@ class TestLEssaiABlanc(unittest.TestCase): self.assertEqual(rapport["atteinte"], 0) self.assertTrue(all(not e["ok"] for e in rapport["etages"])) - def test_the_first_level_is_wide_and_the_others_are_not(self): - # 12 vCPU au quatrième étage ont gelé un noyau invité ; deux - # avançaient. + def test_the_plan_shrinks_towards_the_bottom(self): + """Chaque étage annoncé est plus étroit que son parent, sur les trois + ressources. + + Deux vCPU à chaque étage imbriqué donnaient un parent aussi étroit que + son enfant : cent pour cent de surengagement, et l'hyperviseur à servir + par-dessus. Mesuré : l'installation de l'étage 4 dépassait 2 h 50 + contre 793 s pour l'étage 3.""" # Par expression exacte : la ligne « machine : … Mo … Go » du haut # contient les mêmes unités et décalait l'index d'un cran. import re @@ -193,10 +203,17 @@ class TestLEssaiABlanc(unittest.TestCase): re.M, ) self.assertEqual(len(plan), 4, plan) - niveaux = {int(n): int(v) for n, v, _r, _d in plan} - self.assertGreater(niveaux[1], 1, "le premier étage peut être large") - for niveau in (2, 3, 4): - self.assertEqual(niveaux[niveau], 2, f"étage {niveau}") + etages = sorted( + (int(n), int(v), int(r), int(d)) for n, v, r, d in plan + ) + for parent, enfant in zip(etages, etages[1:]): + for i, quoi in ((1, "vCPU"), (2, "RAM"), (3, "disque")): + self.assertGreater( + parent[i], enfant[i], f"étage {parent[0]} : {quoi}" + ) + # Et le plus profond reçoit ce qu'un Proxmox de test demande, pas ce + # qui reste. + self.assertEqual(etages[-1][1], 2, "vCPU du plus profond") class TestDefaireSansEffacerAutreChose(unittest.TestCase): @@ -272,6 +289,107 @@ class TestDefaireSansEffacerAutreChose(unittest.TestCase): self.assertIn("dry_run=args.dry_run", principal) +class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): + """Le rapport ne s'écrivait qu'à la FIN de la descente. + + Constaté : une descente de dix étages arrêtée pendant l'installation du + quatrième laissait quatre machines réelles, et « --detruire » répondait + « aucun rapport de descente : rien à défaire ». Le seul enregistrement du + couple (alias du parent, VMID) mourait avec le processus — il fallait + retrouver ces VM à la main, c'est-à-dire par leur nom, ce que tout le + reste de ce fichier s'applique à ne pas faire. + """ + + 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-rapport-") + self.addCleanup(shutil.rmtree, self.dossier, ignore_errors=True) + + def _descente_tuee(self, a_l_etage): + """Une descente dont l'installation MEURT à l'étage donné. + + Rien de réel n'est touché : aucune des méthodes qui créent une machine + ou écrivent dans ~/.ssh/config n'est appelée pour de vrai. + """ + niveaux = [ + {"niveau": n, "vcpu": 2, "ram": 4096, "disque": 25} + for n in (1, 2, 3) + ] + plan = {"demandee": 3, "atteignable": 3, "niveaux": niveaux} + chemin = os.path.join(self.dossier, "rapport.json") + d = self.dp.Descente(plan, None, False, chemin) + + 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}", + ) + d.ecrire_alias = lambda *a, **k: None + d.attendre_ssh = lambda cible, delai: 1 + d.redemarrer_et_verifier = lambda cible: True + d.reparer_pmxcfs = lambda cible: True + + def installer(cible): + appels.append(cible) + if len(appels) >= a_l_etage: + raise KeyboardInterrupt("descente tuée") + return True + + d.installer_proxmox = installer + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(KeyboardInterrupt): + d.parcourir() + with open(chemin, encoding="utf-8") as fh: + return json.load(fh) + + def test_a_killed_descent_still_names_what_it_created(self): + rapport = self._descente_tuee(a_l_etage=3) + # Le couple (parent, VMID) des étages imbriqués créés : c'est de lui + # seul que « --detruire » se sert. + self.assertEqual( + self.dp.a_defaire(rapport), + [ + (3, "deep-pve-1+deep-pve-2", 103, self.dp.nom_etage(3)), + (2, "deep-pve-1", 102, self.dp.nom_etage(2)), + ], + ) + + def test_the_report_exists_as_soon_as_the_first_vm_does(self): + """Tuée pendant l'installation de l'étage 1, il n'y a aucun VMID à + noter — mais le domaine libvirt existe, et sans rapport « --detruire » + ne le regardait même pas.""" + rapport = self._descente_tuee(a_l_etage=1) + self.assertTrue(rapport["etages"]) + self.assertEqual(self.dp.a_defaire(rapport), []) + + def test_a_partial_report_never_reads_as_a_finished_descent(self): + rapport = self._descente_tuee(a_l_etage=3) + self.assertTrue(rapport["interrompu"]) + self.assertLess(rapport["atteinte"], rapport["demandee"]) + # Et il n'est pas écarté comme un essai à blanc : c'est bien de VRAIES + # machines qu'il parle. + self.assertFalse(rapport["dry_run"]) + + def test_a_dry_run_writes_no_partial_report(self): + """Un plan n'a rien créé : lui laisser écrire un rapport ferait + détruire d'après un plan.""" + plan = { + "demandee": 1, + "atteignable": 1, + "niveaux": [{"niveau": 1, "vcpu": 2, "ram": 4096, "disque": 25}], + } + chemin = os.path.join(self.dossier, "blanc.json") + d = self.dp.Descente(plan, None, True, chemin) + with contextlib.redirect_stdout(io.StringIO()): + d._sauver({"niveau": 1, "vmid": 101, "parent_alias": "x"}) + self.assertFalse(os.path.exists(chemin)) + + class TestLeMenu(unittest.TestCase): def test_the_mixin_is_wired_into_TODO(self): todo = TODO.__new__(TODO) From c046e027e9f52bd8371a7a5e4d3f36a507c83486 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 06:56:35 -0400 Subject: [PATCH 13/26] =?UTF-8?q?[FIX]=20LongTest=20:=20ne=20pas=20d=C3=A9?= =?UTF-8?q?truire=20sous=20une=20descente=20vivante=20;=20blocs=20ssh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Relecture adversaire du commit précédent : il avait CRÉÉ un danger. Le rapport s'écrivant maintenant VM par VM, celui de la descente EN COURS est le plus récent, et « --detruire » l'aurait choisi — qm destroy --purge sur l'arbre que le processus installait encore. Deux garde-fous : un PID dans le rapport, et un refus net tant qu'un autre deep_proxmox.py tourne. Le second est nécessaire car une descente déjà lancée a l'ancien module en mémoire. Reconnu par ARGUMENT, pas par sous-chaîne : mon propre « pgrep -f deep_proxmox.py » de surveillance donnait deux faux positifs sur trois. Trois autres, mêmes preuves : - un rapport vide plus récent masquait celui qui nommait les VM réelles ; - detruire() ne créditait jamais l'étage 1 : le décompte était décalé de un dans tous les cas, donc l'avertissement sortait toujours ; - _ssh_config_drop_hosts prenait l'indentation pour de la syntaxe. Sur un bloc au corps non indenté, seule la ligne Host partait et ssh rattachait « StrictHostKeyChecking no » au bloc précédent — un serveur de production. Et un bloc partagé (« Host prod-db vm-a ») partait en entier. Les cinq correctifs meurent sous mutation. --- EN --- Adversarial review of the previous commit: it had CREATED a hazard. With the report now written VM by VM, the RUNNING descent's is the most recent, and "--detruire" would have picked it — qm destroy --purge on the tree the process was still installing. Two guards: a PID in the report, and a flat refusal while another deep_proxmox.py runs. The second is needed because an already-running descent holds the old module in memory. Matched by ARGUMENT, not substring: my own monitoring "pgrep -f deep_proxmox.py" produced two false positives out of three. Three more, same evidence: - a newer empty report masked the one naming the real VMs; - detruire() never credited level 1: the count was off by one in every case, so the warning always fired; - _ssh_config_drop_hosts took indentation for syntax. On a block with an unindented body only the Host line went, and ssh attached "StrictHostKeyChecking no" to the preceding block — a production server. And a shared block ("Host prod-db vm-a") went entirely. All five fixes die under mutation. Assisted-by: claude-opus-5 (cherry picked from commit 7d348d976f96e420b4fec4d879911b658a730ffa) --- LongTest/deep_proxmox.py | 116 ++++++++++++++++-- script/todo/todo.py | 69 ++++++++--- test/test_proxmox_form.py | 118 +++++++++++++++++++ test/test_todo_longtest.py | 236 +++++++++++++++++++++++++++++++++++++ 4 files changed, 512 insertions(+), 27 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 4f5fc94..76fbcdb 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -628,12 +628,13 @@ class Descente: return temporaire = self.chemin_json + ".tmp" try: + etat = self._etat(interrompu=True, en_cours=en_cours) + # Le PID de la descente qui écrit : c'est ce qui distingue un + # rapport ABANDONNÉ d'un rapport en cours d'écriture. Le rapport + # final, lui, n'en porte pas — la descente est finie. + etat["pid"] = os.getpid() with open(temporaire, "w", encoding="utf-8") as fh: - json.dump( - self._etat(interrompu=True, en_cours=en_cours), - fh, - indent=2, - ) + json.dump(etat, fh, indent=2) os.replace(temporaire, self.chemin_json) except OSError as err: self.dire(f" ⚠ rapport non écrit : {err}") @@ -664,13 +665,84 @@ class Descente: return etat +def _lance_ce_script(pid): + """`pid` exécute-t-il CE script — et non pas seulement le nomme-t-il ? + + Par ARGUMENT, jamais par sous-chaîne. Constaté sur cette machine : un + « pgrep -f deep_proxmox.py » posé dans une boucle de surveillance donne un + shell dont la ligne de commande contient le motif, et le contrôle comptait + ce shell comme une descente — deux faux positifs sur trois. Un argument + qui SE TERMINE par le nom du fichier, lui, ne peut venir que d'un + interpréteur qu'on a lancé dessus. + """ + try: + with open(f"/proc/{int(pid)}/cmdline", "rb") as fh: + arguments = fh.read().split(b"\0") + except (OSError, ValueError): + return False + return any(a.endswith(b"deep_proxmox.py") for a in arguments) + + +def descente_vivante(pid): + """Le processus `pid` est-il une descente EN COURS ? + + Le PID seul ne suffirait pas : les numéros se réutilisent, et rien ne dit + qu'un rapport vieux d'une semaine ne porte pas le PID d'un shell + d'aujourd'hui. La ligne de commande est donc lue aussi. + """ + return bool(pid) and _lance_ce_script(pid) + + +def autre_deep_proxmox(): + """Les PID des AUTRES deep_proxmox.py vivants. Le sien est exclu. + + Le garde-fou du rapport — un PID dans le fichier — ne protège que les + descentes lancées APRÈS son écriture : celle qui tournait déjà avait + chargé l'ancien module en mémoire et n'écrira jamais de PID. Constaté sur + une descente réelle de dix étages, à l'étage 4. Ce contrôle-ci ne dépend + d'aucun rapport : détruire pendant qu'une descente tourne n'est jamais + juste, quel que soit le rapport choisi. + + /proc plutôt que pgrep : « pgrep -f deep_proxmox » attrape le shell qui + l'invoque, et on croit alors voir survivre un processus qui n'existe pas. + """ + moi = os.getpid() + vivants = [] + try: + entrees = os.listdir("/proc") + except OSError: + return vivants + for entree in entrees: + if not entree.isdigit() or int(entree) == moi: + continue + if _lance_ce_script(entree): + vivants.append(int(entree)) + return vivants + + def dernier_rapport(): - """Le rapport JSON le plus récent, ou {}. + """Le rapport le plus récent qui NOMME quelque chose à défaire, ou {}. C'est le SEUL enregistrement de ce que la descente a créé : un couple (alias du parent, VMID) par étage. Détruire d'après lui, et non d'après les noms, est toute la différence entre défaire son propre travail et effacer une machine qui se trouve porter un nom voisin. + + Deux rapports sont ÉCARTÉS, et chacun l'est pour un accident précis : + + * celui d'une descente VIVANTE. Depuis que le rapport s'écrit VM par VM, + la descente en cours en a un sur le disque, et c'est le plus récent : + « --detruire » aurait détruit l'arbre sous le processus qui installait + encore, emportant des heures de mesure. Avant, la descente en cours + n'avait aucun rapport et la question ne se posait pas — le correctif a + créé le danger. + + * celui qui n'a RIEN créé. Un second lancement qui meurt à l'étage 1 — + « le disque existe déjà » — écrit un rapport vide sous un horodatage + plus tardif. Il masquait le partiel qui nommait les VM réelles : + « 0 VM imbriquée(s) », puis « virsh undefine --remove-all-storage » sur + l'étage 1, dont le disque contient les étages 2 et suivants — jamais + arrêtés, jamais nommés. """ dossier = os.path.expanduser("~/.erplibre/longtest") try: @@ -680,14 +752,20 @@ def dernier_rapport(): except OSError: return {} for nom in reversed(fichiers): + chemin = os.path.join(dossier, nom) try: - with open(os.path.join(dossier, nom), encoding="utf-8") as fh: + with open(chemin, encoding="utf-8") as fh: rapport = json.load(fh) except (OSError, ValueError): continue if rapport.get("dry_run"): continue # un plan n'a rien créé - rapport["fichier"] = os.path.join(dossier, nom) + if descente_vivante(rapport.get("pid")): + dire(f" ⏳ descente EN COURS ({rapport['pid']}) : {nom} ignoré") + continue + if not (rapport.get("etages") or []): + continue # rien créé : ne pas masquer un rapport qui nomme des VM + rapport["fichier"] = chemin return rapport return {} @@ -820,6 +898,18 @@ def detruire(journal=None, dry_run=False): dont le nom contenait « deep-pve » — une machine de labo appelée « deep-pve-lab » sur un hyperviseur de production tombait dedans. """ + # Avant tout : refuser tant qu'une descente tourne. Elle installe encore + # sur les machines qu'on s'apprête à détruire, et son rapport peut être + # celui qu'on vient de choisir. + autres = autre_deep_proxmox() + if autres: + dire( + f" ⛔ une descente tourne ({', '.join(map(str, autres))}) :" + " rien ne sera détruit.", + journal, + ) + dire(" Attendre qu'elle finisse, ou l'arrêter d'abord.", journal) + return 1 rapport = dernier_rapport() if not rapport: dire(" aucun rapport de descente : rien à défaire.", journal) @@ -851,8 +941,14 @@ def detruire(journal=None, dry_run=False): for niveau, parent_alias, vmid, nom in liste if detruire_une(parent_alias, vmid, nom, journal) ) - if not detruire_etage1(journal): - faits -= 1 + # « if not … : faits -= 1 » : un succès de l'étage 1 n'ajoutait RIEN, + # alors que le total est len(liste) + 1. Le décompte était décalé de un + # dans TOUS les cas — une destruction complète annonçait « il reste des + # machines » et sortait 1, si bien que le seul avertissement censé + # prévenir qu'un disque de plusieurs dizaines de Go reste alloué + # s'affichait toujours, et qu'on apprenait à ne plus le lire. + if detruire_etage1(journal): + faits += 1 dire( f"\n {faits} / {len(liste) + 1} défait(s)." + ( diff --git a/script/todo/todo.py b/script/todo/todo.py index 2a2dd43..0a5fb21 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1398,34 +1398,69 @@ class TODO( @staticmethod def _ssh_config_drop_hosts(content, names): - """Retire les blocs « Host … » qui déclarent l'un de `names`. + """Retire de ~/.ssh/config ce qui déclare l'un de `names`. On découpe en blocs plutôt que de substituer par expression - régulière : une ligne Host peut porter PLUSIEURS noms, et il faut - alors retirer le bloc entier dès qu'un seul de ses noms est repris — - sinon le même nom se retrouverait défini deux fois, et ssh - appliquerait la première définition rencontrée.""" + régulière : une ligne Host peut porter PLUSIEURS noms. + + Deux règles, chacune corrigeant une perte de données CONSTATÉE dans + le fichier d'un utilisateur. + + 1. Seuls « Host » et « Match » clôturent un bloc. La règle d'avant — + « une ligne non indentée clôt le bloc » — prenait l'indentation + pour de la syntaxe, alors qu'elle est cosmétique dans ce format et + qu'un fichier écrit à la main s'en passe souvent. Sur un bloc au + corps non indenté, seule la ligne « Host » partait : HostName, + User, IdentityFile et « StrictHostKeyChecking no » restaient, sans + Host au-dessus, et ssh les rattachait au bloc PRÉCÉDENT. La + vérification de clé d'hôte se retrouvait désactivée sur un serveur + de production. + + 2. Un bloc qui déclare AUSSI des noms qu'on ne retire pas survit, + amputé de ceux-là seulement. Il partait en entier : « Host prod-db + vm-a » perdait le prod-db de l'utilisateur, et le surnom qu'on + ajoute à un bloc généré disparaissait au déploiement suivant. + + La queue du bloc — lignes vides et commentaires — n'est pas emportée : + elle précède le plus souvent le bloc SUIVANT, et l'utilisateur y met + ses propres notes. + """ drop = set(names) - out, block, block_names = [], [], set() + out, block, block_names = [], [], [] def flush(): - if block and not (block_names & drop): + if not block: + return + restants = [n for n in block_names if n not in drop] + if restants == block_names: out.extend(block) + return + fin = len(block) + while fin > 1 and ( + not block[fin - 1].strip() + or block[fin - 1].lstrip().startswith("#") + ): + fin -= 1 + if restants: + tete = block[0] + marge = tete[: len(tete) - len(tete.lstrip())] + out.append(f"{marge}Host {' '.join(restants)}\n") + out.extend(block[1:fin]) + out.extend(block[fin:]) for line in content.splitlines(keepends=True): - if re.match(r"^[ \t]*Host[ \t]+", line): + if re.match(r"^[ \t]*Host[ \t]+", line, re.I): flush() block = [line] - block_names = set(line.split()[1:]) + block_names = line.split()[1:] + elif re.match(r"^[ \t]*Match[ \t]+", line, re.I): + # Match ouvre une section qui n'appartient à aucun Host : la + # garder telle quelle, quel que soit le sort du bloc d'avant. + flush() + block, block_names = [], [] + out.append(line) elif block: - # Une ligne non indentée et non vide clôt le bloc (Match, - # directive globale…) : elle n'appartient à personne. - if line.strip() and not line[:1].isspace(): - flush() - block, block_names = [], set() - out.append(line) - else: - block.append(line) + block.append(line) else: out.append(line) flush() diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 7276470..34e1b71 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -1019,6 +1019,124 @@ class TestUnSeulNomDansSshConfig(unittest.TestCase): ) +class TestNeRienPerdreDansSshConfig(unittest.TestCase): + """~/.ssh/config contient les entrées PERSONNELLES de l'utilisateur. + + Ce fichier est réécrit en entier à chaque déploiement de VM. Deux pertes + de données y ont été constatées, l'une capable de désactiver la + vérification de clé d'hôte sur un serveur de production.""" + + def setUp(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.retirer = TODO._ssh_config_drop_hosts + + def test_a_block_with_an_unindented_body_goes_entirely(self): + """L'indentation est COSMÉTIQUE dans ce format, et un fichier écrit à + la main s'en passe souvent. La règle d'avant la prenait pour de la + syntaxe : seule la ligne « Host » partait.""" + avant = ( + "Host prod\n" + " HostName prod.example.com\n" + " User root\n" + "\n" + "Host deep-1\n" + "HostName 10.0.0.1\n" + "User erplibre\n" + "StrictHostKeyChecking no\n" + "UserKnownHostsFile /dev/null\n" + "IdentityFile ~/.ssh/id_deep\n" + ) + apres = self.retirer(avant, ["deep-1"]) + # Rien du bloc retiré ne subsiste : sans Host au-dessus, ssh + # rattacherait ces lignes à « prod » et la production perdrait sa + # vérification de clé d'hôte. + for orphelin in ( + "10.0.0.1", + "StrictHostKeyChecking", + "UserKnownHostsFile", + "id_deep", + ): + self.assertNotIn(orphelin, apres, orphelin) + # Et le bloc de l'utilisateur est intact. + self.assertIn("HostName prod.example.com", apres) + self.assertIn("User root", apres) + + def test_a_shared_host_line_keeps_the_names_not_dropped(self): + """« Host prod-db vm-a » perdait le prod-db de l'utilisateur : le bloc + partait en entier dès qu'UN de ses noms était repris.""" + avant = ( + "Host prod-db vm-a\n" + " HostName db.interne\n" + " ProxyJump pve9\n" + ) + apres = self.retirer(avant, ["vm-a"]) + self.assertIn("Host prod-db\n", apres) + self.assertNotIn("vm-a", apres) + # Le corps suit le nom qui reste : sinon prod-db perd son rebond. + self.assertIn("HostName db.interne", apres) + self.assertIn("ProxyJump pve9", apres) + + def test_a_nickname_added_by_hand_survives_a_redeploy(self): + avant = "Host pve9+vm-a webtest\n HostName 10.10.10.5\n" + apres = self.retirer(avant, ["pve9+vm-a"]) + self.assertIn("Host webtest\n", apres) + self.assertIn("HostName 10.10.10.5", apres) + + def test_all_names_dropped_removes_the_block(self): + avant = "Host a b\n HostName 1.2.3.4\n\nHost garde\n User x\n" + apres = self.retirer(avant, ["a", "b"]) + self.assertNotIn("1.2.3.4", apres) + self.assertIn("Host garde", apres) + + def test_a_match_section_is_never_swallowed(self): + avant = ( + "Host part\n" + " HostName 10.0.0.9\n" + "\n" + "Match host *.interne\n" + " User admin\n" + ) + apres = self.retirer(avant, ["part"]) + self.assertIn("Match host *.interne", apres) + self.assertIn("User admin", apres) + self.assertNotIn("10.0.0.9", apres) + + def test_comments_before_the_next_block_are_not_swallowed(self): + avant = ( + "Host part\n" + " HostName 10.0.0.9\n" + "\n" + "# la machine du client, ne pas toucher\n" + "Host client\n" + " HostName 10.0.0.10\n" + ) + apres = self.retirer(avant, ["part"]) + self.assertIn("# la machine du client, ne pas toucher", apres) + self.assertIn("Host client", apres) + + def test_global_directives_above_the_first_host_stay(self): + avant = "ServerAliveInterval 60\n\nHost part\n HostName 10.0.0.9\n" + apres = self.retirer(avant, ["part"]) + self.assertIn("ServerAliveInterval 60", apres) + self.assertNotIn("10.0.0.9", apres) + + def test_the_keyword_is_read_case_insensitively(self): + # ssh lit ses mots-clés sans égard à la casse ; nous aussi, sinon un + # « host » minuscule échappe au retrait et le nom vit deux fois. + avant = "host part\n HostName 10.0.0.9\n" + self.assertNotIn("10.0.0.9", self.retirer(avant, ["part"])) + + def test_hostname_is_not_mistaken_for_a_host_line(self): + avant = "Host garde\n HostName part\n" + apres = self.retirer(avant, ["part"]) + self.assertIn("Host garde", apres) + self.assertIn("HostName part", apres) + + class TestLAncienNomSEnVa(unittest.TestCase): """La convention a changé : les entrées écrites AVANT portent le nom court, et rien ne les retirerait — elles ne portent pas le nom qu'on diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 5be410b..0e7e043 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -18,6 +18,7 @@ import shutil import subprocess import sys import tempfile +import time import unittest sys.argv = ["todo.py"] @@ -390,6 +391,241 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): self.assertFalse(os.path.exists(chemin)) +class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): + """Le correctif du rapport partiel a CRÉÉ ce danger. + + Avant, la descente en cours n'avait aucun rapport sur le disque et + « --detruire » retombait sur la précédente, terminée. Depuis qu'il s'écrit + VM par VM, le rapport de la descente VIVANTE est le plus récent : détruire + aurait emporté l'arbre sous le processus qui installait encore.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.maison = tempfile.mkdtemp(prefix="longtest-maison-") + self.dossier = os.path.join(self.maison, ".erplibre/longtest") + os.makedirs(self.dossier) + self._vrai = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.addCleanup(shutil.rmtree, self.maison, ignore_errors=True) + # Un bouchon posé par un test et non repris fausse les SUIVANTS : la + # première version de ce fichier remplaçait dernier_rapport et le + # laissait en place, et le test d'après lisait le bouchon. + self._vrais = { + nom: getattr(deep_proxmox, nom) + for nom in ("autre_deep_proxmox", "dernier_rapport") + } + + def tearDown(self): + if self._vrai is not None: + os.environ["HOME"] = self._vrai + for nom, vrai in self._vrais.items(): + setattr(self.dp, nom, vrai) + + def _ecrire(self, nom, rapport): + with open( + os.path.join(self.dossier, nom), "w", encoding="utf-8" + ) as fh: + json.dump(rapport, fh) + + def test_a_living_descent_is_recognised_by_its_pid(self): + # Ce processus-ci exécute bien un test, pas deep_proxmox.py : c'est + # justement ce que le contrôle doit savoir distinguer. + self.assertFalse(self.dp.descente_vivante(os.getpid())) + self.assertFalse(self.dp.descente_vivante(None)) + self.assertFalse(self.dp.descente_vivante(999999999)) + + def test_a_shell_that_merely_names_the_script_is_not_a_descent(self): + """Constaté sur la machine : un « pgrep -f deep_proxmox.py » posé dans + une boucle de surveillance donnait un shell dont la ligne de commande + contient le motif, et deux faux positifs sur trois.""" + import subprocess + + faux = subprocess.Popen( + [ + "sh", + "-c", + "echo deep_proxmox.py --depth 10 >/dev/null; sleep 30", + ] + ) + self.addCleanup(faux.kill) + self.assertFalse(self.dp._lance_ce_script(faux.pid)) + self.assertNotIn(faux.pid, self.dp.autre_deep_proxmox()) + + def _fausse_descente(self): + """Un processus qui exécute VRAIMENT un « deep_proxmox.py ». + + Un PID inventé ne prouverait rien : le contrôle lit /proc, et la seule + façon honnête de l'éprouver est de lui donner un processus à voir.""" + faux = os.path.join(self.maison, "deep_proxmox.py") + with open(faux, "w", encoding="utf-8") as fh: + fh.write("import time\ntime.sleep(60)\n") + proc = subprocess.Popen([sys.executable, faux]) + self.addCleanup(proc.kill) + for _ in range(60): + if self.dp._lance_ce_script(proc.pid): + return proc.pid + time.sleep(0.05) + self.skipTest("le processus témoin n'a pas démarré") + + def test_a_living_descent_is_seen_in_proc(self): + pid = self._fausse_descente() + self.assertTrue(self.dp.descente_vivante(pid)) + self.assertIn(pid, self.dp.autre_deep_proxmox()) + + def test_the_report_of_a_living_descent_is_skipped(self): + """Sans ce filtre, « --detruire » choisissait le rapport de la + descente EN COURS — le plus récent — et détruisait l'arbre sous le + processus qui installait encore.""" + pid = self._fausse_descente() + self._ecrire( + "deep-pve-20260101-000000.json", + { + "dry_run": False, + "etages": [{"niveau": 2, "vmid": 102, "parent_alias": "a"}], + }, + ) + self._ecrire( + "deep-pve-20260102-000000.json", + { + "dry_run": False, + "pid": pid, + "etages": [{"niveau": 5, "vmid": 105, "parent_alias": "vif"}], + }, + ) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + rapport = self.dp.dernier_rapport() + # Celui de la descente vivante est écarté, et on le DIT. + self.assertIn("descente EN COURS", sortie.getvalue()) + self.assertEqual(rapport["etages"][0]["vmid"], 102) + + def test_an_empty_later_report_never_masks_one_that_names_vms(self): + """Un second lancement qui meurt à l'étage 1 — « le disque existe + déjà » — écrivait un rapport VIDE sous un horodatage plus tardif. + « --detruire » annonçait « 0 VM imbriquée(s) » puis effaçait le disque + de l'étage 1, où vivaient les étages 2 et suivants : jamais arrêtés, + jamais nommés.""" + self._ecrire( + "deep-pve-20260101-000000.json", + { + "dry_run": False, + "etages": [ + {"niveau": 3, "vmid": 103, "parent_alias": "a+b"}, + {"niveau": 2, "vmid": 102, "parent_alias": "a"}, + ], + }, + ) + self._ecrire( + "deep-pve-20260102-000000.json", {"dry_run": False, "etages": []} + ) + with contextlib.redirect_stdout(io.StringIO()): + rapport = self.dp.dernier_rapport() + self.assertEqual(len(self.dp.a_defaire(rapport)), 2) + self.assertTrue(rapport["fichier"].endswith("20260101-000000.json")) + + def test_a_dry_run_report_still_never_wins(self): + self._ecrire( + "deep-pve-20260101-000000.json", + { + "dry_run": False, + "etages": [{"niveau": 2, "vmid": 102, "parent_alias": "a"}], + }, + ) + self._ecrire( + "deep-pve-20260103-000000.json", + { + "dry_run": True, + "etages": [{"niveau": 9, "vmid": 900, "parent_alias": "z"}], + }, + ) + with contextlib.redirect_stdout(io.StringIO()): + rapport = self.dp.dernier_rapport() + self.assertEqual(rapport["etages"][0]["vmid"], 102) + + def test_destroying_refuses_while_a_descent_runs(self): + appels = [] + self.dp.autre_deep_proxmox = lambda: [4242] + self.dp.dernier_rapport = lambda: appels.append("lu") or {} + with contextlib.redirect_stdout(io.StringIO()) as sortie: + code = self.dp.detruire(None, dry_run=False) + self.assertEqual(code, 1) + # Le rapport n'est même pas LU : on ne demande rien, on ne propose + # rien, et surtout on n'attend pas un « OUI » sur un arbre vivant. + self.assertEqual(appels, []) + self.assertIn("descente tourne", sortie.getvalue()) + + +class TestLeDecompteDeLaDestruction(unittest.TestCase): + """« if not detruire_etage1(…) : faits -= 1 » — un succès de l'étage 1 + n'ajoutait RIEN, alors que le total est len(liste) + 1. + + Le décompte était décalé de un dans TOUS les cas : une destruction + complète annonçait « il reste des machines » et sortait 1. Le seul + avertissement censé prévenir qu'un disque de plusieurs dizaines de Go + reste alloué s'affichait toujours — on apprend à ne plus le lire.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self._vrais = { + nom: getattr(deep_proxmox, nom) + for nom in ( + "autre_deep_proxmox", + "dernier_rapport", + "detruire_une", + "detruire_etage1", + ) + } + deep_proxmox.autre_deep_proxmox = lambda: [] + deep_proxmox.dernier_rapport = lambda: { + "fichier": "/x.json", + "etages": [ + {"niveau": 3, "vmid": 103, "parent_alias": "a+b"}, + {"niveau": 2, "vmid": 102, "parent_alias": "a"}, + ], + } + self._entree = ( + __builtins__["input"] + if isinstance(__builtins__, dict) + else __builtins__.input + ) + + def tearDown(self): + for nom, vrai in self._vrais.items(): + setattr(self.dp, nom, vrai) + import builtins + + builtins.input = self._entree + + def _lancer(self, etage1_ok): + import builtins + + builtins.input = lambda _prompt="": "OUI" + self.dp.detruire_une = lambda *a, **k: True + self.dp.detruire_etage1 = lambda *a, **k: etage1_ok + with contextlib.redirect_stdout(io.StringIO()) as sortie: + code = self.dp.detruire(None, dry_run=False) + return code, sortie.getvalue() + + def test_a_complete_destruction_reports_success(self): + code, texte = self._lancer(etage1_ok=True) + self.assertEqual(code, 0) + self.assertIn("3 / 3", texte) + self.assertNotIn("il reste des machines", texte) + + def test_a_surviving_level_one_is_still_warned_about(self): + # L'avertissement doit rester CRÉDIBLE : il ne sort que quand il a + # quelque chose à dire. + code, texte = self._lancer(etage1_ok=False) + self.assertEqual(code, 1) + self.assertIn("2 / 3", texte) + self.assertIn("il reste des machines", texte) + + class TestLeMenu(unittest.TestCase): def test_the_mixin_is_wired_into_TODO(self): todo = TODO.__new__(TODO) From 4d26155901c2b6f0da4685cc078657bc2b00437e Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 07:32:38 -0400 Subject: [PATCH 14/26] =?UTF-8?q?[FIX]=20imbrication=20:=20la=20ressource?= =?UTF-8?q?=20qui=20borne,=20l'attente,=20le=20d=C3=A9compte?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Incident sur une descente réelle : un agent de relecture, chargé de vérifier ce que redemarrer_et_verifier PROUVE, l'a appelé sur l'étage 1 vivant. Le reboot a éteint les étages 2, 3 et 4 d'un coup. La descente a alors attendu son délai entier — quarante minutes — un ssh qui ne pouvait plus aboutir, puis a conclu « jamais joignable ». L'attente surveille désormais la MAISON. Le décompte de la destruction mentait dans l'autre sens : les étages injoignables étaient annoncés « il reste des machines » alors que le disque de l'étage 1, effacé, les contenait. Les entrées ~/.ssh/config sont retirées aussi, sinon leur ProxyJump désigne un hôte disparu. Et « arret » nommait la RAM quand le vCPU bornait : la chaîne était figée en ram > disque > vcpu et évaluée à la profondeur demandée. Il nomme maintenant le plus bas des trois plafonds, et les trois sont affichés. --- EN --- Incident on a real descent: a review agent, tasked with checking what redemarrer_et_verifier PROVES, called it on the living level 1. The reboot took levels 2, 3 and 4 down at once. The descent then waited its whole timeout — forty minutes — for an ssh that could no longer land, and concluded "never reachable". The wait now watches the HOUSE. The destroy count lied the other way: unreachable levels were reported as "il reste des machines" when level 1's erased disk contained them. The ~/.ssh/config entries are removed too, else their ProxyJump names a host that is gone. And "arret" named RAM when vCPU was the bound: the chain was frozen as ram > disque > vcpu and evaluated at the requested depth. It now names the lowest of the three ceilings, and all three are shown. Assisted-by: claude-opus-5 (cherry picked from commit 2d84b62bdd9907e9a30383774015bcb1ae379de1) --- LongTest/deep_proxmox.py | 103 ++++++++++++++++++++--- script/proxmox/nesting.py | 80 +++++++++++------- test/test_proxmox_nesting.py | 44 +++++++++- test/test_todo_longtest.py | 155 +++++++++++++++++++++++++++++++++-- 4 files changed, 333 insertions(+), 49 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 76fbcdb..17ed41a 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -180,12 +180,20 @@ class Descente: self.dire(f" {ligne}") return code, sortie - def attendre_ssh(self, hote, delai): + def attendre_ssh(self, hote, delai, parent=None): """Attend que la machine réponde. Rend les secondes, ou None. Des connexions COURTES successives : cloud-init régénère les clés d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une session longue. + + `parent` : si l'hôte qui HÉBERGE la machine attendue cesse de + répondre, on abandonne tout de suite. Constaté : l'étage 1 a redémarré + pendant l'installation de l'étage 4, ce qui a éteint les étages 2, 3 et + 4 d'un coup ; la descente a attendu son délai entier — quarante + minutes — un ssh qui ne pouvait plus aboutir, puis a rendu « jamais + joignable en ssh ». Le diagnostic était faux : la machine n'était pas + lente, sa MAISON n'existait plus. """ if self.dry_run: return 0 @@ -196,10 +204,19 @@ class Descente: # joignable en ssh ». Le transport marchait ; c'est le diagnostic qui # était faux. sonde = dict(hote, sudo="") + sonde_parent = dict(parent, sudo="") if parent else None while time.time() - debut < delai: code, _o = pve.run(sonde, "true", 60) if code == 0: return int(time.time() - debut) + if sonde_parent is not None: + code_parent, _p = pve.run(sonde_parent, "true", 60) + if code_parent != 0: + self.dire( + f" ✗ l'hôte {sonde_parent['target']} ne répond" + " plus : l'attente n'aboutira pas" + ) + return None time.sleep(15) return None @@ -549,7 +566,7 @@ class Descente: etage["alias"] = alias etage["etape"] = "ssh" - attente = self.attendre_ssh(cible, self.delai("ssh")) + attente = self.attendre_ssh(cible, self.delai("ssh"), parent) if attente is None: self.dire(" ✗ jamais joignable en ssh") self.etages.append(etage) @@ -653,6 +670,19 @@ class Descente: f" profondeur atteinte : {atteint}" f" / {self.plan['demandee']}" ) + # Deux causes très différentes rendaient le même « 5 / 10 » : la + # machine trop petite pour dix, ou un étage tombé en route. La + # première n'est pas un défaut du code, la seconde si. + if atteint == self.plan["atteignable"] < self.plan["demandee"]: + self.dire( + f" (plan borné à {self.plan['atteignable']} par le" + f" {self.plan['arret']} : tout le plan a tenu)" + ) + elif atteint < self.plan["atteignable"]: + self.dire( + f" (le plan annonçait {self.plan['atteignable']} :" + " un étage est tombé, voir plus haut)" + ) for e in self.etages: if self.dry_run: marque, detail = "·", "plan" @@ -947,18 +977,64 @@ def detruire(journal=None, dry_run=False): # machines » et sortait 1, si bien que le seul avertissement censé # prévenir qu'un disque de plusieurs dizaines de Go reste alloué # s'affichait toujours, et qu'on apprenait à ne plus le lire. - if detruire_etage1(journal): + racine = detruire_etage1(journal) + if racine: faits += 1 + retirer_alias(rapport, journal) + if racine: + # L'étage 1 est un DISQUE, et tout le reste vit dedans. « virsh + # undefine --remove-all-storage » l'a effacé : les étages injoignables + # — leur parent était éteint — ont disparu avec, qu'on ait pu leur + # parler ou non. Annoncer « il reste des machines » dans ce cas était + # faux dans l'autre sens, et un avertissement faux ne se lit plus. + reste = len(liste) + 1 - faits + dire( + f"\n {len(liste) + 1} / {len(liste) + 1} défait(s)." + + ( + f" ({reste} injoignable(s), emporté(s) avec le disque de" + " l'étage 1.)" + if reste + else "" + ), + journal, + ) + return 0 dire( f"\n {faits} / {len(liste) + 1} défait(s)." - + ( - "" - if faits == len(liste) + 1 - else " ⚠ il reste des machines : voir plus haut." - ), + " ⚠ l'étage 1 est DEBOUT : ce qu'il contient vit encore.", journal, ) - return 0 if faits == len(liste) + 1 else 1 + return 1 + + +def retirer_alias(rapport, journal=None): + """Retire de ~/.ssh/config les entrées de la descente défaite. + + Sans cela, elles survivaient aux machines : des entrées mortes dont le + ProxyJump désigne un hôte qui n'existe plus, et qu'on retrouve plus tard + sans savoir à quoi elles servaient. + """ + alias = [] + for etage in rapport.get("etages") or []: + nom = etage.get("alias") + if not nom: + # L'étage abandonné avant l'écriture de son alias : le calculer, + # il est déterminé par (niveau, alias du parent). + parent = etage.get("parent_alias") + if parent: + nom = alias_etage(int(etage["niveau"]), parent) + if nom and nom not in alias: + alias.append(nom) + if not alias: + return + try: + from script.todo.todo import TODO + + TODO.__new__(TODO)._write_ssh_config_entry( + [], "erplibre", "", also_drop=tuple(alias) + ) + except Exception as err: # noqa: BLE001 - jamais bloquer la destruction + dire(f" ⚠ entrées ~/.ssh/config non retirées : {err}", journal) def principal(argv=None): @@ -992,9 +1068,16 @@ def principal(argv=None): f" {n['disque']:>5} Go" ) if plan["arret"]: + # Les TROIS plafonds, pas seulement celui qui borne : sans eux on + # ajoute la ressource nommée sans savoir de combien, ni laquelle + # bornera ensuite. + plafonds = " · ".join( + f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() + ) print( f"\n ⚠ demandée {plan['demandee']}, atteignable" - f" {plan['atteignable']} — manque de {plan['arret']}" + f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" + f"\n profondeur permise par chaque ressource : {plafonds}" ) if not plan["niveaux"]: if args.depth < 1: diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index dd8595e..fa3714f 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -90,9 +90,19 @@ DISQUE_MIN_GO = 15 # a deux, son parent trois, et ainsi de suite. Le premier étage d'une descente # à dix en demande onze — sur vingt-huit cœurs réels, cela passe. VCPU_IMBRIQUE = 2 -# Ce qu'on accepte de prendre à la machine physique : la moitié de ses cœurs. -# L'orchestrateur tourne dessus, et la suite de tests aussi. -VCPU_HOTE_PART = 2 +# Ce qu'on LAISSE à la machine physique. L'orchestrateur tourne dessus, son +# ssh vers chaque étage aussi, et la suite de tests avec. +# +# Un nombre fixe, et non une fraction : « la moitié des cœurs » gardait +# quatorze cœurs inutilisés sur vingt-huit, et sur une machine à deux cœurs le +# plancher qui l'accompagnait rendait un budget de deux — soit la machine +# entière, hôte compris. +# +# Deux, et pas plus : l'orchestrateur passe son temps à ATTENDRE du ssh, il ne +# calcule rien. Quatre auraient interdit toute descente sur un hôte à quatre +# cœurs, où un étage tient très bien. Sur une machine trop petite le plan rend +# franchement zéro étage plutôt que de surengager l'hôte. +HOTE_RESERVE_VCPU = 2 # Au-delà, l'imbrication n'est pas un terrain documenté par les fabricants. # On ne refuse pas — on le DIT. @@ -107,9 +117,18 @@ def nesting_plan( ) -> dict: """Les ressources de chaque étage, dimensionnées DEPUIS LE BAS. - Rend {"demandee", "atteignable", "niveaux": [...], "arret"}. `arret` nomme - ce qui a manqué — « ram » ou « disque » — quand la profondeur demandée - n'est pas atteinte, sinon "". + Rend {"demandee", "atteignable", "niveaux", "arret", "plafonds"}. + + `arret` nomme la ressource qui BORNE réellement la profondeur — "ram", + "disque" ou "vcpu" — et "" si la profondeur demandée tient. `plafonds` + donne les trois profondeurs, une par ressource, pour qu'on puisse voir + d'un coup ce qu'il faudrait ajouter et de combien. + + Nommer la bonne, c'est le sujet : la version d'avant prenait la première + d'une chaîne figée ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. + Sur une machine à deux cœurs et 20 Go, elle annonçait « manque de ram » + quand le processeur bornait à un seul étage ; l'opérateur doublait la + mémoire et n'y gagnait pas un étage. Depuis le bas, et c'est tout le sujet. De haut en bas, chaque étage recevait ce que son parent pouvait céder : mesuré, l'étage 4 se retrouvait @@ -144,26 +163,25 @@ def nesting_plan( VCPU_IMBRIQUE + d - 1, ) - budget_vcpu = max(VCPU_IMBRIQUE, int(cpu_hote) // VCPU_HOTE_PART) - atteignable, arret = 0, "" - for d in range(max(0, int(profondeur)), 0, -1): - ram1, disque1, vcpu1 = besoin(d) - if ( - ram1 <= budget_ram - and disque1 <= budget_disque - and vcpu1 <= budget_vcpu - ): - atteignable = d - break - if atteignable < int(profondeur): - # Nommer CE qui a manqué, à la profondeur demandée. - ram1, disque1, vcpu1 = besoin(max(1, int(profondeur))) - if ram1 > budget_ram: - arret = "ram" - elif disque1 > budget_disque: - arret = "disque" - else: - arret = "vcpu" + budget_vcpu = int(cpu_hote) - HOTE_RESERVE_VCPU + # La profondeur que chaque budget permet À LUI SEUL. C'est de l'inverse de + # `besoin` : un balayage décroissant donnait le même résultat, mais son + # coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un + # million de tours pour rendre le même plan. + plafonds = { + "ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1, + "disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1, + "vcpu": budget_vcpu - VCPU_IMBRIQUE + 1, + } + plafonds = {nom: max(0, valeur) for nom, valeur in plafonds.items()} + demandee = max(0, int(profondeur)) + atteignable = min(demandee, *plafonds.values()) + arret = "" + if atteignable < demandee: + # La ressource qui BORNE, c'est-à-dire celle dont le plafond est le + # plus bas — pas la première d'un ordre figé. En ajouter une autre ne + # ferait pas monter la profondeur d'un seul étage. + arret = min(plafonds, key=lambda nom: (plafonds[nom], nom)) niveaux = [ { "niveau": niveau, @@ -171,8 +189,13 @@ def nesting_plan( # enfant, c'est cent pour cent de surengagement — et l'hyperviseur # à servir en plus. "vcpu": VCPU_IMBRIQUE + (atteignable - niveau), - "ram": PVE_RAM_CIBLE_MO + (atteignable - niveau) * PVE_RAM_MO, - "disque": PVE_DISQUE_CIBLE_GO + # Les planchers ne sont pas décoratifs : ils tiennent même si + # quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus + # lus par personne et les tests qui les vérifiaient passaient + # d'eux-mêmes. + "ram": max(RAM_MIN_MO, PVE_RAM_CIBLE_MO) + + (atteignable - niveau) * PVE_RAM_MO, + "disque": max(DISQUE_MIN_GO, PVE_DISQUE_CIBLE_GO) + (atteignable - niveau) * PVE_DISQUE_GO, } for niveau in range(1, atteignable + 1) @@ -182,6 +205,7 @@ def nesting_plan( "atteignable": atteignable, "niveaux": niveaux, "arret": arret, + "plafonds": plafonds, } diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 72f9271..8a20fd0 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -86,11 +86,51 @@ class TestLePlanDesEtages(unittest.TestCase): ) self.assertEqual(plan["arret"], "vcpu") self.assertLess(plan["atteignable"], 10) - # Et le premier étage ne dépasse pas la part concédée à l'hôte. + # Et le premier étage laisse à l'hôte ce qui lui est réservé. self.assertLessEqual( - plan["niveaux"][0]["vcpu"], 8 // nesting.VCPU_HOTE_PART + plan["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU ) + def test_the_named_resource_is_the_one_that_really_binds(self): + """La version d'avant prenait la première d'une chaîne figée + ram > disque > vcpu, évaluée à la profondeur DEMANDÉE. Sur deux cœurs + et 20 Go elle annonçait « manque de ram » quand le processeur bornait à + zéro étage : l'opérateur doublait la mémoire et n'y gagnait rien.""" + for cpu, ram, disque, attendu in ( + (2, 20000, 5000, "vcpu"), + (2, 200000, 100, "vcpu"), + (4, 16384, 200, "vcpu"), + (28, 12288, 500, "ram"), + (28, 200000, 60, "disque"), + ): + with self.subTest(cpu=cpu, ram=ram, disque=disque): + plan = nesting.nesting_plan(10, cpu, ram, disque) + self.assertEqual(plan["arret"], attendu) + # Et c'est bien le plus BAS des trois plafonds. + self.assertEqual( + plan["plafonds"][attendu], min(plan["plafonds"].values()) + ) + self.assertEqual( + plan["atteignable"], min(10, *plan["plafonds"].values()) + ) + + def test_doubling_the_named_resource_gains_a_level(self): + """L'épreuve utile du diagnostic : ce qu'il nomme, ajouté, PAIE.""" + base = dict(cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500) + avant = nesting.nesting_plan(10, **base) + self.assertEqual(avant["arret"], "ram") + apres = nesting.nesting_plan( + 10, **{**base, "ram_dispo_mo": base["ram_dispo_mo"] * 2} + ) + self.assertGreater(apres["atteignable"], avant["atteignable"]) + + def test_a_huge_depth_costs_nothing(self): + # Le balayage décroissant tournait autant de tours que la profondeur + # demandée pour rendre exactement le même plan. + plan = nesting.nesting_plan(10**6, 28, 58000, 165) + self.assertEqual(plan["atteignable"], min(plan["plafonds"].values())) + self.assertEqual(len(plan["niveaux"]), plan["atteignable"]) + def test_running_out_of_ram_is_named(self): plan = nesting.nesting_plan( 10, cpu_hote=28, ram_dispo_mo=12288, disque_libre_go=500 diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 0e7e043..d2d79b6 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -331,7 +331,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): f"10.10.10.{niveau}", ) d.ecrire_alias = lambda *a, **k: None - d.attendre_ssh = lambda cible, delai: 1 + d.attendre_ssh = lambda cible, delai, parent=None: 1 d.redemarrer_et_verifier = lambda cible: True d.reparer_pmxcfs = lambda cible: True @@ -557,6 +557,89 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): self.assertIn("descente tourne", sortie.getvalue()) +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. + + La descente a attendu son délai entier — quarante minutes — un ssh qui ne + pouvait plus aboutir, puis a conclu « jamais joignable en ssh ». Le + diagnostic était faux : la machine n'était pas lente, sa MAISON n'existait + plus.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.vrai_run = deep_proxmox.pve.run + self.addCleanup(setattr, deep_proxmox.pve, "run", self.vrai_run) + self.d = deep_proxmox.Descente.__new__(deep_proxmox.Descente) + self.d.dry_run = False + self.d.journal = None + + def _cibles(self): + return ( + {"target": "enfant", "sudo": "sudo ", "jump": ""}, + {"target": "parent", "sudo": "sudo ", "jump": ""}, + ) + + def test_it_gives_up_as_soon_as_the_parent_stops_answering(self): + appels = [] + + def faux(hote, cmd, timeout=None): + appels.append(hote["target"]) + # Une borne DURE : si le garde-fou disparaissait, la boucle + # sonderait l'enfant jusqu'à l'expiration du délai. On la fait + # éclater au troisième tour plutôt que de laisser le test tourner + # — et ce test-ci doit échouer vite quand le code régresse. + if appels.count("enfant") > 2: + raise AssertionError(f"sondé sans fin : {appels[:6]}") + return 255, "" + + self.dp.pve.run = faux + enfant, parent = self._cibles() + vrai_sleep = time.sleep + time.sleep = lambda _s: None + self.addCleanup(setattr, time, "sleep", vrai_sleep) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = self.d.attendre_ssh(enfant, 45, parent) + self.assertIsNone(res) + # DEUX sondes, et c'est tout : l'enfant, puis sa maison. Sans le + # garde-fou la liste comptait autant d'« enfant » que le délai le + # permet, et la descente attendait pour rien. + self.assertEqual(appels, ["enfant", "parent"]) + self.assertIn("ne répond plus", sortie.getvalue()) + + def test_a_slow_child_with_a_living_parent_is_still_waited_for(self): + """Le contrôle NÉGATIF : un étage lent n'est pas un étage mort. Sans + lui, ce garde-fou abandonnerait toute descente profonde.""" + etat = {"tours": 0} + + def faux(hote, cmd, timeout=None): + if hote["target"] == "parent": + return 0, "" # la maison tient + etat["tours"] += 1 + return (0, "") if etat["tours"] >= 3 else (255, "") + + self.dp.pve.run = faux + self.dp.time = time # même horloge + enfant, parent = self._cibles() + vrai_sleep = time.sleep + time.sleep = lambda _s: None + self.addCleanup(setattr, time, "sleep", vrai_sleep) + with contextlib.redirect_stdout(io.StringIO()): + res = self.d.attendre_ssh(enfant, 3600, parent) + self.assertIsNotNone(res) + self.assertEqual(etat["tours"], 3) + + def test_without_a_parent_the_behaviour_is_unchanged(self): + # L'étage 1 n'a pas de parent : il tourne sur du métal. + self.dp.pve.run = lambda hote, cmd, timeout=None: (0, "") + enfant, _ = self._cibles() + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual(self.d.attendre_ssh(enfant, 60), 0) + + class TestLeDecompteDeLaDestruction(unittest.TestCase): """« if not detruire_etage1(…) : faits -= 1 » — un succès de l'étage 1 n'ajoutait RIEN, alors que le total est len(liste) + 1. @@ -578,9 +661,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): "dernier_rapport", "detruire_une", "detruire_etage1", + "retirer_alias", ) } deep_proxmox.autre_deep_proxmox = lambda: [] + deep_proxmox.retirer_alias = lambda *a, **k: None deep_proxmox.dernier_rapport = lambda: { "fichier": "/x.json", "etages": [ @@ -601,11 +686,11 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): builtins.input = self._entree - def _lancer(self, etage1_ok): + def _lancer(self, etage1_ok, une=True): import builtins builtins.input = lambda _prompt="": "OUI" - self.dp.detruire_une = lambda *a, **k: True + self.dp.detruire_une = lambda *a, **k: une self.dp.detruire_etage1 = lambda *a, **k: etage1_ok with contextlib.redirect_stdout(io.StringIO()) as sortie: code = self.dp.detruire(None, dry_run=False) @@ -615,15 +700,67 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): code, texte = self._lancer(etage1_ok=True) self.assertEqual(code, 0) self.assertIn("3 / 3", texte) - self.assertNotIn("il reste des machines", texte) + self.assertNotIn("⚠", texte) - def test_a_surviving_level_one_is_still_warned_about(self): - # L'avertissement doit rester CRÉDIBLE : il ne sort que quand il a - # quelque chose à dire. + def test_unreachable_levels_go_with_the_root_disk(self): + """Mesuré sur un arbre réel : les étages 3 et 4 étaient injoignables + — leur parent était éteint — et le compte disait « 2 / 4, il reste des + machines ». Or « virsh undefine --remove-all-storage » sur l'étage 1 + efface le disque où ils VIVENT. L'avertissement était faux dans + l'autre sens, et un avertissement faux ne se lit plus.""" + self.dp.detruire_une = lambda *a, **k: False + code, texte = self._lancer(etage1_ok=True, une=False) + self.assertEqual(code, 0) + self.assertIn("3 / 3", texte) + self.assertIn("emporté(s) avec le disque de l'étage 1", texte) + + def test_a_surviving_level_one_is_the_only_real_warning(self): + # Là, et là seulement, quelque chose vit encore : le disque est + # debout, et tout ce qu'il contient avec lui. code, texte = self._lancer(etage1_ok=False) self.assertEqual(code, 1) - self.assertIn("2 / 3", texte) - self.assertIn("il reste des machines", texte) + self.assertIn("l'étage 1 est DEBOUT", texte) + + def test_the_ssh_aliases_of_a_destroyed_descent_are_removed(self): + """Elles survivaient aux machines : des entrées mortes dont le + ProxyJump désigne un hôte qui n'existe plus.""" + retires = [] + self.dp.retirer_alias = lambda rapport, journal=None: retires.append( + [e.get("alias") for e in rapport["etages"]] + ) + self._lancer(etage1_ok=True) + self.assertEqual(len(retires), 1) + + def test_the_aliases_are_computed_when_the_report_lacks_them(self): + """Un étage abandonné avant l'écriture de son alias en a tout de même + un : il est déterminé par (niveau, alias du parent).""" + vus = {} + import script.todo.todo as module_todo + + vrai = module_todo.TODO._write_ssh_config_entry + + def espion(self, host, user, ip, **kw): + vus["drop"] = kw.get("also_drop") + + module_todo.TODO._write_ssh_config_entry = espion + self.addCleanup( + setattr, module_todo.TODO, "_write_ssh_config_entry", vrai + ) + with contextlib.redirect_stdout(io.StringIO()): + # La VRAIE fonction : setUp en a posé un bouchon pour les autres + # tests de cette classe. + self._vrais["retirer_alias"]( + { + "etages": [ + {"niveau": 1, "alias": "deep-pve-1"}, + {"niveau": 2, "parent_alias": "deep-pve-1"}, + ] + } + ) + self.assertEqual( + vus["drop"], + ("deep-pve-1", self.dp.alias_etage(2, "deep-pve-1")), + ) class TestLeMenu(unittest.TestCase): From 178785b90a320f5c90bda87ff1980a7242940093 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 07:48:19 -0400 Subject: [PATCH 15/26] [FIX] LongTest : garder le VMID, le code des lectures, le journal PVE MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- LongTest/deep_proxmox.py | 73 +++++++++++-- test/test_todo_longtest.py | 214 ++++++++++++++++++++++++++++++++++++- 2 files changed, 276 insertions(+), 11 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index 17ed41a..aa3e864 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -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 diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index d2d79b6..b1a724e 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -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. From 006f4d271b4f46d9975430a54a95a4ffa2dac784 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 07:59:07 -0400 Subject: [PATCH 16/26] =?UTF-8?q?[FIX]=20LongTest=20:=20l'=C3=A9tage=201?= =?UTF-8?q?=20s'identifie=20par=20son=20UUID,=20pas=20par=20son=20nom?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dernières trouvailles de la relecture, et la famille la plus tenace de ce travail : une machine liée à ce qu'elle s'appelle plutôt qu'à ce qui l'identifie. « virsh undefine --remove-all-storage » partait sur le nom fixe deep-pve-1, quel que soit le domaine qui le porte — la VM d'une descente précédente qu'on voulait garder, ou une machine sans rapport. L'UUID est noté à la création et vérifié avant de détruire ; un rapport ancien n'en a pas, on procède alors par le nom faute de mieux, mais on le dit. Le nom des étages imbriqués était de même déduit du numéro d'étage à la RELECTURE. Un rapport écrit avant un changement de nom_etage aurait désigné des machines qui ne sont pas les siennes. Il est écrit à la création. Les deux meurent sous mutation. 52 tests dans ce fichier. --- EN --- Last findings from the review, and the most persistent family in this work: a machine bound to what it is called rather than to what identifies it. "virsh undefine --remove-all-storage" went by the fixed name deep-pve-1, whatever domain carries it — a previous descent's VM one meant to keep, or an unrelated machine. The UUID is recorded at creation and checked before destroying; an older report has none, so we fall back to the name, and say so. The nested levels' names were likewise derived from the level number at READ time. A report written before a change to nom_etage would have named machines that are not its own. It is now written at creation. Both die under mutation. 52 tests in this file. Assisted-by: claude-opus-5 (cherry picked from commit 93292653afb91351b0efa5700fcf66f6bb82604f) --- LongTest/deep_proxmox.py | 86 ++++++++++++++++++++++++++++--- test/test_todo_longtest.py | 100 +++++++++++++++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 6 deletions(-) diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index aa3e864..ec2869b 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -481,6 +481,25 @@ class Descente: ) return nom + @staticmethod + def uuid_libvirt(nom): + """L'UUID du domaine `nom`, ou "". C'est lui qui l'identifie. + + Un nom se réutilise ; un UUID non. Sans lui, « --detruire » effaçait + « deep-pve-1 » quel qu'il soit — la VM d'une descente précédente qu'on + voulait garder, ou une machine sans rapport qui porte ce nom. + """ + try: + res = subprocess.run( + ["sudo", "-n", "virsh", "domuuid", nom], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return "" + return "" if res.returncode else res.stdout.strip() + def creer_enfant(self, parent, niveau, res, prepare, noter=None): """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None). @@ -589,6 +608,10 @@ class Descente: self.interrompu = True break alias = nom + etage["nom"] = nom + # L'UUID, et non le nom : c'est de lui que « --detruire » se + # servira. Un nom se réutilise, un UUID non. + etage["uuid"] = self.uuid_libvirt(nom) # Le domaine libvirt existe : le rapport doit exister aussi. self._sauver(etage) else: @@ -599,9 +622,19 @@ class Descente: self.interrompu = True break - def noter(numero, etage=etage, parent_alias=parent_alias): + def noter( + numero, + etage=etage, + parent_alias=parent_alias, + niveau=niveau, + ): etage["vmid"] = numero etage["parent_alias"] = parent_alias + # Le nom est ÉCRIT, non déduit du numéro d'étage à la + # relecture : si nom_etage change un jour, un rapport + # ancien désignerait des machines qui ne sont pas les + # siennes. + etage["nom"] = nom_etage(niveau) self._sauver(etage) vmid, adresse = self.creer_enfant( @@ -880,7 +913,10 @@ def a_defaire(rapport): int(e["niveau"]), e["parent_alias"], int(e["vmid"]), - nom_etage(int(e["niveau"])), + # Le nom ÉCRIT par la descente. Le déduire du numéro d'étage + # supposait que nom_etage ne changera jamais — un rapport ancien + # aurait alors nommé des machines qui ne sont pas les siennes. + e.get("nom") or nom_etage(int(e["niveau"])), ) for e in etages ] @@ -932,7 +968,7 @@ def detruire_une(parent_alias, vmid, nom, journal): return True -def detruire_etage1(journal, dry_run=False): +def detruire_etage1(journal, dry_run=False, attendu=None, nom=None): """Le domaine libvirt du premier étage — le SEUL qui en soit un. La boucle d'avant tournait sur trente niveaux avec une condition morte, et @@ -940,8 +976,15 @@ def detruire_etage1(journal, dry_run=False): jamais rien créé : « virsh undefine --remove-all-storage » partait alors sur un domaine qui pouvait être n'importe quoi, sortie capturée, sans un mot. + + `attendu` : l'UUID que le rapport a noté à la création. C'est LUI qui + identifie la machine, pas son nom. Un nom se réutilise — la VM d'une + descente précédente qu'on voulait garder, ou une machine sans rapport qui + porte celui-là — et « --remove-all-storage » efface un disque pour de bon. + Un rapport ancien n'a pas d'UUID : on procède alors comme avant, par le + nom, faute de mieux, mais en le disant. """ - nom = nom_etage(1) + nom = nom or nom_etage(1) existe = subprocess.run( ["sudo", "-n", "virsh", "dominfo", nom], capture_output=True, @@ -950,6 +993,19 @@ def detruire_etage1(journal, dry_run=False): if existe.returncode: dire(f" — {nom} : aucun domaine libvirt", journal) return True + if attendu: + vu = Descente.uuid_libvirt(nom) + if vu != attendu: + dire( + f" ✗ {nom} : UUID {vu or '—'} au lieu de {attendu} —" + " ce n'est PAS notre machine, rien touché", + journal, + ) + return False + else: + dire( + f" ⚠ {nom} : rapport sans UUID, identifié par son NOM", journal + ) if dry_run: dire( f" [à blanc] virsh undefine {nom} --remove-all-storage", journal @@ -1015,7 +1071,15 @@ def detruire(journal=None, dry_run=False): f" étage {niveau:2d} {nom} ({vmid}) sur {parent_alias}", journal, ) - dire(f" étage 1 {nom_etage(1)} (libvirt)", journal) + etage1_nom = next( + ( + e.get("nom") + for e in (rapport.get("etages") or []) + if int(e.get("niveau", 0)) == 1 + ), + None, + ) + dire(f" étage 1 {etage1_nom or nom_etage(1)} (libvirt)", journal) if dry_run: dire("\n --dry-run : rien ne sera détruit.", journal) return 0 @@ -1036,7 +1100,17 @@ def detruire(journal=None, dry_run=False): # machines » et sortait 1, si bien que le seul avertissement censé # prévenir qu'un disque de plusieurs dizaines de Go reste alloué # s'affichait toujours, et qu'on apprenait à ne plus le lire. - racine = detruire_etage1(journal) + etage1 = next( + ( + e + for e in (rapport.get("etages") or []) + if int(e.get("niveau", 0)) == 1 + ), + {}, + ) + racine = detruire_etage1( + journal, attendu=etage1.get("uuid"), nom=etage1.get("nom") + ) if racine: faits += 1 retirer_alias(rapport, journal) diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index b1a724e..5a05e7d 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -561,6 +561,106 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): self.assertIn("descente tourne", sortie.getvalue()) +class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): + """« virsh undefine --remove-all-storage » efface un disque pour de bon. + + Il partait sur le NOM fixe deep-pve-1, quel que soit le domaine qui le + porte : la VM d'une descente précédente qu'on voulait garder, ou une + machine sans rapport. C'est la famille de défauts la plus tenace de ce + travail — une ressource liée à une machine par son nom au lieu de ce qui + l'identifie vraiment.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + self.dp = deep_proxmox + self.vrai_run = deep_proxmox.subprocess.run + # LE staticmethod, pas la fonction qu'il enveloppe : le rendre nu en + # ferait une méthode d'instance, et « self.uuid_libvirt(nom) » + # passerait deux arguments à une fonction qui en prend un. La fuite + # tombait sur les tests SUIVANTS. + self.vrai_uuid = deep_proxmox.Descente.__dict__["uuid_libvirt"] + self.addCleanup(setattr, deep_proxmox.subprocess, "run", self.vrai_run) + self.addCleanup( + setattr, deep_proxmox.Descente, "uuid_libvirt", self.vrai_uuid + ) + self.lances = [] + + def _virsh(self, dominfo=0): + import types + + def faux(argv, **kw): + self.lances.append(" ".join(argv[2:])) + code = dominfo if "dominfo" in argv else 0 + return types.SimpleNamespace(returncode=code, stdout="", stderr="") + + self.dp.subprocess.run = faux + + def test_a_homonym_with_another_uuid_is_left_alone(self): + self._virsh() + self.dp.Descente.uuid_libvirt = staticmethod(lambda nom: "AUTRE-UUID") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = self.dp.detruire_etage1(None, attendu="LE-NOTRE") + self.assertFalse(res) + self.assertIn("PAS notre machine", sortie.getvalue()) + # Aucun undefine, aucun destroy : seule la lecture a eu lieu. + self.assertTrue(all("dominfo" in c for c in self.lances), self.lances) + + def test_our_own_machine_is_destroyed(self): + self._virsh() + self.dp.Descente.uuid_libvirt = staticmethod(lambda nom: "LE-NOTRE") + with contextlib.redirect_stdout(io.StringIO()): + res = self.dp.detruire_etage1(None, attendu="LE-NOTRE") + self.assertTrue(res) + self.assertTrue( + any( + "undefine" in c and "remove-all-storage" in c + for c in self.lances + ), + self.lances, + ) + + def test_an_old_report_without_a_uuid_says_so(self): + """On procède alors par le nom, faute de mieux — mais on le DIT, + plutôt que de laisser croire qu'on a vérifié.""" + self._virsh() + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = self.dp.detruire_etage1(None) + self.assertTrue(res) + self.assertIn("identifié par son NOM", sortie.getvalue()) + + def test_an_absent_domain_is_not_an_error(self): + self._virsh(dominfo=1) + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(self.dp.detruire_etage1(None, attendu="X")) + + def test_the_name_comes_from_the_report_not_from_the_level(self): + """Le déduire du numéro d'étage supposait que nom_etage ne changera + jamais : un rapport ancien nommerait alors d'autres machines.""" + rapport = { + "etages": [ + { + "niveau": 2, + "vmid": 102, + "parent_alias": "a", + "nom": "nom-ecrit-a-la-creation", + } + ] + } + self.assertEqual( + self.dp.a_defaire(rapport), + [(2, "a", 102, "nom-ecrit-a-la-creation")], + ) + + def test_a_report_without_a_name_falls_back_on_the_level(self): + rapport = {"etages": [{"niveau": 3, "vmid": 103, "parent_alias": "b"}]} + self.assertEqual( + self.dp.a_defaire(rapport), + [(3, "b", 103, self.dp.nom_etage(3))], + ) + + 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 From b2b3f36026ea7ee5f0c96b7adc0ec340c1440879 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 10:09:03 -0400 Subject: [PATCH 17/26] =?UTF-8?q?[FIX]=20imbrication=20:=20aucun=20=C3=A9t?= =?UTF-8?q?age=20imbriqu=C3=A9=20n'est=20large,=20le=20gel=20le=20dit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ma propre conclusion de ce matin était fausse, et une mesure l'a réfutée. J'avais écrit — code, README, commit — que le gel à 12 vCPU venait du SURENGAGEMENT : cette VM avait douze vCPU sur un hôte qui en avait deux. Descente réelle : l'étage 4 à huit vCPU, sur un parent qui en avait NEUF, charge 1,47, aucun surengagement. Gelé pareil. 32 Mio lus en 106 minutes, même RIP à trois relevés espacés de cinq minutes. C'est le nombre de vCPU de l'invité imbriqué, et rien d'autre. Le dimensionnement de bas en haut donnait 8 vCPU à l'étage 4, 7 au 5 : il rendait larges précisément les étages qui doivent rester étroits. Trois largeurs fixes le remplacent — métal, intermédiaire, fond. La mémoire et le disque, eux, restent dimensionnés depuis le bas. VCPU_INTERMEDIAIRE = 3 est une hypothèse assumée : deux démarre au quatrième étage, huit gèle, rien n'est mesuré entre les deux. --- EN --- My own conclusion from this morning was wrong, and a measurement refuted it. I had written — code, README, commit — that the 12-vCPU freeze came from OVERCOMMIT: that VM had twelve vCPU on a host with two. Real descent: level 4 at eight vCPU, on a parent with NINE, load 1.47, no overcommit whatsoever. Frozen all the same. 32 MiB read in 106 minutes, same RIP at three readings five minutes apart. It is the nested guest's vCPU count, nothing else. Bottom-up sizing gave level 4 eight vCPU and level 5 seven: it made wide exactly the levels that must stay narrow. Three fixed widths replace it — metal, intermediate, floor. Memory and disk stay sized from the bottom. VCPU_INTERMEDIAIRE = 3 is an owned hypothesis: two boots at the fourth level, eight freezes, nothing is measured in between. Assisted-by: claude-opus-5 (cherry picked from commit ee45ff4f333c69e3862e277334cb34efa39bbd57) --- LongTest/README.base.md | 95 +++++++++++++++++++++++++----------- LongTest/README.fr.md | 49 +++++++++++++------ LongTest/README.md | 46 +++++++++++------ script/proxmox/nesting.py | 87 +++++++++++++++++++++++---------- test/test_proxmox_nesting.py | 85 +++++++++++++++++++++++++++----- 5 files changed, 265 insertions(+), 97 deletions(-) diff --git a/LongTest/README.base.md b/LongTest/README.base.md index d280936..2e9c8ef 100644 --- a/LongTest/README.base.md +++ b/LongTest/README.base.md @@ -57,12 +57,12 @@ level, with the hypervisor itself to serve on top. Its install ran past two and a half hours against thirteen minutes for level 3, and extrapolating that ratio gave five years for the tenth. -So the direction is reversed. The deepest level gets what a test Proxmox -actually asks for — 4 GB of memory, 25 GB of disk, 2 vCPU — and every parent -above it adds its own overhead and nothing else: one vCPU, 2 GiB, 10 GB. A -ten-level descent therefore asks its first level for 11 vCPU, 22 GB and -115 GB, where handing resources down wanted 50 GB of memory for the same -depth. +So the direction is reversed for **memory and disk**. The deepest level gets +what a test Proxmox actually asks for — 4 GB of memory, 25 GB of disk — and +every parent above it adds its own overhead and nothing else: 2 GiB and 10 GB. +A ten-level descent therefore asks its first level for 22 GB and 115 GB, where +handing resources down wanted 50 GB of memory for the same depth. The +processor follows a different rule; see below. Three budgets can bound the depth, and `script/proxmox/nesting.py` names the one that ran out: @@ -71,15 +71,33 @@ one that ran out: `pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child; * **disk** — the child's disk lives *inside* the parent's, which must also hold its own system; -* **processor** — each level wants one vCPU more than its child, so ten levels - ask eleven of the first. Half the physical cores is the ceiling: the - orchestrator runs on that machine too. +* **processor** — it does *not* grow with depth. Every nested level keeps a + fixed, narrow width; only the first level counts against the physical cores. + Either the machine can carry that first level or it can carry nothing. -That third budget is measured, not assumed. Twelve vCPU at the fourth level -froze the guest kernel in early boot — same instruction pointer at three -readings two minutes apart — while two progressed. The number was not the -culprit: that VM had twelve vCPU on a host with two, six times wider than its -own machine. Overcommit freezes, not the twelve. +That third rule is measured, and it cost two descents to get right. A nested +guest at the **fourth** level freezes in early boot as soon as it is wide: +twelve vCPU the first time, eight the second — same instruction pointer at +three readings five minutes apart, 32 MiB read and not one byte more for 106 +minutes. Two vCPU boots. + +The first freeze was blamed on **overcommit**: that VM had twelve vCPU on a +host with two. The second measurement refuted it — eight vCPU on a parent with +**nine**, load 1.47, no overcommit at all, and the same freeze. It is the +nested guest's vCPU count, not its ratio to its host's. + +At the third level, 9 vCPU boots in 117 s. The threshold sits between the +third and fourth level, so no nested level is ever made wide. An earlier +version of this algorithm gave each parent one vCPU more than its child, which +made level 4 eight wide — exactly the frozen case. The rule made wide what +must stay narrow. + +Hence three fixed widths: `VCPU_METAL` for level 1 (on bare metal, no freeze +risk — eleven vCPU booted there in 42 s), `VCPU_IMBRIQUE` for the deepest, and +`VCPU_INTERMEDIAIRE` in between, wide enough to host its child without being +as narrow as it. That middle number is a **hypothesis**: two is proven to boot +at the fourth level and eight is proven to freeze, with nothing measured in +between. The descent decides. Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply the @@ -147,12 +165,12 @@ surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dess Son installation dépassait deux heures et demie contre treize minutes pour l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième. -Le sens est donc inversé. Le plus profond reçoit ce qu'un Proxmox de test -demande vraiment — 4 Go de mémoire, 25 Go de disque, 2 vCPU — et chaque parent -au-dessus ajoute son propre surcoût, rien d'autre : un vCPU, 2 Gio, 10 Go. Une -descente à dix étages demande ainsi 11 vCPU, 22 Go et 115 Go à son premier -étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la même -profondeur. +Le sens est donc inversé pour la **mémoire et le disque**. Le plus profond +reçoit ce qu'un Proxmox de test demande vraiment — 4 Go de mémoire, 25 Go de +disque — et chaque parent au-dessus ajoute son propre surcoût, rien d'autre : +2 Gio et 10 Go. Une descente à dix étages demande ainsi 22 Go et 115 Go à son +premier étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la +même profondeur. Le processeur, lui, suit une autre règle — voir plus bas. Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py` nomme celui qui a manqué : @@ -162,16 +180,35 @@ nomme celui qui a manqué : enfant ; * **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit aussi contenir son propre système ; -* **le processeur** — chaque étage en veut un de plus que son enfant, donc dix - étages en demandent onze au premier. La moitié des cœurs physiques est le - plafond : l'orchestrateur tourne sur cette machine lui aussi. +* **le processeur** — il ne croît *pas* avec la profondeur. Tout étage + imbriqué garde une largeur fixe et étroite ; seul le premier compte sur les + cœurs physiques. Ou la machine peut porter ce premier étage, ou elle ne peut + rien. -Ce troisième budget est mesuré, pas supposé. Douze vCPU au quatrième étage ont -gelé le noyau invité en tout début de démarrage — même pointeur d'instruction à -trois relevés, deux minutes d'écart — quand deux avançaient. Le nombre n'était -pas le fautif : cette VM avait douze vCPU sur un hôte qui en avait deux, six -fois plus large que sa propre machine. C'est le surengagement qui gèle, pas le -douze. +Cette troisième règle est mesurée, et il a fallu deux descentes pour la poser +juste. Un invité imbriqué au **quatrième** étage gèle en tout début de +démarrage dès qu'il est large : douze vCPU la première fois, huit la seconde — +même pointeur d'instruction à trois relevés espacés de cinq minutes, 32 Mio lus +et plus un octet pendant 106 minutes. Deux vCPU démarrent. + +Le premier gel avait été imputé au **surengagement** : cette VM à douze vCPU +tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté — huit +vCPU sur un parent qui en avait **neuf**, charge 1,47, aucun surengagement, et +le même gel. C'est le nombre de vCPU de l'invité imbriqué, et non son rapport à +celui de son hôte. + +Au troisième étage, 9 vCPU démarrent en 117 s. Le seuil est entre le troisième +et le quatrième étage : aucun étage imbriqué n'est donc rendu large. Une version +précédente de cet algorithme donnait un vCPU de plus à chaque parent, ce qui +rendait l'étage 4 large de huit — exactement le cas gelé. La règle rendait large +ce qui doit rester étroit. + +D'où trois largeurs fixes : `VCPU_METAL` pour l'étage 1 (sur le métal, aucun +risque de gel — onze vCPU y ont démarré en 42 s), `VCPU_IMBRIQUE` pour le plus +profond, et `VCPU_INTERMEDIAIRE` entre les deux, juste assez large pour héberger +son enfant sans être aussi étroit que lui. Ce nombre du milieu est une +**hypothèse** : deux démarre au quatrième étage, huit gèle, et rien n'est mesuré +entre les deux. C'est la descente qui tranche. La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md index 9c1b051..67e05c2 100644 --- a/LongTest/README.fr.md +++ b/LongTest/README.fr.md @@ -56,12 +56,12 @@ surengagement, à chaque étage, avec l'hyperviseur lui-même à servir par-dess Son installation dépassait deux heures et demie contre treize minutes pour l'étage 3, et l'extrapolation de ce rapport donnait cinq ANS pour le dixième. -Le sens est donc inversé. Le plus profond reçoit ce qu'un Proxmox de test -demande vraiment — 4 Go de mémoire, 25 Go de disque, 2 vCPU — et chaque parent -au-dessus ajoute son propre surcoût, rien d'autre : un vCPU, 2 Gio, 10 Go. Une -descente à dix étages demande ainsi 11 vCPU, 22 Go et 115 Go à son premier -étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la même -profondeur. +Le sens est donc inversé pour la **mémoire et le disque**. Le plus profond +reçoit ce qu'un Proxmox de test demande vraiment — 4 Go de mémoire, 25 Go de +disque — et chaque parent au-dessus ajoute son propre surcoût, rien d'autre : +2 Gio et 10 Go. Une descente à dix étages demande ainsi 22 Go et 115 Go à son +premier étage, là où la cession de haut en bas voulait 50 Go de mémoire pour la +même profondeur. Le processeur, lui, suit une autre règle — voir plus bas. Trois budgets peuvent borner la profondeur, et `script/proxmox/nesting.py` nomme celui qui a manqué : @@ -71,16 +71,35 @@ nomme celui qui a manqué : enfant ; * **le disque** — le disque de l'enfant vit *dans* celui du parent, qui doit aussi contenir son propre système ; -* **le processeur** — chaque étage en veut un de plus que son enfant, donc dix - étages en demandent onze au premier. La moitié des cœurs physiques est le - plafond : l'orchestrateur tourne sur cette machine lui aussi. +* **le processeur** — il ne croît *pas* avec la profondeur. Tout étage + imbriqué garde une largeur fixe et étroite ; seul le premier compte sur les + cœurs physiques. Ou la machine peut porter ce premier étage, ou elle ne peut + rien. -Ce troisième budget est mesuré, pas supposé. Douze vCPU au quatrième étage ont -gelé le noyau invité en tout début de démarrage — même pointeur d'instruction à -trois relevés, deux minutes d'écart — quand deux avançaient. Le nombre n'était -pas le fautif : cette VM avait douze vCPU sur un hôte qui en avait deux, six -fois plus large que sa propre machine. C'est le surengagement qui gèle, pas le -douze. +Cette troisième règle est mesurée, et il a fallu deux descentes pour la poser +juste. Un invité imbriqué au **quatrième** étage gèle en tout début de +démarrage dès qu'il est large : douze vCPU la première fois, huit la seconde — +même pointeur d'instruction à trois relevés espacés de cinq minutes, 32 Mio lus +et plus un octet pendant 106 minutes. Deux vCPU démarrent. + +Le premier gel avait été imputé au **surengagement** : cette VM à douze vCPU +tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté — huit +vCPU sur un parent qui en avait **neuf**, charge 1,47, aucun surengagement, et +le même gel. C'est le nombre de vCPU de l'invité imbriqué, et non son rapport à +celui de son hôte. + +Au troisième étage, 9 vCPU démarrent en 117 s. Le seuil est entre le troisième +et le quatrième étage : aucun étage imbriqué n'est donc rendu large. Une version +précédente de cet algorithme donnait un vCPU de plus à chaque parent, ce qui +rendait l'étage 4 large de huit — exactement le cas gelé. La règle rendait large +ce qui doit rester étroit. + +D'où trois largeurs fixes : `VCPU_METAL` pour l'étage 1 (sur le métal, aucun +risque de gel — onze vCPU y ont démarré en 42 s), `VCPU_IMBRIQUE` pour le plus +profond, et `VCPU_INTERMEDIAIRE` entre les deux, juste assez large pour héberger +son enfant sans être aussi étroit que lui. Ce nombre du milieu est une +**hypothèse** : deux démarre au quatrième étage, huit gèle, et rien n'est mesuré +entre les deux. C'est la descente qui tranche. La mémoire n'est pas le levier. Sur cette même VM examinée à la main, la faire passer de 9 Go à 2 Go n'a rien déplacé : elle s'arrêtait après avoir lu les diff --git a/LongTest/README.md b/LongTest/README.md index ef5fb45..41cc307 100644 --- a/LongTest/README.md +++ b/LongTest/README.md @@ -52,12 +52,12 @@ level, with the hypervisor itself to serve on top. Its install ran past two and a half hours against thirteen minutes for level 3, and extrapolating that ratio gave five years for the tenth. -So the direction is reversed. The deepest level gets what a test Proxmox -actually asks for — 4 GB of memory, 25 GB of disk, 2 vCPU — and every parent -above it adds its own overhead and nothing else: one vCPU, 2 GiB, 10 GB. A -ten-level descent therefore asks its first level for 11 vCPU, 22 GB and -115 GB, where handing resources down wanted 50 GB of memory for the same -depth. +So the direction is reversed for **memory and disk**. The deepest level gets +what a test Proxmox actually asks for — 4 GB of memory, 25 GB of disk — and +every parent above it adds its own overhead and nothing else: 2 GiB and 10 GB. +A ten-level descent therefore asks its first level for 22 GB and 115 GB, where +handing resources down wanted 50 GB of memory for the same depth. The +processor follows a different rule; see below. Three budgets can bound the depth, and `script/proxmox/nesting.py` names the one that ran out: @@ -66,15 +66,33 @@ one that ran out: `pvestatd`, `pvedaemon`, `pveproxy`) *and* hold its child; * **disk** — the child's disk lives *inside* the parent's, which must also hold its own system; -* **processor** — each level wants one vCPU more than its child, so ten levels - ask eleven of the first. Half the physical cores is the ceiling: the - orchestrator runs on that machine too. +* **processor** — it does *not* grow with depth. Every nested level keeps a + fixed, narrow width; only the first level counts against the physical cores. + Either the machine can carry that first level or it can carry nothing. -That third budget is measured, not assumed. Twelve vCPU at the fourth level -froze the guest kernel in early boot — same instruction pointer at three -readings two minutes apart — while two progressed. The number was not the -culprit: that VM had twelve vCPU on a host with two, six times wider than its -own machine. Overcommit freezes, not the twelve. +That third rule is measured, and it cost two descents to get right. A nested +guest at the **fourth** level freezes in early boot as soon as it is wide: +twelve vCPU the first time, eight the second — same instruction pointer at +three readings five minutes apart, 32 MiB read and not one byte more for 106 +minutes. Two vCPU boots. + +The first freeze was blamed on **overcommit**: that VM had twelve vCPU on a +host with two. The second measurement refuted it — eight vCPU on a parent with +**nine**, load 1.47, no overcommit at all, and the same freeze. It is the +nested guest's vCPU count, not its ratio to its host's. + +At the third level, 9 vCPU boots in 117 s. The threshold sits between the +third and fourth level, so no nested level is ever made wide. An earlier +version of this algorithm gave each parent one vCPU more than its child, which +made level 4 eight wide — exactly the frozen case. The rule made wide what +must stay narrow. + +Hence three fixed widths: `VCPU_METAL` for level 1 (on bare metal, no freeze +risk — eleven vCPU booted there in 42 s), `VCPU_IMBRIQUE` for the deepest, and +`VCPU_INTERMEDIAIRE` in between, wide enough to host its child without being +as narrow as it. That middle number is a **hypothesis**: two is proven to boot +at the fourth level and eight is proven to freeze, with nothing measured in +between. The descent decides. Memory is not the lever. On that same manual VM, dropping it from 9 GB to 2 GB moved nothing — it stopped after reading the same 32 MiB, which is simply the diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index fa3714f..f73296c 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -22,13 +22,19 @@ documente l'imbrication qu'à DEUX niveaux. Deux nombres viennent de la même mesure, et méritent d'être dits : -* 12 vCPU au quatrième étage ont GELÉ le noyau invité en tout début de - démarrage — même RIP à trois relevés, deux minutes d'écart, pas un octet lu - de plus. Les mêmes 2 vCPU avançaient. Le nombre n'était pas le fautif : cette - VM avait douze vCPU sur un hôte qui en avait DEUX, six fois plus large que sa - propre machine. C'est le surengagement qui gèle, pas le douze — d'où le - dimensionnement par étage plus bas, qui donne à chaque parent un vCPU de plus - qu'à son enfant ; +* au QUATRIÈME étage, un invité large GÈLE en tout début de démarrage. Mesuré + deux fois, à douze vCPU puis à huit : même RIP à trois relevés espacés de + cinq minutes, 32 Mio lus et plus un octet — 106 minutes durant, pour le + second. Les mêmes 2 vCPU démarrent. + + On avait d'abord imputé le premier gel au SURENGAGEMENT : cette VM à douze + vCPU tournait sur un hôte qui en avait deux. La seconde mesure l'a réfuté — + huit vCPU sur un parent qui en avait NEUF, charge 1,47, aucun surengagement, + et le même gel. C'est bien le nombre de vCPU de l'invité imbriqué, et non son + rapport à celui de son hôte. + + Au TROISIÈME étage, 9 vCPU démarrent en 117 s. Le seuil est donc entre le + troisième et le quatrième étage ; tout étage imbriqué reste étroit ; * cette VM-là s'arrêtait après avoir lu 33 682 432 octets — 32 Mio, soit simplement la taille de ses fichiers d'amorçage — et le chiffre ne bougeait pas quand on lui retirait de la mémoire. La mémoire n'était donc pas le @@ -74,22 +80,40 @@ PVE_DISQUE_CIBLE_GO = 25 # En dessous, un Proxmox ne démarre pas ses démons ou n'a plus la place # d'importer une image cloud. +# Une profondeur qu'aucun budget ne borne. Grand, mais fini : « inf » se +# propagerait dans min() et rendrait un float là où tout le reste compte des +# étages entiers. +PLAFOND_LIBRE = 10**6 RAM_MIN_MO = 2048 DISQUE_MIN_GO = 15 -# Le processeur se dimensionne DEPUIS LE BAS lui aussi, et pour la même -# raison que la mémoire — mais celle-là s'est vue à l'usage. +# Le processeur NE se dimensionne PAS depuis le bas, contrairement à la +# mémoire et au disque. Trois nombres fixes, et la mesure les impose. # -# Avec deux vCPU à chaque étage imbriqué, l'étage 3 avait deux vCPU pour -# héberger un invité qui en demandait deux : cent pour cent de surengagement, -# et l'hyperviseur lui-même à servir par-dessus. À chaque étage. Mesuré sur une -# descente réelle : une seconde VM démarrée au quatrième étage a lu DEUX -# KILO-OCTETS en onze minutes, affamée par l'installation qui tournait à côté. +# Une première version donnait à chaque parent un vCPU de plus qu'à son enfant, +# pour supprimer le surengagement : le plus profond deux, son parent trois, et +# ainsi de suite jusqu'à onze au premier. Elle rendait donc LARGES les étages +# du milieu — huit au quatrième, sept au cinquième. Or c'est exactement là que +# l'invité gèle : mesuré, l'étage 4 à huit vCPU n'a pas passé son amorçage en +# 106 minutes, sur un parent à neuf vCPU parfaitement sain. La règle rendait +# large ce qui doit rester étroit. # -# Chaque étage reçoit donc UN vCPU de plus que son enfant : le plus profond en -# a deux, son parent trois, et ainsi de suite. Le premier étage d'une descente -# à dix en demande onze — sur vingt-huit cœurs réels, cela passe. +# Le plus profond : deux, le seul chiffre dont on ait la preuve qu'il démarre +# au quatrième étage. VCPU_IMBRIQUE = 2 +# Les étages imbriqués intermédiaires : un de plus, pour héberger leur enfant +# sans être aussi étroits que lui. Deux hébergeant deux, c'est cent pour cent +# de surengagement — et l'installation de l'étage 4 dépassait alors 2 h 50 +# contre 793 s pour l'étage 3. +# +# Trois est une HYPOTHÈSE : on a la preuve que deux démarre au quatrième étage +# et que huit gèle, rien entre les deux. C'est la descente qui tranchera. +VCPU_INTERMEDIAIRE = 3 +# Le premier étage tourne sur le MÉTAL : aucun risque de gel, et son amorçage +# est rapide — onze vCPU y ont démarré en 42 s. Il n'a pourtant qu'un enfant à +# trois vCPU à servir ; quatre suffisent, et laissent la machine physique aux +# autres. +VCPU_METAL = 4 # Ce qu'on LAISSE à la machine physique. L'orchestrateur tourne dessus, son # ssh vers chaque étage aussi, et la suite de tests avec. # @@ -153,14 +177,14 @@ def nesting_plan( """Ce que le PREMIER étage doit avoir pour qu'une descente de `d` étages tienne : la cible du bas, plus un surcoût par étage au-dessus. - Le processeur en fait partie : chaque étage en veut un de plus que son - enfant, donc le premier en veut VCPU_IMBRIQUE + d - 1. Sans cette - condition, on annonçait dix étages sur une machine à quatre cœurs. + Le processeur n'en fait PAS partie de la même façon : il ne croît + pas avec la profondeur, puisque tout étage imbriqué reste étroit. Le + premier étage demande VCPU_METAL, quelle que soit la profondeur. """ return ( PVE_RAM_CIBLE_MO + (d - 1) * PVE_RAM_MO, PVE_DISQUE_CIBLE_GO + (d - 1) * PVE_DISQUE_GO, - VCPU_IMBRIQUE + d - 1, + VCPU_METAL if d > 1 else VCPU_IMBRIQUE, ) budget_vcpu = int(cpu_hote) - HOTE_RESERVE_VCPU @@ -171,7 +195,10 @@ def nesting_plan( plafonds = { "ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1, "disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1, - "vcpu": budget_vcpu - VCPU_IMBRIQUE + 1, + # Le processeur ne borne plus la profondeur : les étages imbriqués + # gardent une largeur fixe, seul le premier compte sur le métal. Il + # borne encore à ZÉRO une machine trop petite pour ce premier étage. + "vcpu": PLAFOND_LIBRE if budget_vcpu >= VCPU_METAL else 0, } plafonds = {nom: max(0, valeur) for nom, valeur in plafonds.items()} demandee = max(0, int(profondeur)) @@ -185,10 +212,18 @@ def nesting_plan( niveaux = [ { "niveau": niveau, - # UN de plus que son enfant. Un parent aussi étroit que son - # enfant, c'est cent pour cent de surengagement — et l'hyperviseur - # à servir en plus. - "vcpu": VCPU_IMBRIQUE + (atteignable - niveau), + # Trois largeurs, et aucune ne dépend de la profondeur : le + # métal en premier, le fond au plus étroit, les intermédiaires + # juste assez larges pour héberger leur enfant. + "vcpu": ( + VCPU_METAL + if niveau == 1 + else ( + VCPU_IMBRIQUE + if niveau == atteignable + else VCPU_INTERMEDIAIRE + ) + ), # Les planchers ne sont pas décoratifs : ils tiennent même si # quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus # lus par personne et les tests qui les vérifiaient passaient diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 8a20fd0..36db064 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -50,21 +50,64 @@ class TestLePlanDesEtages(unittest.TestCase): def test_each_parent_adds_exactly_its_own_overhead(self): # Ni plus ni moins : un parent plus large que nécessaire ralentit tout - # ce qu'il héberge, un parent trop juste ne le fait pas tourner. + # ce qu'il héberge, un parent trop juste ne le fait pas tourner. Vaut + # pour la mémoire et le disque — le processeur, lui, ne croît pas avec + # la profondeur, voir test_no_nested_level_is_ever_wide. niveaux = nesting.nesting_plan(8, **self.HOTE)["niveaux"] for parent, enfant in zip(niveaux, niveaux[1:]): self.assertEqual(parent["ram"] - enfant["ram"], nesting.PVE_RAM_MO) self.assertEqual( parent["disque"] - enfant["disque"], nesting.PVE_DISQUE_GO ) - self.assertEqual(parent["vcpu"] - enfant["vcpu"], 1) + + def test_no_nested_level_is_ever_wide(self): + """MESURÉ, deux fois : au quatrième étage un invité large GÈLE. + + Douze vCPU d'abord, sur un parent qui en avait deux : on avait imputé + le gel au surengagement. Puis huit vCPU sur un parent qui en avait + NEUF, charge 1,47, aucun surengagement — 32 Mio lus en 106 minutes, + même RIP à trois relevés espacés de cinq minutes. C'est le nombre de + vCPU de l'invité imbriqué, et rien d'autre. + + Une version de ce module donnait un vCPU de plus à chaque parent, ce + qui rendait l'étage 4 large de huit : exactement le cas gelé. Aucun + étage imbriqué ne doit dépasser VCPU_INTERMEDIAIRE, à AUCUNE + profondeur demandée.""" + for profondeur in range(1, 13): + niveaux = nesting.nesting_plan(profondeur, **self.HOTE)["niveaux"] + for n in niveaux[1:]: + self.assertLessEqual( + n["vcpu"], + nesting.VCPU_INTERMEDIAIRE, + f"profondeur {profondeur}, étage {n['niveau']}", + ) + + def test_the_three_widths_are_where_they_belong(self): + niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] + # Le métal : aucun risque de gel, onze vCPU y ont démarré en 42 s. + self.assertEqual(niveaux[0]["vcpu"], nesting.VCPU_METAL) + # Le fond : le seul chiffre dont on ait la preuve qu'il démarre au + # quatrième étage. + self.assertEqual(niveaux[-1]["vcpu"], nesting.VCPU_IMBRIQUE) + for n in niveaux[1:-1]: + self.assertEqual(n["vcpu"], nesting.VCPU_INTERMEDIAIRE) + + def test_a_single_level_descent_runs_on_metal(self): + niveaux = nesting.nesting_plan(1, **self.HOTE)["niveaux"] + self.assertEqual(len(niveaux), 1) + self.assertEqual(niveaux[0]["vcpu"], nesting.VCPU_METAL) def test_a_parent_is_never_narrower_than_its_child(self): """Deux vCPU hébergeant deux vCPU, c'est cent pour cent de surengagement — et l'hyperviseur à servir en plus. Mesuré : une VM démarrée au quatrième étage a lu DEUX KILO-OCTETS en onze minutes, - affamée par l'installation qui tournait à côté.""" - for coeurs in (4, 8, 12, 28): + affamée par l'installation qui tournait à côté. L'installation de + l'étage 4 dépassait alors 2 h 50 contre 793 s pour l'étage 3. + + « Jamais plus étroit », et non « toujours plus large » : deux étages + imbriqués voisins ont la même largeur, ce que le gel du quatrième + étage impose. C'est le PLUS PROFOND qui descend à deux.""" + for coeurs in (6, 8, 12, 28): with self.subTest(coeurs=coeurs): niveaux = nesting.nesting_plan( 6, @@ -73,22 +116,38 @@ class TestLePlanDesEtages(unittest.TestCase): disque_libre_go=400, )["niveaux"] for parent, enfant in zip(niveaux, niveaux[1:]): - self.assertGreater(parent["vcpu"], enfant["vcpu"]) + self.assertGreaterEqual(parent["vcpu"], enfant["vcpu"]) self.assertGreater(parent["ram"], enfant["ram"]) self.assertGreater(parent["disque"], enfant["disque"]) - def test_the_cpu_budget_can_bound_the_depth(self): - """Sur une petite machine, c'est le PROCESSEUR qui borne, pas la - mémoire : chaque étage en veut un de plus que son enfant, donc dix - étages demandent onze vCPU au premier.""" - plan = nesting.nesting_plan( + def test_the_cpu_only_ever_refuses_outright(self): + """Le processeur ne borne plus une profondeur INTERMÉDIAIRE : les + étages imbriqués gardent une largeur fixe, seul le premier compte sur + le métal. Ou la machine peut le porter, ou elle ne peut rien. + + Une version d'avant faisait croître la demande avec la profondeur — + onze vCPU pour dix étages — et bornait donc à trois étages sur huit + cœurs. Elle rendait aussi le quatrième étage large de huit, ce qui le + gelait : la borne cachait un défaut.""" + large = nesting.nesting_plan( 10, cpu_hote=8, ram_dispo_mo=64000, disque_libre_go=400 ) - self.assertEqual(plan["arret"], "vcpu") - self.assertLess(plan["atteignable"], 10) + self.assertEqual(large["atteignable"], 10) + self.assertEqual(large["arret"], "") + # Trop petite pour le premier étage : zéro, et le dire. + for coeurs in (1, 2, 4): + with self.subTest(coeurs=coeurs): + petite = nesting.nesting_plan( + 10, + cpu_hote=coeurs, + ram_dispo_mo=64000, + disque_libre_go=400, + ) + self.assertEqual(petite["atteignable"], 0) + self.assertEqual(petite["arret"], "vcpu") # Et le premier étage laisse à l'hôte ce qui lui est réservé. self.assertLessEqual( - plan["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU + large["niveaux"][0]["vcpu"], 8 - nesting.HOTE_RESERVE_VCPU ) def test_the_named_resource_is_the_one_that_really_binds(self): From 30399b45597af99db77675b473be9c00c645820d Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Thu, 27 Aug 2026 15:39:24 -0400 Subject: [PATCH 18/26] =?UTF-8?q?[FIX]=20imbrication=20:=20le=20co=C3=BBt?= =?UTF-8?q?=20d'un=20vCPU=20d=C3=A9pend=20de=20la=20profondeur=20de=20l'?= =?UTF-8?q?=C3=A9tage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Amorçage du quatrième étage, mesuré sur deux descentes complètes : 1 664 s à 2 vCPU, 15 608 s à 3. Un seul vCPU de plus, ×9,4. Aux étages 2 et 3 le même vCPU ne coûte RIEN — ssh en 37 s et 93 s, comme à deux. Le « gel » observé à 8 et 12 vCPU n'est probablement pas autre chose que cette courbe poussée assez loin : 1 664 × 9,4 par vCPU dépasse vite toute patience, et un RIP immobile à cinq minutes d'intervalle ne s'en distingue pas. D'où un SEUIL de profondeur au lieu d'une largeur uniforme. Le troisième vCPU reste aux étages 2 et 3, où il est gratuit et où il enlève le surengagement qui affamait l'installation de l'étage 4 — celle-ci ne finissait pas avec un parent à 2 vCPU, elle progresse avec un parent à 3. À partir du quatrième étage, le strict minimum. La combinaison ainsi obtenue — parent 3, enfant 2 — n'a jamais été mesurée : les deux essais étaient (2, 2) et (3, 3). --- EN --- Fourth-level boot, measured on two full descents: 1,664 s at 2 vCPU, 15,608 s at 3. One more vCPU, ×9.4. At levels 2 and 3 that same vCPU costs NOTHING — ssh in 37 s and 93 s, same as at two. The "freeze" seen at 8 and 12 vCPU is most likely nothing but this curve taken far enough: 1,664 × 9.4 per vCPU quickly exceeds any patience, and a static RIP five minutes apart is indistinguishable from it. Hence a depth THRESHOLD instead of a uniform width. The third vCPU stays at levels 2 and 3, where it is free and where it removes the overcommit that starved level 4's install — which never finished with a 2-vCPU parent and does progress with a 3-vCPU one. From the fourth level down, the strict minimum. The resulting combination — parent 3, child 2 — has never been measured: the two attempts were (2, 2) and (3, 3). Assisted-by: claude-opus-5 (cherry picked from commit 9a5583a4b9461a087d161775764c560c82e0609e) --- script/proxmox/nesting.py | 36 ++++++++++++++++++++++++--------- test/test_proxmox_nesting.py | 39 ++++++++++++++++++++++++++++++------ 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index f73296c..3acd18c 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -101,14 +101,25 @@ DISQUE_MIN_GO = 15 # Le plus profond : deux, le seul chiffre dont on ait la preuve qu'il démarre # au quatrième étage. VCPU_IMBRIQUE = 2 -# Les étages imbriqués intermédiaires : un de plus, pour héberger leur enfant +# Les étages imbriqués PEU PROFONDS : un de plus, pour héberger leur enfant # sans être aussi étroits que lui. Deux hébergeant deux, c'est cent pour cent # de surengagement — et l'installation de l'étage 4 dépassait alors 2 h 50 # contre 793 s pour l'étage 3. # -# Trois est une HYPOTHÈSE : on a la preuve que deux démarre au quatrième étage -# et que huit gèle, rien entre les deux. C'est la descente qui tranchera. +# « Peu profonds », et c'est la mesure qui l'impose. Ce troisième vCPU ne coûte +# RIEN aux étages 2 et 3 — leur ssh répond en 37 s et 93 s, comme à deux vCPU — +# et il coûte tout au quatrième : 15 608 s, soit 4 h 20, contre 1 664 s à deux +# vCPU. Un seul vCPU de plus, l'amorçage multiplié par 9,4. VCPU_INTERMEDIAIRE = 3 +# Le premier étage qui doit rester au strict minimum. +# +# Amorçage du quatrième étage, mesuré : 1 664 s à 2 vCPU, 15 608 s à 3, jamais +# à 8 ni à 12 — même RIP à cinq minutes d'intervalle. Le « gel » observé à 8 et +# 12 n'est probablement pas autre chose que cette courbe poussée assez loin : +# 1 664 × 9,4 par vCPU supplémentaire dépasse vite toute patience. +# +# Aux étages 2 et 3, la même largeur ne coûte rien. Le seuil est donc là. +SEUIL_ETROIT = 4 # Le premier étage tourne sur le MÉTAL : aucun risque de gel, et son amorçage # est rapide — onze vCPU y ont démarré en 42 s. Il n'a pourtant qu'un enfant à # trois vCPU à servir ; quatre suffisent, et laissent la machine physique aux @@ -178,8 +189,9 @@ def nesting_plan( étages tienne : la cible du bas, plus un surcoût par étage au-dessus. Le processeur n'en fait PAS partie de la même façon : il ne croît - pas avec la profondeur, puisque tout étage imbriqué reste étroit. Le - premier étage demande VCPU_METAL, quelle que soit la profondeur. + pas avec la profondeur — il DÉCROÎT, et se stabilise à + VCPU_IMBRIQUE dès SEUIL_ETROIT. Le premier étage demande VCPU_METAL, + quelle que soit la profondeur. """ return ( PVE_RAM_CIBLE_MO + (d - 1) * PVE_RAM_MO, @@ -212,15 +224,21 @@ def nesting_plan( niveaux = [ { "niveau": niveau, - # Trois largeurs, et aucune ne dépend de la profondeur : le - # métal en premier, le fond au plus étroit, les intermédiaires - # juste assez larges pour héberger leur enfant. + # Le métal peut être large ; un étage imbriqué peu profond + # gagne son troisième vCPU pour héberger son enfant sans + # surengagement ; à partir de SEUIL_ETROIT, le strict minimum, + # parce que là ce troisième vCPU multiplie l'amorçage par 9,4. + # + # L'étage juste AU-DESSUS du seuil garde donc trois quand son + # enfant en a deux : c'est le seul endroit de la descente où le + # surengagement disparaît, et c'est celui qui compte, puisque + # l'étage 4 est le premier dont l'installation s'effondrait. "vcpu": ( VCPU_METAL if niveau == 1 else ( VCPU_IMBRIQUE - if niveau == atteignable + if niveau >= SEUIL_ETROIT else VCPU_INTERMEDIAIRE ) ), diff --git a/test/test_proxmox_nesting.py b/test/test_proxmox_nesting.py index 36db064..d03079d 100644 --- a/test/test_proxmox_nesting.py +++ b/test/test_proxmox_nesting.py @@ -83,14 +83,41 @@ class TestLePlanDesEtages(unittest.TestCase): ) def test_the_three_widths_are_where_they_belong(self): + """Le coût d'un vCPU dépend de la PROFONDEUR de l'étage, pas d'une + largeur absolue. + + Mesuré : le troisième vCPU ne coûte rien aux étages 2 et 3 — ssh en + 37 s et 93 s, comme à deux vCPU — et coûte 4 h 20 au quatrième, contre + 1 664 s à deux. Un seul vCPU de plus, l'amorçage ×9,4.""" niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] + largeurs = {n["niveau"]: n["vcpu"] for n in niveaux} # Le métal : aucun risque de gel, onze vCPU y ont démarré en 42 s. - self.assertEqual(niveaux[0]["vcpu"], nesting.VCPU_METAL) - # Le fond : le seul chiffre dont on ait la preuve qu'il démarre au - # quatrième étage. - self.assertEqual(niveaux[-1]["vcpu"], nesting.VCPU_IMBRIQUE) - for n in niveaux[1:-1]: - self.assertEqual(n["vcpu"], nesting.VCPU_INTERMEDIAIRE) + self.assertEqual(largeurs[1], nesting.VCPU_METAL) + # Peu profonds : le troisième vCPU est gratuit, et il enlève le + # surengagement là où l'installation s'effondrait. + for niveau in range(2, nesting.SEUIL_ETROIT): + self.assertEqual( + largeurs[niveau], nesting.VCPU_INTERMEDIAIRE, f"étage {niveau}" + ) + # À partir du seuil : le strict minimum, sans exception. + for niveau in range(nesting.SEUIL_ETROIT, 7): + self.assertEqual( + largeurs[niveau], nesting.VCPU_IMBRIQUE, f"étage {niveau}" + ) + + def test_the_level_above_the_threshold_keeps_its_headroom(self): + """C'est le seul endroit de la descente où le surengagement disparaît, + et c'est celui qui compte : l'étage 4 est le premier dont + l'installation s'effondrait, faute d'un parent plus large que lui. + + Les deux combinaisons mesurées étaient (parent 2, enfant 2) — démarre + en 1 664 s puis l'installation ne finit pas — et (parent 3, enfant 3) — + démarre en 15 608 s. Celle-ci est (parent 3, enfant 2).""" + niveaux = nesting.nesting_plan(6, **self.HOTE)["niveaux"] + largeurs = {n["niveau"]: n["vcpu"] for n in niveaux} + parent = largeurs[nesting.SEUIL_ETROIT - 1] + enfant = largeurs[nesting.SEUIL_ETROIT] + self.assertGreater(parent, enfant) def test_a_single_level_descent_runs_on_metal(self): niveaux = nesting.nesting_plan(1, **self.HOTE)["niveaux"] From 8e5f5b9097ab38bff2d32f8df27504f8b1e98499 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 00:21:51 -0400 Subject: [PATCH 19/26] =?UTF-8?q?[UPD]=20LongTest=20:=20trois=20=C3=A9tage?= =?UTF-8?q?s=20par=20d=C3=A9faut,=20la=20mesure=20le=20dit?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le défaut promettait dix étages qu'aucune machine ne tient. Une descente complète par ligne, sur 28 cœurs : étage 1 : 0 s d'amorçage, 200 s d'installation, 280 s en tout étage 2 : 37 s, 344 s, 495 s étage 3 : 93 s, 777 s, 1 064 s étage 4 : 15 608 s, 26 306 s, n'a pas abouti Trois étages coûtent une demi-heure. Le quatrième a coûté 4 h 20 d'amorçage et 7 h 18 d'installation sur la même machine : tout y est 15 à 30 fois plus lent, pas une seule étape. C'est aussi là que les fabricants s'arrêtent — le quatrième étage est le troisième hyperviseur imbriqué, AMD en documente deux. La profondeur reste le seul paramètre, et le README dit désormais ce qu'on achète en la montant. --- EN --- The default promised ten levels no machine can hold. One full descent per row, on 28 cores: level 1: 0 s boot, 200 s install, 280 s total level 2: 37 s, 344 s, 495 s level 3: 93 s, 777 s, 1 064 s level 4: 15 608 s, 26 306 s, did not finish Three levels cost half an hour. The fourth cost 4 h 20 of boot and 7 h 18 of install on the same machine: everything there is 15 to 30 times slower, not one step. It is also where the vendors stop — level 4 is the third nested hypervisor, and AMD documents two. Depth remains the only parameter, and the README now says what raising it buys. Assisted-by: claude-opus-5 (cherry picked from commit 226d6ffa3c664ba9e1a6dfdf48d52027b52eda23) --- LongTest/README.base.md | 59 +++++++++++++++++++++++++++++++++--- LongTest/README.fr.md | 30 ++++++++++++++++-- LongTest/README.md | 29 ++++++++++++++++-- LongTest/deep_proxmox.py | 8 ++++- script/todo/longtest_menu.py | 12 ++++++-- script/todo/todo_i18n.py | 6 ++-- test/test_todo_longtest.py | 59 +++++++++++++++++++++++++++++++++++- 7 files changed, 187 insertions(+), 16 deletions(-) diff --git a/LongTest/README.base.md b/LongTest/README.base.md index 2e9c8ef..50fbd93 100644 --- a/LongTest/README.base.md +++ b/LongTest/README.base.md @@ -33,11 +33,36 @@ Which is the point of the script: a number obtained once, on one machine, in one chain, is an anecdote. ``` -./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created -./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py # three levels, ~30 minutes +./LongTest/deep_proxmox.py --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 5 # ask for more, knowingly ./LongTest/deep_proxmox.py --detruire # undo it ``` +### How deep is worth asking for + +The depth is the only setting, and **three** is the default because three +works. Measured on a 28-core machine, one full descent per row: + +| level | boot (ssh) | install | total | +|------:|-----------:|--------:|------:| +| 1 | 0 s | 200 s | 280 s | +| 2 | 37 s | 344 s | 495 s | +| 3 | 93 s | 777 s | 1 064 s | +| 4 | **15 608 s** | **26 306 s** | did not finish | + +Three levels cost half an hour. The **fourth** cost 4 h 20 of boot and 7 h 18 +of install on the same machine — everything there is 15 to 30 times slower, not +just one step. And it lands exactly where the hardware vendors stop: level 4 is +the *third* nested hypervisor, and AMD documents two. + +A wider guest makes it worse, sharply: at level 4, one extra vCPU multiplied +the boot by 9.4 (1 664 s at two vCPU, 15 608 s at three), and at eight vCPU the +guest read 32 MiB in 106 minutes with a static instruction pointer. At levels 2 +and 3 that same vCPU costs nothing. + +So: three by default, five if you want to know, ten only to watch the wall. + The descent is **uniform**. Every level, the first included, goes through the same six steps: create, wait for ssh, install Proxmox, reboot and check the kernel, bring pmxcfs back up, check the storage. Only creation differs — @@ -141,11 +166,37 @@ D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, est une anecdote. ``` -./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé -./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py # trois étages, ~30 minutes +./LongTest/deep_proxmox.py --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 5 # en demander plus, sciemment ./LongTest/deep_proxmox.py --detruire # défaire ``` +### Quelle profondeur vaut la peine d'être demandée + +La profondeur est le seul réglage, et **trois** est le défaut parce que trois +marche. Mesuré sur une machine à 28 cœurs, une descente complète par ligne : + +| étage | amorçage (ssh) | installation | total | +|------:|---------------:|-------------:|------:| +| 1 | 0 s | 200 s | 280 s | +| 2 | 37 s | 344 s | 495 s | +| 3 | 93 s | 777 s | 1 064 s | +| 4 | **15 608 s** | **26 306 s** | n'a pas abouti | + +Trois étages coûtent une demi-heure. Le **quatrième** a coûté 4 h 20 +d'amorçage et 7 h 18 d'installation sur la même machine — tout y est 15 à 30 +fois plus lent, pas une seule étape. Et cela tombe précisément là où les +fabricants s'arrêtent : le quatrième étage est le *troisième* hyperviseur +imbriqué, et AMD en documente deux. + +Un invité plus large aggrave brutalement : au quatrième étage, un vCPU de plus +a multiplié l'amorçage par 9,4 (1 664 s à deux vCPU, 15 608 s à trois), et à +huit vCPU l'invité a lu 32 Mio en 106 minutes, pointeur d'instruction +immobile. Aux étages 2 et 3, ce même vCPU ne coûte rien. + +Donc : trois par défaut, cinq pour savoir, dix seulement pour voir le mur. + La descente est **uniforme**. Chaque étage, le premier compris, passe par les mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la diff --git a/LongTest/README.fr.md b/LongTest/README.fr.md index 67e05c2..55137c3 100644 --- a/LongTest/README.fr.md +++ b/LongTest/README.fr.md @@ -32,11 +32,37 @@ D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, est une anecdote. ``` -./LongTest/deep_proxmox.py --depth 10 --dry-run # le plan, rien de créé -./LongTest/deep_proxmox.py --depth 10 # des heures +./LongTest/deep_proxmox.py # trois étages, ~30 minutes +./LongTest/deep_proxmox.py --dry-run # le plan, rien de créé +./LongTest/deep_proxmox.py --depth 5 # en demander plus, sciemment ./LongTest/deep_proxmox.py --detruire # défaire ``` +### Quelle profondeur vaut la peine d'être demandée + +La profondeur est le seul réglage, et **trois** est le défaut parce que trois +marche. Mesuré sur une machine à 28 cœurs, une descente complète par ligne : + +| étage | amorçage (ssh) | installation | total | +|------:|---------------:|-------------:|------:| +| 1 | 0 s | 200 s | 280 s | +| 2 | 37 s | 344 s | 495 s | +| 3 | 93 s | 777 s | 1 064 s | +| 4 | **15 608 s** | **26 306 s** | n'a pas abouti | + +Trois étages coûtent une demi-heure. Le **quatrième** a coûté 4 h 20 +d'amorçage et 7 h 18 d'installation sur la même machine — tout y est 15 à 30 +fois plus lent, pas une seule étape. Et cela tombe précisément là où les +fabricants s'arrêtent : le quatrième étage est le *troisième* hyperviseur +imbriqué, et AMD en documente deux. + +Un invité plus large aggrave brutalement : au quatrième étage, un vCPU de plus +a multiplié l'amorçage par 9,4 (1 664 s à deux vCPU, 15 608 s à trois), et à +huit vCPU l'invité a lu 32 Mio en 106 minutes, pointeur d'instruction +immobile. Aux étages 2 et 3, ce même vCPU ne coûte rien. + +Donc : trois par défaut, cinq pour savoir, dix seulement pour voir le mur. + La descente est **uniforme**. Chaque étage, le premier compris, passe par les mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la diff --git a/LongTest/README.md b/LongTest/README.md index 41cc307..6158c24 100644 --- a/LongTest/README.md +++ b/LongTest/README.md @@ -28,11 +28,36 @@ Which is the point of the script: a number obtained once, on one machine, in one chain, is an anecdote. ``` -./LongTest/deep_proxmox.py --depth 10 --dry-run # the plan, nothing created -./LongTest/deep_proxmox.py --depth 10 # hours +./LongTest/deep_proxmox.py # three levels, ~30 minutes +./LongTest/deep_proxmox.py --dry-run # the plan, nothing created +./LongTest/deep_proxmox.py --depth 5 # ask for more, knowingly ./LongTest/deep_proxmox.py --detruire # undo it ``` +### How deep is worth asking for + +The depth is the only setting, and **three** is the default because three +works. Measured on a 28-core machine, one full descent per row: + +| level | boot (ssh) | install | total | +|------:|-----------:|--------:|------:| +| 1 | 0 s | 200 s | 280 s | +| 2 | 37 s | 344 s | 495 s | +| 3 | 93 s | 777 s | 1 064 s | +| 4 | **15 608 s** | **26 306 s** | did not finish | + +Three levels cost half an hour. The **fourth** cost 4 h 20 of boot and 7 h 18 +of install on the same machine — everything there is 15 to 30 times slower, not +just one step. And it lands exactly where the hardware vendors stop: level 4 is +the *third* nested hypervisor, and AMD documents two. + +A wider guest makes it worse, sharply: at level 4, one extra vCPU multiplied +the boot by 9.4 (1 664 s at two vCPU, 15 608 s at three), and at eight vCPU the +guest read 32 MiB in 106 minutes with a static instruction pointer. At levels 2 +and 3 that same vCPU costs nothing. + +So: three by default, five if you want to know, ten only to watch the wall. + The descent is **uniform**. Every level, the first included, goes through the same six steps: create, wait for ssh, install Proxmox, reboot and check the kernel, bring pmxcfs back up, check the storage. Only creation differs — diff --git a/LongTest/deep_proxmox.py b/LongTest/deep_proxmox.py index ec2869b..bd2be2f 100755 --- a/LongTest/deep_proxmox.py +++ b/LongTest/deep_proxmox.py @@ -1174,7 +1174,13 @@ def principal(argv=None): parseur = argparse.ArgumentParser( description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?" ) - parseur.add_argument("--depth", type=int, default=10) + # Trois par défaut, et c'est une MESURE, pas une prudence : les trois + # premiers étages coûtent 280, 495 et 1 064 secondes — une demi-heure en + # tout. Le quatrième en a coûté 7 h 18 d'installation et 4 h 20 d'amorçage + # sur la même machine. Un défaut à dix promettait ce qu'aucune machine ne + # peut tenir ; la profondeur reste un paramètre, et c'est à qui la demande + # de savoir ce qu'il demande. + parseur.add_argument("--depth", type=int, default=3) parseur.add_argument("--dry-run", action="store_true") parseur.add_argument("--detruire", action="store_true") args = parseur.parse_args(argv) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 4fe7b04..918da38 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -87,6 +87,12 @@ class LongTestMenuMixin: print(t("Command not found !")) def _longtest_depth(self): - """Profondeur demandée. Dix par défaut : c'est ce qu'on veut mesurer.""" - brut = input(f"{t('Depth (default 10): ')}").strip() - return int(brut) if brut.isdigit() and int(brut) > 0 else 10 + """Profondeur demandée. Trois par défaut, parce que trois marche. + + Mesuré sur cette machine : les trois premiers étages prennent 280, 495 + et 1 064 secondes — une demi-heure. Le quatrième a demandé 7 h 18 + d'installation et 4 h 20 d'amorçage, et les suivants se comptent en + jours. Dix par défaut promettait ce qu'aucune machine ne tient. + """ + brut = input(f"{t('Depth (default 3): ')}").strip() + return int(brut) if brut.isdigit() and int(brut) > 0 else 3 diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d94bf33..d0df4f4 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3454,9 +3454,9 @@ TRANSLATIONS = { "fr": "Script introuvable :", "en": "Script not found:", }, - "Depth (default 10): ": { - "fr": "Profondeur (défaut 10) : ", - "en": "Depth (default 10): ", + "Depth (default 3): ": { + "fr": "Profondeur (défaut 3) : ", + "en": "Depth (default 3): ", }, "Nesting level": { "fr": "Étage d'imbrication", diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 5a05e7d..c72c71a 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -208,15 +208,72 @@ class TestLEssaiABlanc(unittest.TestCase): (int(n), int(v), int(r), int(d)) for n, v, r, d in plan ) for parent, enfant in zip(etages, etages[1:]): - for i, quoi in ((1, "vCPU"), (2, "RAM"), (3, "disque")): + # Mémoire et disque : STRICTEMENT décroissants, chaque parent + # portant son enfant en plus de lui-même. + for i, quoi in ((2, "RAM"), (3, "disque")): self.assertGreater( parent[i], enfant[i], f"étage {parent[0]} : {quoi}" ) + # Le processeur : jamais plus étroit, mais pas toujours plus + # large. Deux étages imbriqués peu profonds ont la même largeur — + # au quatrième étage, un vCPU de plus multiplie l'amorçage par + # 9,4, alors qu'aux étages 2 et 3 il ne coûte rien. + self.assertGreaterEqual( + parent[1], enfant[1], f"étage {parent[0]} : vCPU" + ) # Et le plus profond reçoit ce qu'un Proxmox de test demande, pas ce # qui reste. self.assertEqual(etages[-1][1], 2, "vCPU du plus profond") +class TestLaProfondeurParDefaut(unittest.TestCase): + """Trois, et c'est une MESURE, pas une prudence. + + Sur la machine où ce test a été écrit, les trois premiers étages coûtent + 280, 495 et 1 064 secondes — une demi-heure en tout. Le quatrième a demandé + 7 h 18 d'installation et 4 h 20 d'amorçage, et les suivants se comptent en + jours. Un défaut à dix promettait ce qu'aucune machine ne peut tenir : la + profondeur reste un paramètre, mais le défaut doit marcher.""" + + def test_the_script_defaults_to_three(self): + import inspect + import sys as _sys + + _sys.path.insert(0, os.path.join(RACINE, "LongTest")) + import deep_proxmox + + src = inspect.getsource(deep_proxmox.principal) + self.assertIn('"--depth", type=int, default=3', src) + + def test_the_menu_defaults_to_three(self): + import inspect + + from script.todo.todo import TODO + + src = inspect.getsource(TODO._longtest_depth) + self.assertIn("else 3", src) + # Et l'invite le DIT : un défaut caché se subit, il ne se choisit pas. + self.assertIn("Depth (default 3): ", src) + + def test_the_prompt_is_translated(self): + from script.todo.todo_i18n import TRANSLATIONS + + entree = TRANSLATIONS.get("Depth (default 3): ") + self.assertIsNotNone(entree, "invite non traduite") + self.assertIn("3", entree["fr"]) + + def test_the_default_depth_fits_a_modest_machine(self): + """Le défaut doit tenir là où le test sera lancé, pas seulement sur la + machine de celui qui l'a écrit.""" + from script.proxmox import nesting + + plan = nesting.nesting_plan( + 3, cpu_hote=8, ram_dispo_mo=24000, disque_libre_go=120 + ) + self.assertEqual(plan["atteignable"], 3) + self.assertEqual(plan["arret"], "") + + class TestDefaireSansEffacerAutreChose(unittest.TestCase): """« --detruire » effaçait par SOUS-CHAÎNE de nom, dans le mauvais ordre, sans confirmation et sans honorer --dry-run. From 84ec78a61dd954a30ceb039438ee1acebcdf5376 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 00:50:02 -0400 Subject: [PATCH 20/26] =?UTF-8?q?[REF]=20long=5Ftest=20:=20renommer=20le?= =?UTF-8?q?=20r=C3=A9pertoire=20selon=20la=20convention=20du=20d=C3=A9p?= =?UTF-8?q?=C3=B4t?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LongTest était le SEUL répertoire en CamelCase que nous ayons créé. Les deux exceptions sous script/ — OCA_maintainer-tools, OCA_odoo-module-migrator — sont des noms de dépôts amont tirés par Google Repo, pas les nôtres. Tout le reste est en minuscules avec des soulignés : image_db, code_generator, fork_github_repo, shell_script_odoo. Le nom avait été repris tel qu'il m'avait été dicté, sans être confronté à la convention — le contrôle même que le reste de ce travail applique partout. 50 occurrences dans 8 fichiers. Le menu TODO résout le nouveau chemin, l'essai à blanc passe, et les 125 tests des trois fichiers touchés restent verts. --- EN --- LongTest was the ONLY CamelCase directory we created. The two exceptions under script/ — OCA_maintainer-tools, OCA_odoo-module-migrator — are upstream repo names pulled by Google Repo, not ours. Everything else is lowercase with underscores: image_db, code_generator, fork_github_repo, shell_script_odoo. The name had been taken as dictated, without being checked against the convention — the very check the rest of this work applies everywhere. 50 occurrences across 8 files. The TODO menu resolves the new path, the dry run passes, and the 125 tests in the three touched files stay green. Assisted-by: claude-opus-5 (cherry picked from commit 170ee61e50dfeff638c84c07eadac00c62526e4d) --- {LongTest => long_test}/README.base.md | 20 ++++++------ {LongTest => long_test}/README.fr.md | 10 +++--- {LongTest => long_test}/README.md | 10 +++--- {LongTest => long_test}/deep_proxmox.py | 6 ++-- script/proxmox/nesting.py | 2 +- script/todo/longtest_menu.py | 6 ++-- script/todo/todo.py | 4 +-- test/test_todo_longtest.py | 42 ++++++++++++------------- 8 files changed, 50 insertions(+), 50 deletions(-) rename {LongTest => long_test}/README.base.md (95%) rename {LongTest => long_test}/README.fr.md (94%) rename {LongTest => long_test}/README.md (94%) rename {LongTest => long_test}/deep_proxmox.py (99%) diff --git a/LongTest/README.base.md b/long_test/README.base.md similarity index 95% rename from LongTest/README.base.md rename to long_test/README.base.md index 50fbd93..9b7ccb0 100644 --- a/LongTest/README.base.md +++ b/long_test/README.base.md @@ -4,7 +4,7 @@ -# LongTest — tests that create real machines +# long_test — tests that create real machines These are not unit tests. They create virtual machines, install systems on them, and take hours. They live here and **not** in `test/`, which the unit @@ -33,10 +33,10 @@ Which is the point of the script: a number obtained once, on one machine, in one chain, is an anecdote. ``` -./LongTest/deep_proxmox.py # three levels, ~30 minutes -./LongTest/deep_proxmox.py --dry-run # the plan, nothing created -./LongTest/deep_proxmox.py --depth 5 # ask for more, knowingly -./LongTest/deep_proxmox.py --detruire # undo it +./long_test/deep_proxmox.py # three levels, ~30 minutes +./long_test/deep_proxmox.py --dry-run # the plan, nothing created +./long_test/deep_proxmox.py --depth 5 # ask for more, knowingly +./long_test/deep_proxmox.py --detruire # undo it ``` ### How deep is worth asking for @@ -133,7 +133,7 @@ promises a depth it knows will not fit — better to announce six levels and reach six than to promise ten and die at the seventh without knowing why. -# LongTest — des tests qui créent de vraies machines +# long_test — des tests qui créent de vraies machines Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y installent des systèmes, et durent des heures. Ils vivent ici et **non** dans @@ -166,10 +166,10 @@ D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, est une anecdote. ``` -./LongTest/deep_proxmox.py # trois étages, ~30 minutes -./LongTest/deep_proxmox.py --dry-run # le plan, rien de créé -./LongTest/deep_proxmox.py --depth 5 # en demander plus, sciemment -./LongTest/deep_proxmox.py --detruire # défaire +./long_test/deep_proxmox.py # trois étages, ~30 minutes +./long_test/deep_proxmox.py --dry-run # le plan, rien de créé +./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment +./long_test/deep_proxmox.py --detruire # défaire ``` ### Quelle profondeur vaut la peine d'être demandée diff --git a/LongTest/README.fr.md b/long_test/README.fr.md similarity index 94% rename from LongTest/README.fr.md rename to long_test/README.fr.md index 55137c3..b87c56b 100644 --- a/LongTest/README.fr.md +++ b/long_test/README.fr.md @@ -1,5 +1,5 @@ -# LongTest — des tests qui créent de vraies machines +# long_test — des tests qui créent de vraies machines Ce ne sont pas des tests unitaires. Ils créent des machines virtuelles, y installent des systèmes, et durent des heures. Ils vivent ici et **non** dans @@ -32,10 +32,10 @@ D'où le script : un chiffre obtenu une fois, sur une machine, dans une chaîne, est une anecdote. ``` -./LongTest/deep_proxmox.py # trois étages, ~30 minutes -./LongTest/deep_proxmox.py --dry-run # le plan, rien de créé -./LongTest/deep_proxmox.py --depth 5 # en demander plus, sciemment -./LongTest/deep_proxmox.py --detruire # défaire +./long_test/deep_proxmox.py # trois étages, ~30 minutes +./long_test/deep_proxmox.py --dry-run # le plan, rien de créé +./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment +./long_test/deep_proxmox.py --detruire # défaire ``` ### Quelle profondeur vaut la peine d'être demandée diff --git a/LongTest/README.md b/long_test/README.md similarity index 94% rename from LongTest/README.md rename to long_test/README.md index 6158c24..4686619 100644 --- a/LongTest/README.md +++ b/long_test/README.md @@ -1,5 +1,5 @@ -# LongTest — tests that create real machines +# long_test — tests that create real machines These are not unit tests. They create virtual machines, install systems on them, and take hours. They live here and **not** in `test/`, which the unit @@ -28,10 +28,10 @@ Which is the point of the script: a number obtained once, on one machine, in one chain, is an anecdote. ``` -./LongTest/deep_proxmox.py # three levels, ~30 minutes -./LongTest/deep_proxmox.py --dry-run # the plan, nothing created -./LongTest/deep_proxmox.py --depth 5 # ask for more, knowingly -./LongTest/deep_proxmox.py --detruire # undo it +./long_test/deep_proxmox.py # three levels, ~30 minutes +./long_test/deep_proxmox.py --dry-run # the plan, nothing created +./long_test/deep_proxmox.py --depth 5 # ask for more, knowingly +./long_test/deep_proxmox.py --detruire # undo it ``` ### How deep is worth asking for diff --git a/LongTest/deep_proxmox.py b/long_test/deep_proxmox.py similarity index 99% rename from LongTest/deep_proxmox.py rename to long_test/deep_proxmox.py index bd2be2f..bad92b6 100755 --- a/LongTest/deep_proxmox.py +++ b/long_test/deep_proxmox.py @@ -24,9 +24,9 @@ dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souve en retard sur le checkout — un correctif absent du distant a fait « revenir » le même défaut sur trois VM de suite. - ./LongTest/deep_proxmox.py --depth 10 --dry-run - ./LongTest/deep_proxmox.py --depth 10 - ./LongTest/deep_proxmox.py --detruire # défait ce que la descente a posé + ./long_test/deep_proxmox.py --depth 10 --dry-run + ./long_test/deep_proxmox.py --depth 10 + ./long_test/deep_proxmox.py --detruire # défait ce que la descente a posé """ import argparse diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 3acd18c..4c2c979 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -43,7 +43,7 @@ Deux nombres viennent de la même mesure, et méritent d'être dits : Une descente complète a ensuite RÉFUTÉ ce qu'on avait conclu de la première : son quatrième étage, à 2 vCPU, a démarré, s'est installé, et a écrit des gigaoctets. Le plafond était celui du parallélisme sous imbrication, pas celui -de l'imbrication. La profondeur RÉELLEMENT atteignable se mesure — LongTest la +de l'imbrication. La profondeur RÉELLEMENT atteignable se mesure — long_test la mesure ; ce module ne calcule que ce qui est arithmétiquement possible. """ diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 918da38..35c7e9f 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -3,7 +3,7 @@ # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Les tests LONGS : de vraies machines, des heures. -Ils vivent dans `LongTest/` et non dans `test/`, et ce n'est pas un rangement +Ils vivent dans `long_test/` et non dans `test/`, et ce n'est pas un rangement de confort : le lanceur unitaire balaie `test/test_*.py` et doit rester lançable en quelques secondes, partout. Un test qui crée dix VM n'a rien à y faire — il le ferait échouer sur toute machine sans virtualisation, et @@ -21,10 +21,10 @@ import click from script.todo.todo_i18n import t # Le répertoire des tests longs, à la racine du dépôt. -LONGTEST_DIR = "LongTest" +LONGTEST_DIR = "long_test" -class LongTestMenuMixin: +class long_testMenuMixin: def _longtest_script(self, nom): """Chemin d'un test long, ou "" s'il n'est pas là.""" chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom) diff --git a/script/todo/todo.py b/script/todo/todo.py index 0a5fb21..f27bd02 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -28,7 +28,7 @@ from script.config import config_file from script.execute import execute from script.todo import todo_prefs from script.todo.database_manager import DatabaseManager -from script.todo.longtest_menu import LongTestMenuMixin +from script.todo.longtest_menu import long_testMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin from script.todo.qemu_access import QemuAccessMixin from script.todo.qemu_deploy import QemuDeployMixin @@ -97,7 +97,7 @@ class TODO( QemuManageMixin, QemuAccessMixin, ProxmoxMenuMixin, - LongTestMenuMixin, + long_testMenuMixin, ): def __init__(self): self.dir_path = None diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index c72c71a..1592262 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -29,18 +29,18 @@ PYTHON = os.path.join(RACINE, ".venv.erplibre/bin/python") class TestLaFrontiere(unittest.TestCase): - """LongTest est hors de portée du lanceur unitaire, et ce n'est pas un + """long_test est hors de portée du lanceur unitaire, et ce n'est pas un rangement de confort.""" - def test_the_unit_runner_does_not_sweep_LongTest(self): + def test_the_unit_runner_does_not_sweep_long_test(self): with open( os.path.join(RACINE, "script/test/run_unit_test.sh"), encoding="utf-8", ) as fh: lanceur = fh.read() # Le lanceur ne liste que des fichiers de test/ : rien qui parte de - # LongTest, sinon la suite unitaire créerait des VM. - self.assertNotIn("LongTest", lanceur) + # long_test, sinon la suite unitaire créerait des VM. + self.assertNotIn("long_test", lanceur) def test_the_runner_only_looks_under_test(self): """Le lanceur balaie TOUT test/test_*.py depuis qu'une liste de @@ -48,24 +48,24 @@ class TestLaFrontiere(unittest.TestCase): La frontière n'est donc plus un nom mais un RÉPERTOIRE : ce qui doit rester hors de la suite doit vivre ailleurs que dans test/. C'est - exactement pourquoi LongTest est à la racine.""" + exactement pourquoi long_test est à la racine.""" with open( os.path.join(RACINE, "script/test/run_unit_test.sh"), encoding="utf-8", ) as fh: lanceur = fh.read() self.assertIn("test/test_*.py", lanceur) - # Aucun chemin du lanceur ne sort de test/ : sinon LongTest y + # Aucun chemin du lanceur ne sort de test/ : sinon long_test y # entrerait par la porte de service. - self.assertNotIn("LongTest", lanceur) + self.assertNotIn("long_test", lanceur) def test_the_script_is_executable_and_documented(self): - script = os.path.join(RACINE, "LongTest/deep_proxmox.py") + script = os.path.join(RACINE, "long_test/deep_proxmox.py") self.assertTrue(os.access(script, os.X_OK), "doit être exécutable") # La doc est un .base.md : un .md généré se perd au prochain # « make doc_markdown ». self.assertTrue( - os.path.exists(os.path.join(RACINE, "LongTest/README.base.md")) + os.path.exists(os.path.join(RACINE, "long_test/README.base.md")) ) @@ -86,7 +86,7 @@ class TestLEssaiABlanc(unittest.TestCase): cls.res = subprocess.run( [ PYTHON, - os.path.join(RACINE, "LongTest/deep_proxmox.py"), + os.path.join(RACINE, "long_test/deep_proxmox.py"), "--depth", "4", "--dry-run", @@ -160,7 +160,7 @@ class TestLEssaiABlanc(unittest.TestCase): import inspect import sys as _sys - _sys.path.insert(0, os.path.join(RACINE, "LongTest")) + _sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox src = inspect.getsource(deep_proxmox.Descente.creer_etage1) @@ -239,7 +239,7 @@ class TestLaProfondeurParDefaut(unittest.TestCase): import inspect import sys as _sys - _sys.path.insert(0, os.path.join(RACINE, "LongTest")) + _sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox src = inspect.getsource(deep_proxmox.principal) @@ -283,7 +283,7 @@ class TestDefaireSansEffacerAutreChose(unittest.TestCase): emporte les disques ET les entrées de sauvegarde.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -359,7 +359,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): """ def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -461,7 +461,7 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): aurait emporté l'arbre sous le processus qui installait encore.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -628,7 +628,7 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): l'identifie vraiment.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -729,7 +729,7 @@ class TestLaCauseDUnMontageAbsent(unittest.TestCase): lent que son hôte.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -791,7 +791,7 @@ class TestUneLectureRateeNeConclutRien(unittest.TestCase): 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")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -862,7 +862,7 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): 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")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -930,7 +930,7 @@ class TestNePasAttendreUneMaisonDisparue(unittest.TestCase): plus.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox @@ -1013,7 +1013,7 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): reste alloué s'affichait toujours — on apprend à ne plus le lire.""" def setUp(self): - sys.path.insert(0, os.path.join(RACINE, "LongTest")) + sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox self.dp = deep_proxmox From b46615f3cf274fba416f33bdcc4cf515669f6e7b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 01:31:36 -0400 Subject: [PATCH 21/26] =?UTF-8?q?[REF]=20long=5Ftest=20:=20moteur=20commun?= =?UTF-8?q?,=20s=C3=BBret=C3=A9=20d=C3=A9clar=C3=A9e,=20sixi=C3=A8me=20?= =?UTF-8?q?=C3=A9tape?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deep_proxmox.py passe de 1245 à 474 lignes : tout ce qui ne connaît ni « qm » ni pmxcfs vit désormais dans descente.py, prêt pour un second test long. L'extraction a mis à nu ce qui protégeait un hôte qu'on n'a pas créé : rien. a_defaire exigeait « vmid » et « parent_alias », deux clés que seule une descente écrit — la protection tenait parce qu'aucun champ ne décrivait un hôte emprunté. Un champ « cree », écrit à l'instant de la création, la rend explicite et ferme trois portes : la liste de destruction, le repli par NOM de detruire_etage1, et le retrait des entrées ~/.ssh/config de l'utilisateur. Quatrième porte : le dossier des rapports est partagé. « deep_qemu --detruire » aurait pris le rapport le plus récent, fût-il celui d'une descente Proxmox. Le rapport porte son outil ; un rapport plus ancien, qui n'en a pas, est placé par le préfixe de son nom de fichier plutôt que d'être rendu indéfaisable. detruire_etage1 ne devine plus le nom de sa cible : il est obligatoire. Et une sixième étape est née — « cet étage peut-il héberger le suivant ? » — parce que le contrôle du stockage était celui du DÉBUT de l'étage suivant. 64 tests, les cinq garde-fous morts sous mutation. Au passage : la classe LongTestMenuMixin, que mon renommage de répertoire avait rebaptisée long_testMenuMixin sans qu'aucun test le voie. --- EN --- deep_proxmox.py drops from 1245 to 474 lines: everything that knows neither "qm" nor pmxcfs now lives in descente.py, ready for a second long test. The extraction laid bare what protected a host we did not create: nothing. a_defaire required "vmid" and "parent_alias", two keys only a descent writes — the protection held because no field described a borrowed host. A "cree" field, written the instant a machine is created, makes it explicit and closes three doors: the destroy list, detruire_etage1's fallback to the NAME, and the removal of the user's own ~/.ssh/config entries. Fourth door: the report directory is shared. "deep_qemu --detruire" would have taken the most recent report, Proxmox's included. Reports now carry their tool; an older one without it is placed by its filename prefix rather than made undestroyable. detruire_etage1 no longer guesses its target's name: it is mandatory. And a sixth step is born — "can this level host the next?" — because the storage check was the one at the START of the next level. 64 tests, all five guards die under mutation. Along the way: the class LongTestMenuMixin, which my directory rename had turned into long_testMenuMixin without any test noticing. Assisted-by: claude-opus-5 (cherry picked from commit e1bc9ae3cfacd36a502bc88dc0789a4e86ce986b) --- long_test/deep_proxmox.py | 973 ++++--------------------------- long_test/descente.py | 1039 ++++++++++++++++++++++++++++++++++ script/todo/longtest_menu.py | 2 +- script/todo/todo.py | 4 +- test/test_todo_longtest.py | 315 +++++++++-- 5 files changed, 1410 insertions(+), 923 deletions(-) create mode 100644 long_test/descente.py diff --git a/long_test/deep_proxmox.py b/long_test/deep_proxmox.py index bad92b6..3569855 100755 --- a/long_test/deep_proxmox.py +++ b/long_test/deep_proxmox.py @@ -7,26 +7,25 @@ Ce n'est pas un test unitaire : il crée de vraies machines et prend des HEURES. Il vit donc hors de `test/`, que le lanceur unitaire balaie. Ce qu'il établit, et pourquoi cela valait un script : la profondeur -d'imbrication praticable ne se déduit pas, elle se mesure. Une mesure à la -main a montré, au quatrième étage, un invité 36 fois plus lent que le temps -réel — 583 secondes d'horloge pour 16 secondes de temps invité — puis un noyau -gelé au MÊME octet quelles que soient les ressources. Un chiffre obtenu une -fois, sur une machine, n'est pas un chiffre : ce script le refait à la demande -et dit exactement OÙ ça casse. +d'imbrication praticable ne se déduit pas, elle se mesure. Mesuré ici, sur une +machine à 28 cœurs : trois étages coûtent 34 minutes, et le quatrième 4 h 20 +d'amorçage plus 7 h 18 d'installation. Tout y est 15 à 30 fois plus lent — et +c'est là que les fabricants cessent de documenter l'imbrication. -La descente est UNIFORME. Chaque étage, le premier compris, passe par les -mêmes six étapes : créer, attendre le ssh, installer Proxmox, redémarrer et -vérifier le noyau, remettre pmxcfs debout, contrôler le stockage. Seule la -création diffère — libvirt en local, « qm » ensuite. +La descente et ce qu'elle sait sont dans `descente.py`, partagés avec +`deep_qemu.py`. Ce fichier-ci n'a que les VERBES de Proxmox : « qm create » +chez le parent, install_proxmox.sh, le noyau -pve, pmxcfs debout, un stockage +capable d'accueillir l'étage suivant. Il envoie NOTRE install_proxmox.sh par scp au lieu de laisser la VM cloner le dépôt : c'est notre code qu'on veut éprouver, et le dépôt distant est souvent en retard sur le checkout — un correctif absent du distant a fait « revenir » le même défaut sur trois VM de suite. - ./long_test/deep_proxmox.py --depth 10 --dry-run - ./long_test/deep_proxmox.py --depth 10 - ./long_test/deep_proxmox.py --detruire # défait ce que la descente a posé + ./long_test/deep_proxmox.py # trois étages, ~34 minutes + ./long_test/deep_proxmox.py --depth 5 # en demander plus, sciemment + ./long_test/deep_proxmox.py --dry-run # le plan, rien de créé + ./long_test/deep_proxmox.py --detruire # défaire ce qui a été posé """ import argparse @@ -40,197 +39,63 @@ import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) +sys.path.insert(0, os.path.join(RACINE, "long_test")) from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 +import descente # noqa: E402 +from descente import ( # noqa: E402,F401 + DELAIS, + _lance_une_descente, + Famille, + a_defaire, + alias_etage as _alias_etage, + autre_descente, + capacite_hote, + cle_publique, + dernier_rapport, + descente_vivante, + detruire, + detruire_etage1, + dire, + identite_de, + module_qemu, + nom_etage as _nom_etage, + retirer_alias, +) + # L'image des étages imbriqués. Debian parce que install_proxmox.sh s'installe # SUR une Debian — Proxmox ne publie pas d'image cloud. DISTRO = "proxmox" NOM_BASE = "deep-pve" - -# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le -# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent -# que le précédent — c'est précisément ce qu'on mesure. -DELAIS = { - "creation": 1200, - "ssh": 2400, - "install": 7200, - "reboot": 2400, - "reparation": 600, - "controle": 180, -} - - -def dire(msg, journal=None): - ligne = f"[{time.strftime('%H:%M:%S')}] {msg}" - print(ligne, flush=True) - if journal: - with open(journal, "a", encoding="utf-8") as fh: - fh.write(ligne + "\n") - - -def capacite_hote(): - """(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle. - - « available » et non « free » : c'est ce que le noyau promet de rendre sans - mettre la machine à genoux. - """ - coeurs = os.cpu_count() or 2 - ram = 0 - try: - with open("/proc/meminfo", encoding="utf-8") as fh: - for ligne in fh: - if ligne.startswith("MemAvailable:"): - ram = int(ligne.split()[1]) // 1024 - break - except OSError: - pass - disque = 0 - try: - st = os.statvfs("/var/lib/libvirt/images") - disque = (st.f_bavail * st.f_frsize) // (1024**3) - except OSError: - pass - return coeurs, ram, disque - - -def module_qemu(): - """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" - import importlib.util - - chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py") - spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) - mod = importlib.util.module_from_spec(spec) - spec.loader.exec_module(mod) - return mod - - -def cle_publique(): - for nom in ("id_ed25519.pub", "id_rsa.pub"): - chemin = os.path.expanduser(f"~/.ssh/{nom}") - if os.path.exists(chemin): - return chemin - return "" +OUTIL = "deep_proxmox" def nom_etage(niveau): - return f"{NOM_BASE}-{niveau}" + return _nom_etage(niveau, NOM_BASE) def alias_etage(niveau, parent_alias): - """« parent+enfant », la convention du dépôt : elle dit où la machine vit - et ne peut rien voler à un homonyme.""" - if not parent_alias: - return nom_etage(niveau) - court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias) - return f"{court}+{nom_etage(niveau)}" + return _alias_etage(niveau, parent_alias, NOM_BASE) -class Descente: - """Un étage après l'autre, et ce qu'on en sait.""" +class Descente(descente.Descente): + """Les verbes de Proxmox. Le reste est dans `descente.Descente`.""" - def __init__(self, plan, journal, dry_run=False, chemin_json=None): - self.plan = plan - self.journal = journal - self.chemin_json = chemin_json - self.dry_run = dry_run - self.etages = [] - self.interrompu = False - self.niveau_courant = 1 + OUTIL = OUTIL + NOM_BASE = NOM_BASE + DISTRO = DISTRO - def dire(self, msg): - dire(msg, self.journal) + def noyau_convient(self, noyau): + """Le noyau Proxmox, et pas celui de Debian. - def delai(self, etape): - """Le délai de cette étape, à l'étage courant. - - Constant, il contredisait la raison d'être du script : au quatrième - étage un invité tournait 36 fois moins vite. Une installation de dix - minutes au premier étage en demande des heures au quatrième, et le - plafond fixe la déclarait échouée — en concluant à un mur - d'imbrication là où il n'y avait qu'un délai trop court. - - Le facteur est CARRÉ et borné : chaque étage ajoute une couche - d'hyperviseur à traverser, mais un facteur illimité rendrait un - échec réel indiscernable d'une attente sans fin. + Sans lui la machine reste sur le noyau cloud, dépouillé de tout + netfilter : ni pont NAT, ni invité. """ - facteur = min(max(1, self.niveau_courant), 5) ** 2 - return DELAIS[etape] * facteur + return "-pve" in noyau - # ---------------------------------------------------------------- # - # Parler aux machines - # ---------------------------------------------------------------- # - def executer(self, hote, remote, delai, etiquette, montrer=False): - if self.dry_run: - argv = pve.ssh_argv( - hote, pve.wrap_privilege(remote, hote.get("sudo") or "") - ) - print(" " + " ".join(shlex.quote(a) for a in argv)[:200]) - return 0, "" - debut = time.time() - code, sortie = pve.run(hote, remote, delai) - if code or montrer: - self.dire( - f" {etiquette} : code {code}" - f" en {int(time.time() - debut)} s" - ) - if code: - for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]: - self.dire(f" {ligne}") - return code, sortie - - def attendre_ssh(self, hote, delai, parent=None): - """Attend que la machine réponde. Rend les secondes, ou None. - - Des connexions COURTES successives : cloud-init régénère les clés - d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une - session longue. - - `parent` : si l'hôte qui HÉBERGE la machine attendue cesse de - répondre, on abandonne tout de suite. Constaté : l'étage 1 a redémarré - pendant l'installation de l'étage 4, ce qui a éteint les étages 2, 3 et - 4 d'un coup ; la descente a attendu son délai entier — quarante - minutes — un ssh qui ne pouvait plus aboutir, puis a rendu « jamais - joignable en ssh ». Le diagnostic était faux : la machine n'était pas - lente, sa MAISON n'existait plus. - """ - if self.dry_run: - return 0 - debut = time.time() - # SANS privilège : wrap_privilege transformerait « true » en - # « sudo sh -c true », et un sudo qui réclame un mot de passe — le - # temps que cloud-init écrive /etc/sudoers.d — se lisait « jamais - # joignable en ssh ». Le transport marchait ; c'est le diagnostic qui - # était faux. - sonde = dict(hote, sudo="") - sonde_parent = dict(parent, sudo="") if parent else None - while time.time() - debut < delai: - code, _o = pve.run(sonde, "true", 60) - if code == 0: - return int(time.time() - debut) - if sonde_parent is not None: - code_parent, _p = pve.run(sonde_parent, "true", 60) - if code_parent != 0: - self.dire( - f" ✗ l'hôte {sonde_parent['target']} ne répond" - " plus : l'attente n'aboutira pas" - ) - return None - time.sleep(15) - return None - - def sudo_pret(self, hote): - """sudo répond-il sans mot de passe ? Nommé à part de ssh.""" - if self.dry_run: - return True - code, _o = pve.run(hote, "true", 60) - return code == 0 - - # ---------------------------------------------------------------- # - # Les six étapes, les mêmes à chaque étage - # ---------------------------------------------------------------- # - def installer_proxmox(self, hote): + def installer(self, hote): """Envoie NOTRE script et l'exécute. Rend True si Proxmox est posé.""" local = os.path.join(RACINE, "script/proxmox/install_proxmox.sh") distant = "/tmp/install_proxmox.sh" @@ -263,97 +128,6 @@ class Descente: ) return code == 0 - def redemarrer_et_verifier(self, hote): - """Redémarre, attend le retour, exige le noyau Proxmox. - - Le script pose le noyau sans redémarrer — lancé par ssh, un reboot - couperait sa session et ferait passer l'installation pour un échec. - Sans ce redémarrage, la machine reste sur le noyau cloud de Debian, - dépouillé de tout netfilter : ni pont NAT, ni invité. - """ - if self.dry_run: - print(" reboot, puis btime changé ET *-pve dans uname -r") - return True - # L'instant de démarrage AVANT : le noyau seul ne prouve rien. Rejoué - # sur un étage déjà installé, le script est idempotent et ne redémarre - # pas ; vingt secondes après l'ordre, sshd répond encore et la machine - # tourne DÉJÀ sur -pve. On validait donc un redémarrage qui n'avait pas - # eu lieu, et l'étape suivante tombait sur une machine en train de - # s'éteindre — avec un diagnostic sans rapport. Même piège que celui - # corrigé dans le suivi d'installation, refait ici. - _c, out = pve.run(dict(hote, sudo=""), "stat -c %Y /proc/1", 60) - avant = pve.strip_ssh_noise(out).strip() - pve.run(hote, "systemctl reboot", 60) - debut = time.time() - while time.time() - debut < self.delai("reboot"): - time.sleep(20) - code, out = pve.run( - dict(hote, sudo=""), "uname -r; stat -c %Y /proc/1", 60 - ) - lignes = pve.strip_ssh_noise(out).strip().splitlines() - if code or len(lignes) < 2: - continue - noyau, apres = lignes[0].strip(), lignes[-1].strip() - if "-pve" not in noyau: - continue - if avant and apres == avant: - continue # elle n'a pas encore redémarré - self.dire( - f" noyau {noyau} après {int(time.time() - debut)} s" - ) - return True - self.dire(" ✗ pas revenue sur un noyau -pve") - return False - - def reparer_pmxcfs(self, hote): - """Gel de cloud-init, /etc/hosts, unités, constat du montage.""" - if self.dry_run: - print(" gel cloud-init + /etc/hosts + unités + montage") - return True - _c, out = pve.run( - dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30 - ) - ip = pve.ssh_server_ip(out) - if not ip: - self.dire(" ✗ adresse d'accès inconnue") - return False - for cmd, etiquette in ( - (pve.cloud_hosts_freeze_cmd(), "gel cloud-init"), - (pve.hosts_repair_cmd(ip), "/etc/hosts"), - ): - code, sortie = self.executer( - hote, cmd, self.delai("reparation"), etiquette - ) - 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: - 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. @@ -435,71 +209,6 @@ class Descente: pve.parse_nameservers(resolv), ) - def creer_etage1(self, res): - """Une VM locale, par la CLI QEMU/KVM.""" - nom = nom_etage(1) - argv = [ - os.path.join(RACINE, ".venv.erplibre/bin/python"), - os.path.join(RACINE, "script/qemu/deploy_qemu.py"), - "--distro", - DISTRO, - "--name", - nom, - "--vcpus", - str(res["vcpu"]), - "--memory", - str(res["ram"]), - "--disk-size", - f"{res['disque']}G", - ] - pub = cle_publique() - if pub: - argv += ["--ssh-key", pub] - if self.dry_run: - print(" " + " ".join(shlex.quote(a) for a in argv)) - return nom - res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3) - 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 - - @staticmethod - def uuid_libvirt(nom): - """L'UUID du domaine `nom`, ou "". C'est lui qui l'identifie. - - Un nom se réutilise ; un UUID non. Sans lui, « --detruire » effaçait - « deep-pve-1 » quel qu'il soit — la VM d'une descente précédente qu'on - voulait garder, ou une machine sans rapport qui porte ce nom. - """ - try: - res = subprocess.run( - ["sudo", "-n", "virsh", "domuuid", nom], - capture_output=True, - text=True, - timeout=60, - ) - except (OSError, subprocess.SubprocessError): - return "" - return "" if res.returncode else res.stdout.strip() - def creer_enfant(self, parent, niveau, res, prepare, noter=None): """« qm create » sur le parent. Rend (vmid, adresse) ou (None, None). @@ -583,343 +292,64 @@ class Descente: # ---------------------------------------------------------------- # # La descente - # ---------------------------------------------------------------- # - def parcourir(self): - parent = None - parent_alias = "" - for res in self.plan["niveaux"]: - niveau = res["niveau"] - self.niveau_courant = niveau - debut = time.time() - etage = { - "niveau": niveau, - "ressources": res, - "etape": "creation", - "ok": False, - } - self.dire( - f" ── étage {niveau} : {res['vcpu']} vCPU," - f" {res['ram']} Mo, {res['disque']} Go" - ) - if niveau == 1: - nom = self.creer_etage1(res) - if not nom: - self.etages.append(etage) - self.interrompu = True - break - alias = nom - etage["nom"] = nom - # L'UUID, et non le nom : c'est de lui que « --detruire » se - # servira. Un nom se réutilise, un UUID non. - etage["uuid"] = self.uuid_libvirt(nom) - # Le domaine libvirt existe : le rapport doit exister aussi. - self._sauver(etage) - else: - prepare = self.preparer_parent(parent) - if not prepare: - etage["etape"] = "parent" - self.etages.append(etage) - self.interrompu = True - break - - def noter( - numero, - etage=etage, - parent_alias=parent_alias, - niveau=niveau, - ): - etage["vmid"] = numero - etage["parent_alias"] = parent_alias - # Le nom est ÉCRIT, non déduit du numéro d'étage à la - # relecture : si nom_etage change un jour, un rapport - # ancien désignerait des machines qui ne sont pas les - # siennes. - etage["nom"] = nom_etage(niveau) - self._sauver(etage) - - vmid, adresse = self.creer_enfant( - parent, niveau, res, prepare, noter - ) - if vmid is None: - self.etages.append(etage) - self.interrompu = True - break - etage["vmid"] = vmid - # Le parent est noté AVANT tout autre contrôle : c'est le seul - # enregistrement de ce qu'on vient de créer, et --detruire s'en - # sert. Sans lui, une VM abandonnée juste après « qm create » - # n'était nommée nulle part. - etage["parent_alias"] = parent_alias - self._sauver(etage) - alias = alias_etage(niveau, parent_alias) - if not self.dry_run: - self.ecrire_alias(alias, adresse, parent_alias) - cible = {"target": alias, "sudo": "sudo ", "jump": ""} - etage["alias"] = alias - - etage["etape"] = "ssh" - attente = self.attendre_ssh(cible, self.delai("ssh"), parent) - if attente is None: - self.dire(" ✗ jamais joignable en ssh") - self.etages.append(etage) - self.interrompu = True - break - etage["ssh_secondes"] = attente - self.dire(f" ssh après {attente} s") - - for etape, action in ( - ("install", lambda: self.installer_proxmox(cible)), - ("reboot", lambda: self.redemarrer_et_verifier(cible)), - ("pmxcfs", lambda: self.reparer_pmxcfs(cible)), - ): - etage["etape"] = etape - self._sauver(etage) - if not action(): - self.etages.append(etage) - return self.rapport(interrompu=True) - - # En dry-run, aucune étape n'a été mesurée : les marquer - # « atteintes » produisait un rapport indiscernable d'une vraie - # réussite, JSON compris, et un code de sortie 0. - etage["etape"] = "plan" if self.dry_run else "termine" - etage["ok"] = not self.dry_run - etage["secondes"] = int(time.time() - debut) - self.etages.append(etage) - self._sauver() - self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") - parent, parent_alias = cible, alias - return self.rapport(interrompu=self.interrompu) - - def ecrire_alias(self, alias, adresse, parent_alias): - """Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent.""" - from script.todo.todo import TODO - - todo = TODO.__new__(TODO) - prive = cle_publique()[:-4] if cle_publique() else None - todo._write_ssh_config_entry( - [alias], - "erplibre", - adresse, - proxy_jump=parent_alias or None, - identity_file=prive, - ) - - def _etat(self, interrompu, en_cours=None): - """Le rapport, à cet instant. `en_cours` : l'étage pas encore rangé.""" - etages = list(self.etages) - if en_cours is not None and en_cours not in etages: - etages.append(en_cours) - return { - "demandee": self.plan["demandee"], - "atteignable": self.plan["atteignable"], - "atteinte": sum(1 for e in etages if e.get("ok")), - "interrompu": interrompu, - # Sans ce champ, un rapport d'essai à blanc se lisait comme une - # descente réussie — et « --detruire » s'en servait. - "dry_run": self.dry_run, - "etages": etages, - } - - def _sauver(self, en_cours=None): - """Écrit le rapport PARTIEL, dès qu'une VM existe. - - Il ne s'écrivait qu'à la fin. Une descente tuée au quatrième étage — - c'est arrivé — laissait quatre machines réelles et « --detruire » - répondait « aucun rapport : rien à défaire » : le seul enregistrement - du couple (alias du parent, VMID) mourait avec le processus. Il fallait - alors les retrouver et les détruire à la main, c'est-à-dire par leur - nom, ce que tout le reste de ce fichier s'applique à ne pas faire. - - Marqué « interrompu » jusqu'au bout : un rapport partiel ne doit jamais - se lire comme une descente terminée. - """ - if self.dry_run or not self.chemin_json: - return - temporaire = self.chemin_json + ".tmp" - try: - etat = self._etat(interrompu=True, en_cours=en_cours) - # Le PID de la descente qui écrit : c'est ce qui distingue un - # rapport ABANDONNÉ d'un rapport en cours d'écriture. Le rapport - # final, lui, n'en porte pas — la descente est finie. - etat["pid"] = os.getpid() - with open(temporaire, "w", encoding="utf-8") as fh: - json.dump(etat, fh, indent=2) - os.replace(temporaire, self.chemin_json) - except OSError as err: - self.dire(f" ⚠ rapport non écrit : {err}") - - def rapport(self, interrompu=False): - etat = self._etat(interrompu) - atteint = etat["atteinte"] - print("") + def remettre_debout(self, hote): + """Les unités PVE, puis le CONSTAT que /etc/pve est monté.""" if self.dry_run: - self.dire( - f" plan annoncé sur {len(self.etages)} étage(s) —" - " rien n'a été créé" + print(" unités PVE + montage de /etc/pve") + return True + # « 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: + code, sortie = self.executer( + hote, pve.pve_unit_cmd(unite, remonte=True), 300, unite ) - else: - self.dire( - f" profondeur atteinte : {atteint}" - f" / {self.plan['demandee']}" - ) - # Deux causes très différentes rendaient le même « 5 / 10 » : la - # machine trop petite pour dix, ou un étage tombé en route. La - # première n'est pas un défaut du code, la seconde si. - if atteint == self.plan["atteignable"] < self.plan["demandee"]: - self.dire( - f" (plan borné à {self.plan['atteignable']} par le" - f" {self.plan['arret']} : tout le plan a tenu)" - ) - elif atteint < self.plan["atteignable"]: - self.dire( - f" (le plan annonçait {self.plan['atteignable']} :" - " un étage est tombé, voir plus haut)" - ) - for e in self.etages: - if self.dry_run: - marque, detail = "·", "plan" - else: - marque = "✓" if e["ok"] else "✗" - detail = ( - f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] - ) - self.dire(f" {marque} étage {e['niveau']:2d} {detail}") - return etat - - -def _lance_ce_script(pid): - """`pid` exécute-t-il CE script — et non pas seulement le nomme-t-il ? - - Par ARGUMENT, jamais par sous-chaîne. Constaté sur cette machine : un - « pgrep -f deep_proxmox.py » posé dans une boucle de surveillance donne un - shell dont la ligne de commande contient le motif, et le contrôle comptait - ce shell comme une descente — deux faux positifs sur trois. Un argument - qui SE TERMINE par le nom du fichier, lui, ne peut venir que d'un - interpréteur qu'on a lancé dessus. - """ - try: - with open(f"/proc/{int(pid)}/cmdline", "rb") as fh: - arguments = fh.read().split(b"\0") - except (OSError, ValueError): - return False - return any(a.endswith(b"deep_proxmox.py") for a in arguments) - - -def descente_vivante(pid): - """Le processus `pid` est-il une descente EN COURS ? - - Le PID seul ne suffirait pas : les numéros se réutilisent, et rien ne dit - qu'un rapport vieux d'une semaine ne porte pas le PID d'un shell - d'aujourd'hui. La ligne de commande est donc lue aussi. - """ - return bool(pid) and _lance_ce_script(pid) - - -def autre_deep_proxmox(): - """Les PID des AUTRES deep_proxmox.py vivants. Le sien est exclu. - - Le garde-fou du rapport — un PID dans le fichier — ne protège que les - descentes lancées APRÈS son écriture : celle qui tournait déjà avait - chargé l'ancien module en mémoire et n'écrira jamais de PID. Constaté sur - une descente réelle de dix étages, à l'étage 4. Ce contrôle-ci ne dépend - d'aucun rapport : détruire pendant qu'une descente tourne n'est jamais - juste, quel que soit le rapport choisi. - - /proc plutôt que pgrep : « pgrep -f deep_proxmox » attrape le shell qui - l'invoque, et on croit alors voir survivre un processus qui n'existe pas. - """ - moi = os.getpid() - vivants = [] - try: - entrees = os.listdir("/proc") - except OSError: - return vivants - for entree in entrees: - if not entree.isdigit() or int(entree) == moi: - continue - if _lance_ce_script(entree): - vivants.append(int(entree)) - return vivants - - -def dernier_rapport(): - """Le rapport le plus récent qui NOMME quelque chose à défaire, ou {}. - - C'est le SEUL enregistrement de ce que la descente a créé : un couple - (alias du parent, VMID) par étage. Détruire d'après lui, et non d'après - les noms, est toute la différence entre défaire son propre travail et - effacer une machine qui se trouve porter un nom voisin. - - Deux rapports sont ÉCARTÉS, et chacun l'est pour un accident précis : - - * celui d'une descente VIVANTE. Depuis que le rapport s'écrit VM par VM, - la descente en cours en a un sur le disque, et c'est le plus récent : - « --detruire » aurait détruit l'arbre sous le processus qui installait - encore, emportant des heures de mesure. Avant, la descente en cours - n'avait aucun rapport et la question ne se posait pas — le correctif a - créé le danger. - - * celui qui n'a RIEN créé. Un second lancement qui meurt à l'étage 1 — - « le disque existe déjà » — écrit un rapport vide sous un horodatage - plus tardif. Il masquait le partiel qui nommait les VM réelles : - « 0 VM imbriquée(s) », puis « virsh undefine --remove-all-storage » sur - l'étage 1, dont le disque contient les étages 2 et suivants — jamais - arrêtés, jamais nommés. - """ - dossier = os.path.expanduser("~/.erplibre/longtest") - try: - fichiers = sorted( - f for f in os.listdir(dossier) if f.endswith(".json") + 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" ) - except OSError: - return {} - for nom in reversed(fichiers): - chemin = os.path.join(dossier, nom) - try: - with open(chemin, encoding="utf-8") as fh: - rapport = json.load(fh) - except (OSError, ValueError): - continue - if rapport.get("dry_run"): - continue # un plan n'a rien créé - if descente_vivante(rapport.get("pid")): - dire(f" ⏳ descente EN COURS ({rapport['pid']}) : {nom} ignoré") - continue - if not (rapport.get("etages") or []): - continue # rien créé : ne pas masquer un rapport qui nomme des VM - rapport["fichier"] = chemin - return rapport - return {} + 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 controler(self, hote): + """Ce parent peut-il héberger l'étage suivant ? -def a_defaire(rapport): - """[(niveau, parent_alias, vmid, nom)] du plus PROFOND au plus haut. - - Trié sur le niveau LU dans le rapport, pas déduit du nom. La version - d'avant comptait les « + » de l'alias — or `alias_etage` remplace le « + » - du parent par un « - », donc chaque alias en portait exactement UN et le - tri ne triait rien. La destruction partait du plus HAUT : « qm destroy - --purge » sur l'étage 2 emportait le disque contenant les étages 3 et - suivants, sans les avoir arrêtés ni nommés. - """ - etages = [ - e - for e in (rapport.get("etages") or []) - if e.get("vmid") and e.get("parent_alias") - ] - etages.sort(key=lambda e: -int(e["niveau"])) - return [ - ( - int(e["niveau"]), - e["parent_alias"], - int(e["vmid"]), - # Le nom ÉCRIT par la descente. Le déduire du numéro d'étage - # supposait que nom_etage ne changera jamais — un rapport ancien - # aurait alors nommé des machines qui ne sont pas les siennes. - e.get("nom") or nom_etage(int(e["niveau"])), + Sixième étape, et elle manquait : le contrôle du stockage était celui + du DÉBUT de l'étage suivant, si bien qu'un étage marqué « terminé » + pouvait n'avoir aucun stockage capable d'accueillir une image — et le + compteur d'étages atteints mentait d'autant. + """ + if self.dry_run: + print(" pvesm status : un stockage pour les images") + return True + code, out = self.executer( + hote, + "pvesm status --content images", + DELAIS["controle"], + "pvesm", ) - for e in etages - ] + if code: + self.dire(" ✗ « pvesm status » a échoué : rien conclu") + return False + stockage = pve.pick_storage(pve.parse_storages(out)) + if not stockage: + self.dire(" ✗ aucun stockage pour les images") + return False + self.dire(f" stockage : {stockage}") + return True def detruire_une(parent_alias, vmid, nom, journal): @@ -968,206 +398,7 @@ def detruire_une(parent_alias, vmid, nom, journal): return True -def detruire_etage1(journal, dry_run=False, attendu=None, nom=None): - """Le domaine libvirt du premier étage — le SEUL qui en soit un. - - La boucle d'avant tournait sur trente niveaux avec une condition morte, et - sa branche « niveau == 1 » était vraie même quand la descente n'avait - jamais rien créé : « virsh undefine --remove-all-storage » partait alors - sur un domaine qui pouvait être n'importe quoi, sortie capturée, sans un - mot. - - `attendu` : l'UUID que le rapport a noté à la création. C'est LUI qui - identifie la machine, pas son nom. Un nom se réutilise — la VM d'une - descente précédente qu'on voulait garder, ou une machine sans rapport qui - porte celui-là — et « --remove-all-storage » efface un disque pour de bon. - Un rapport ancien n'a pas d'UUID : on procède alors comme avant, par le - nom, faute de mieux, mais en le disant. - """ - nom = nom or nom_etage(1) - existe = subprocess.run( - ["sudo", "-n", "virsh", "dominfo", nom], - capture_output=True, - text=True, - ) - if existe.returncode: - dire(f" — {nom} : aucun domaine libvirt", journal) - return True - if attendu: - vu = Descente.uuid_libvirt(nom) - if vu != attendu: - dire( - f" ✗ {nom} : UUID {vu or '—'} au lieu de {attendu} —" - " ce n'est PAS notre machine, rien touché", - journal, - ) - return False - else: - dire( - f" ⚠ {nom} : rapport sans UUID, identifié par son NOM", journal - ) - if dry_run: - dire( - f" [à blanc] virsh undefine {nom} --remove-all-storage", journal - ) - return True - subprocess.run( - ["sudo", "virsh", "destroy", nom], capture_output=True, text=True - ) - res = subprocess.run( - [ - "sudo", - "virsh", - "undefine", - nom, - "--nvram", - "--remove-all-storage", - ], - capture_output=True, - text=True, - ) - if res.returncode: - dire( - f" ✗ virsh undefine {nom} : {res.stderr.strip()[:160]}", journal - ) - return False - dire(f" ✓ {nom} (libvirt)", journal) - return True - - -def detruire(journal=None, dry_run=False): - """Défait ce que le DERNIER rapport dit avoir créé, du plus profond. - - Rien d'autre. La version d'avant prenait toute entrée ~/.ssh/config dont - le nom contenait « deep-pve », puis sur son rebond détruisait toute VM - dont le nom contenait « deep-pve » — une machine de labo appelée - « deep-pve-lab » sur un hyperviseur de production tombait dedans. - """ - # Avant tout : refuser tant qu'une descente tourne. Elle installe encore - # sur les machines qu'on s'apprête à détruire, et son rapport peut être - # celui qu'on vient de choisir. - autres = autre_deep_proxmox() - if autres: - dire( - f" ⛔ une descente tourne ({', '.join(map(str, autres))}) :" - " rien ne sera détruit.", - journal, - ) - dire(" Attendre qu'elle finisse, ou l'arrêter d'abord.", journal) - return 1 - rapport = dernier_rapport() - if not rapport: - dire(" aucun rapport de descente : rien à défaire.", journal) - dire( - " (les entrées ~/.ssh/config orphelines : menu de nettoyage)", - journal, - ) - return 0 - liste = a_defaire(rapport) - dire(f" rapport : {rapport.get('fichier')}", journal) - dire(f" {len(liste)} VM imbriquée(s) + l'étage 1 :", journal) - for niveau, parent_alias, vmid, nom in liste: - dire( - f" étage {niveau:2d} {nom} ({vmid}) sur {parent_alias}", - journal, - ) - etage1_nom = next( - ( - e.get("nom") - for e in (rapport.get("etages") or []) - if int(e.get("niveau", 0)) == 1 - ), - None, - ) - dire(f" étage 1 {etage1_nom or nom_etage(1)} (libvirt)", journal) - if dry_run: - dire("\n --dry-run : rien ne sera détruit.", journal) - return 0 - # Une confirmation, parce que « --purge » emporte les disques et que le - # menu lançait cette option d'une seule touche. - reponse = input("\n Détruire tout cela ? (tapez OUI) : ").strip() - if reponse != "OUI": - dire(" annulé.", journal) - return 1 - faits = sum( - 1 - for niveau, parent_alias, vmid, nom in liste - if detruire_une(parent_alias, vmid, nom, journal) - ) - # « if not … : faits -= 1 » : un succès de l'étage 1 n'ajoutait RIEN, - # alors que le total est len(liste) + 1. Le décompte était décalé de un - # dans TOUS les cas — une destruction complète annonçait « il reste des - # machines » et sortait 1, si bien que le seul avertissement censé - # prévenir qu'un disque de plusieurs dizaines de Go reste alloué - # s'affichait toujours, et qu'on apprenait à ne plus le lire. - etage1 = next( - ( - e - for e in (rapport.get("etages") or []) - if int(e.get("niveau", 0)) == 1 - ), - {}, - ) - racine = detruire_etage1( - journal, attendu=etage1.get("uuid"), nom=etage1.get("nom") - ) - if racine: - faits += 1 - retirer_alias(rapport, journal) - if racine: - # L'étage 1 est un DISQUE, et tout le reste vit dedans. « virsh - # undefine --remove-all-storage » l'a effacé : les étages injoignables - # — leur parent était éteint — ont disparu avec, qu'on ait pu leur - # parler ou non. Annoncer « il reste des machines » dans ce cas était - # faux dans l'autre sens, et un avertissement faux ne se lit plus. - reste = len(liste) + 1 - faits - dire( - f"\n {len(liste) + 1} / {len(liste) + 1} défait(s)." - + ( - f" ({reste} injoignable(s), emporté(s) avec le disque de" - " l'étage 1.)" - if reste - else "" - ), - journal, - ) - return 0 - dire( - f"\n {faits} / {len(liste) + 1} défait(s)." - " ⚠ l'étage 1 est DEBOUT : ce qu'il contient vit encore.", - journal, - ) - return 1 - - -def retirer_alias(rapport, journal=None): - """Retire de ~/.ssh/config les entrées de la descente défaite. - - Sans cela, elles survivaient aux machines : des entrées mortes dont le - ProxyJump désigne un hôte qui n'existe plus, et qu'on retrouve plus tard - sans savoir à quoi elles servaient. - """ - alias = [] - for etage in rapport.get("etages") or []: - nom = etage.get("alias") - if not nom: - # L'étage abandonné avant l'écriture de son alias : le calculer, - # il est déterminé par (niveau, alias du parent). - parent = etage.get("parent_alias") - if parent: - nom = alias_etage(int(etage["niveau"]), parent) - if nom and nom not in alias: - alias.append(nom) - if not alias: - return - try: - from script.todo.todo import TODO - - TODO.__new__(TODO)._write_ssh_config_entry( - [], "erplibre", "", also_drop=tuple(alias) - ) - except Exception as err: # noqa: BLE001 - jamais bloquer la destruction - dire(f" ⚠ entrées ~/.ssh/config non retirées : {err}", journal) +FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une) def principal(argv=None): @@ -1192,7 +423,7 @@ def principal(argv=None): if args.detruire: # « --dry-run » était ignoré ici : la prudence naturelle avant une # destruction détruisait pour de vrai. - return detruire(journal, dry_run=args.dry_run) + return detruire(FAMILLE, journal, dry_run=args.dry_run) coeurs, ram, disque = capacite_hote() print( diff --git a/long_test/descente.py b/long_test/descente.py new file mode 100644 index 0000000..7ea17fe --- /dev/null +++ b/long_test/descente.py @@ -0,0 +1,1039 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le moteur commun aux tests longs : descendre étage par étage. + +Ce module ne sait rien de Proxmox ni de libvirt. Il sait ce qui est vrai de +TOUTE descente imbriquée, et qui a coûté cher à apprendre : + +* un étage doit être inscrit au rapport à l'instant où sa machine existe, pas + au retour de la fonction qui la crée ; +* le délai de chaque étape croît avec la profondeur, parce que c'est + exactement ce qu'on mesure ; +* attendre un enfant dont le PARENT ne répond plus est une attente perdue ; +* on ne détruit jamais d'après un nom, et jamais ce qu'on n'a pas créé. + +Chaque pile fournit ses VERBES en héritant de `Descente` : comment créer un +enfant, comment installer, ce que « le noyau convient » veut dire, comment +remettre les services debout, comment contrôler qu'un étage peut héberger le +suivant. Le reste est ici, écrit une fois. +""" + +import json +import os +import re +import shlex +import subprocess +import sys +import time + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) + +from script.proxmox import proxmox_deploy as pve # noqa: E402 + +# Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux +# descentes de piles différentes se disputeraient la RAM, le disque et +# ~/.ssh/config aussi sûrement que deux de la même. +SCRIPTS = ("deep_proxmox.py", "deep_qemu.py") + +# Une étape bloquée ne doit pas bloquer le test : chaque appel est borné, et le +# journal dit lequel a expiré. Généreux, parce que chaque étage est plus lent +# que le précédent — c'est précisément ce qu'on mesure. +DELAIS = { + "creation": 1200, + "ssh": 2400, + "install": 7200, + "reboot": 2400, + "reparation": 600, + "controle": 180, +} + + +def dire(msg, journal=None): + ligne = f"[{time.strftime('%H:%M:%S')}] {msg}" + print(ligne, flush=True) + if journal: + with open(journal, "a", encoding="utf-8") as fh: + fh.write(ligne + "\n") + + +def capacite_hote(): + """(cœurs, RAM disponible en Mo, disque libre en Go) de la machine réelle. + + « available » et non « free » : c'est ce que le noyau promet de rendre sans + mettre la machine à genoux. + """ + coeurs = os.cpu_count() or 2 + ram = 0 + try: + with open("/proc/meminfo", encoding="utf-8") as fh: + for ligne in fh: + if ligne.startswith("MemAvailable:"): + ram = int(ligne.split()[1]) // 1024 + break + except OSError: + pass + disque = 0 + try: + st = os.statvfs("/var/lib/libvirt/images") + disque = (st.f_bavail * st.f_frsize) // (1024**3) + except OSError: + pass + return coeurs, ram, disque + + +def module_qemu(): + """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" + import importlib.util + + chemin = os.path.join(RACINE, "script/qemu/deploy_qemu.py") + spec = importlib.util.spec_from_file_location("deploy_qemu", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def cle_publique(): + for nom in ("id_ed25519.pub", "id_rsa.pub"): + chemin = os.path.expanduser(f"~/.ssh/{nom}") + if os.path.exists(chemin): + return chemin + return "" + + +def nom_etage(niveau, base): + return f"{base}-{niveau}" + + +def alias_etage(niveau, parent_alias, base): + """L'alias ssh de l'étage : celui du parent, puis le sien. + + Chaîné, parce qu'un nom seul ne dit pas PAR OÙ passer : deux descentes + peuvent avoir un « -2 », et OpenSSH doit savoir de quel parent il rebondit. + """ + court = re.sub(r"[^A-Za-z0-9._-]", "-", parent_alias) + return f"{court}+{nom_etage(niveau, base)}" + + +class Descente: + """Un étage après l'autre, et ce qu'on en sait. + + Classe de BASE : elle mène la descente, tient le rapport et refuse de + détruire ce qu'elle n'a pas créé. Ce qu'elle ne sait pas faire, elle le + demande à la pile qui en hérite — les six crochets plus bas. + """ + + # Ce que chaque pile déclare. + OUTIL = "" # « deep_proxmox » : écrit au rapport, filtre --detruire + NOM_BASE = "" # « deep-pve » : préfixe des noms de machines + DISTRO = "" # la clé du catalogue d'images de deploy_qemu + + # ------------------------------------------------------------------ # + # Les crochets. Chacun rend True quand l'étape a été CONSTATÉE. + # ------------------------------------------------------------------ # + def preparer_parent(self, parent): + """Ce qu'il faut du parent pour créer chez lui, ou None.""" + raise NotImplementedError + + def creer_enfant(self, parent, niveau, res, prepare, noter=None): + """Rend (identité, adresse), ou (None, None). + + `noter` reçoit l'identité AVANT la première commande qui peut créer + la machine — sinon une création qui échoue à mi-chemin laisse une VM + que le rapport ne nomme nulle part. + """ + raise NotImplementedError + + def installer(self, hote): + """Pose la pile sur l'étage.""" + raise NotImplementedError + + def noyau_convient(self, noyau): + """`uname -r` annonce-t-il le noyau qu'on attend ?""" + raise NotImplementedError + + def remettre_debout(self, hote): + """Les services de la pile répondent-ils, une fois redémarrés ?""" + raise NotImplementedError + + def controler(self, hote): + """Cet étage peut-il HÉBERGER le suivant ? + + Sixième étape, et elle n'existait pas : le contrôle du stockage était + celui du DÉBUT de l'étage suivant, si bien qu'un étage marqué + « terminé » pouvait être incapable d'héberger quoi que ce soit — et le + compteur d'étages atteints mentait d'autant. + """ + raise NotImplementedError + + def nom_etage(self, niveau): + return nom_etage(niveau, self.NOM_BASE) + + def alias_etage(self, niveau, parent_alias): + return alias_etage(niveau, parent_alias, self.NOM_BASE) + + def __init__(self, plan, journal, dry_run=False, chemin_json=None): + self.plan = plan + self.journal = journal + self.chemin_json = chemin_json + self.dry_run = dry_run + self.etages = [] + self.interrompu = False + self.niveau_courant = 1 + + def dire(self, msg): + dire(msg, self.journal) + + def delai(self, etape): + """Le délai de cette étape, à l'étage courant. + + Constant, il contredisait la raison d'être du script : au quatrième + étage un invité tournait 36 fois moins vite. Une installation de dix + minutes au premier étage en demande des heures au quatrième, et le + plafond fixe la déclarait échouée — en concluant à un mur + d'imbrication là où il n'y avait qu'un délai trop court. + + Le facteur est CARRÉ et borné : chaque étage ajoute une couche + d'hyperviseur à traverser, mais un facteur illimité rendrait un + échec réel indiscernable d'une attente sans fin. + """ + facteur = min(max(1, self.niveau_courant), 5) ** 2 + return DELAIS[etape] * facteur + + # ---------------------------------------------------------------- # + # Parler aux machines + # ---------------------------------------------------------------- # + def executer(self, hote, remote, delai, etiquette, montrer=False): + if self.dry_run: + argv = pve.ssh_argv( + hote, pve.wrap_privilege(remote, hote.get("sudo") or "") + ) + print(" " + " ".join(shlex.quote(a) for a in argv)[:200]) + return 0, "" + debut = time.time() + code, sortie = pve.run(hote, remote, delai) + if code or montrer: + self.dire( + f" {etiquette} : code {code}" + f" en {int(time.time() - debut)} s" + ) + if code: + for ligne in pve.strip_ssh_noise(sortie).strip().splitlines()[-5:]: + self.dire(f" {ligne}") + return code, sortie + + def attendre_ssh(self, hote, delai, parent=None): + """Attend que la machine réponde. Rend les secondes, ou None. + + Des connexions COURTES successives : cloud-init régénère les clés + d'hôte et redémarre sshd au premier démarrage, ce qui tuerait une + session longue. + + `parent` : si l'hôte qui HÉBERGE la machine attendue cesse de + répondre, on abandonne tout de suite. Constaté : l'étage 1 a redémarré + pendant l'installation de l'étage 4, ce qui a éteint les étages 2, 3 et + 4 d'un coup ; la descente a attendu son délai entier — quarante + minutes — un ssh qui ne pouvait plus aboutir, puis a rendu « jamais + joignable en ssh ». Le diagnostic était faux : la machine n'était pas + lente, sa MAISON n'existait plus. + """ + if self.dry_run: + return 0 + debut = time.time() + # SANS privilège : wrap_privilege transformerait « true » en + # « sudo sh -c true », et un sudo qui réclame un mot de passe — le + # temps que cloud-init écrive /etc/sudoers.d — se lisait « jamais + # joignable en ssh ». Le transport marchait ; c'est le diagnostic qui + # était faux. + sonde = dict(hote, sudo="") + sonde_parent = dict(parent, sudo="") if parent else None + while time.time() - debut < delai: + code, _o = pve.run(sonde, "true", 60) + if code == 0: + return int(time.time() - debut) + if sonde_parent is not None: + code_parent, _p = pve.run(sonde_parent, "true", 60) + if code_parent != 0: + self.dire( + f" ✗ l'hôte {sonde_parent['target']} ne répond" + " plus : l'attente n'aboutira pas" + ) + return None + time.sleep(15) + return None + + def redemarrer_et_verifier(self, hote): + """Redémarre, attend le retour, exige le noyau voulu. + + L'installation pose le noyau sans redémarrer — lancée par ssh, un + reboot couperait sa session et ferait passer l'installation pour un + échec. Sans ce redémarrage, la machine reste sur le noyau cloud de + Debian, dépouillé de tout netfilter : ni pont NAT, ni invité. + + Ce que « le bon noyau » veut dire appartient à la pile : + `noyau_convient`. + """ + if self.dry_run: + print(" reboot, puis btime changé ET le noyau attendu") + return True + # L'instant de démarrage AVANT : le noyau seul ne prouve rien. Rejoué + # sur un étage déjà installé, le script est idempotent et ne redémarre + # pas ; vingt secondes après l'ordre, sshd répond encore et la machine + # tourne DÉJÀ sur -pve. On validait donc un redémarrage qui n'avait pas + # eu lieu, et l'étape suivante tombait sur une machine en train de + # s'éteindre — avec un diagnostic sans rapport. Même piège que celui + # corrigé dans le suivi d'installation, refait ici. + _c, out = pve.run(dict(hote, sudo=""), "stat -c %Y /proc/1", 60) + avant = pve.strip_ssh_noise(out).strip() + pve.run(hote, "systemctl reboot", 60) + debut = time.time() + while time.time() - debut < self.delai("reboot"): + time.sleep(20) + code, out = pve.run( + dict(hote, sudo=""), "uname -r; stat -c %Y /proc/1", 60 + ) + lignes = pve.strip_ssh_noise(out).strip().splitlines() + if code or len(lignes) < 2: + continue + noyau, apres = lignes[0].strip(), lignes[-1].strip() + if not self.noyau_convient(noyau): + continue + if avant and apres == avant: + continue # elle n'a pas encore redémarré + self.dire( + f" noyau {noyau} après {int(time.time() - debut)} s" + ) + return True + self.dire(" ✗ pas revenue sur le noyau attendu") + return False + + def preparer_systeme(self, hote): + """Gel de cloud-init et réparation de /etc/hosts. Vrai pour TOUTE pile. + + `manage_etc_hosts: True` fait réécrire /etc/hosts à chaque démarrage : + le nom de la machine cesse de résoudre vers son adresse réelle, et les + services qui s'y fient tombent sans dire pourquoi. + """ + if self.dry_run: + print(" gel cloud-init + /etc/hosts") + return True + _c, out = pve.run( + dict(hote, sudo=""), 'printf %s "$SSH_CONNECTION"', 30 + ) + ip = pve.ssh_server_ip(out) + if not ip: + self.dire(" ✗ adresse d'accès inconnue") + return False + for cmd, etiquette in ( + (pve.cloud_hosts_freeze_cmd(), "gel cloud-init"), + (pve.hosts_repair_cmd(ip), "/etc/hosts"), + ): + code, sortie = self.executer( + hote, cmd, self.delai("reparation"), etiquette + ) + if code or "-KO" in pve.strip_ssh_noise(sortie): + self.dire(f" ✗ {etiquette}") + return False + + def creer_etage1(self, res): + """Une VM locale, par la CLI QEMU/KVM. Le seul étage sur du métal.""" + nom = self.nom_etage(1) + argv = [ + os.path.join(RACINE, ".venv.erplibre/bin/python"), + os.path.join(RACINE, "script/qemu/deploy_qemu.py"), + "--distro", + self.DISTRO, + "--name", + nom, + "--vcpus", + str(res["vcpu"]), + "--memory", + str(res["ram"]), + "--disk-size", + f"{res['disque']}G", + ] + pub = cle_publique() + if pub: + argv += ["--ssh-key", pub] + if self.dry_run: + print(" " + " ".join(shlex.quote(a) for a in argv)) + return nom + res_proc = subprocess.run(argv, timeout=DELAIS["creation"] * 3) + 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 + + @staticmethod + def uuid_libvirt(nom): + """L'UUID du domaine `nom`, ou "". C'est lui qui l'identifie. + + Un nom se réutilise ; un UUID non. Sans lui, « --detruire » effaçait + « deep-pve-1 » quel qu'il soit — la VM d'une descente précédente qu'on + voulait garder, ou une machine sans rapport qui porte ce nom. + """ + try: + res = subprocess.run( + ["sudo", "-n", "virsh", "domuuid", nom], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return "" + return "" if res.returncode else res.stdout.strip() + + def parcourir(self): + parent = None + parent_alias = "" + for res in self.plan["niveaux"]: + niveau = res["niveau"] + self.niveau_courant = niveau + debut = time.time() + etage = { + "niveau": niveau, + "ressources": res, + "etape": "creation", + "ok": False, + } + self.dire( + f" ── étage {niveau} : {res['vcpu']} vCPU," + f" {res['ram']} Mo, {res['disque']} Go" + ) + if niveau == 1: + nom = self.creer_etage1(res) + if not nom: + self.etages.append(etage) + self.interrompu = True + break + alias = nom + etage["nom"] = nom + # « cree » : NOUS l'avons faite. C'est de ce seul champ que + # dépend le droit de la détruire. Avant lui, ce qui protégeait + # une machine que nous n'avions pas créée était un effet de + # bord — l'absence des clés qu'une descente écrit. + etage["cree"] = True + # L'UUID, et non le nom : c'est de lui que « --detruire » se + # servira. Un nom se réutilise, un UUID non. + etage["uuid"] = self.uuid_libvirt(nom) + # Le domaine libvirt existe : le rapport doit exister aussi. + self._sauver(etage) + else: + prepare = self.preparer_parent(parent) + if not prepare: + etage["etape"] = "parent" + self.etages.append(etage) + self.interrompu = True + break + + def noter( + numero, + etage=etage, + parent_alias=parent_alias, + niveau=niveau, + ): + # « identite » et non « vmid » : un VMID chez Proxmox, + # un UUID libvirt ailleurs. Le champ ancien est gardé pour + # les rapports écrits avant ce changement. + etage["identite"] = str(numero) + etage["vmid"] = numero + etage["parent_alias"] = parent_alias + etage["cree"] = True + # Le nom est ÉCRIT, non déduit du numéro d'étage à la + # relecture : si nom_etage change un jour, un rapport + # ancien désignerait des machines qui ne sont pas les + # siennes. + etage["nom"] = self.nom_etage(niveau) + self._sauver(etage) + + identite, adresse = self.creer_enfant( + parent, niveau, res, prepare, noter + ) + if identite is None: + self.etages.append(etage) + self.interrompu = True + break + etage["identite"] = str(identite) + etage["vmid"] = identite + etage["cree"] = True + # Le parent est noté AVANT tout autre contrôle : c'est le seul + # enregistrement de ce qu'on vient de créer, et --detruire s'en + # sert. Sans lui, une VM abandonnée juste après « qm create » + # n'était nommée nulle part. + etage["parent_alias"] = parent_alias + self._sauver(etage) + alias = self.alias_etage(niveau, parent_alias) + if not self.dry_run: + self.ecrire_alias(alias, adresse, parent_alias) + cible = {"target": alias, "sudo": "sudo ", "jump": ""} + etage["alias"] = alias + + etage["etape"] = "ssh" + attente = self.attendre_ssh(cible, self.delai("ssh"), parent) + if attente is None: + self.dire(" ✗ jamais joignable en ssh") + self.etages.append(etage) + self.interrompu = True + break + etage["ssh_secondes"] = attente + self.dire(f" ssh après {attente} s") + + # Les quatre étapes qui suivent le ssh. « controle » est la + # sixième et elle est NOUVELLE : sans elle, un étage était déclaré + # terminé sans qu'on sache s'il pouvait héberger le suivant. + for etape, action in ( + ("install", lambda: self.installer(cible)), + ("reboot", lambda: self.redemarrer_et_verifier(cible)), + ("systeme", lambda: self.preparer_systeme(cible)), + ("services", lambda: self.remettre_debout(cible)), + ("controle", lambda: self.controler(cible)), + ): + etage["etape"] = etape + self._sauver(etage) + if not action(): + self.etages.append(etage) + return self.rapport(interrompu=True) + + # En dry-run, aucune étape n'a été mesurée : les marquer + # « atteintes » produisait un rapport indiscernable d'une vraie + # réussite, JSON compris, et un code de sortie 0. + etage["etape"] = "plan" if self.dry_run else "termine" + etage["ok"] = not self.dry_run + etage["secondes"] = int(time.time() - debut) + self.etages.append(etage) + self._sauver() + self.dire(f" ✓ étage {niveau} en {etage['secondes']} s") + parent, parent_alias = cible, alias + return self.rapport(interrompu=self.interrompu) + + def ecrire_alias(self, alias, adresse, parent_alias): + """Une entrée ~/.ssh/config pour joindre l'enfant à travers le parent.""" + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + prive = cle_publique()[:-4] if cle_publique() else None + todo._write_ssh_config_entry( + [alias], + "erplibre", + adresse, + proxy_jump=parent_alias or None, + identity_file=prive, + ) + + def _etat(self, interrompu, en_cours=None): + """Le rapport, à cet instant. `en_cours` : l'étage pas encore rangé.""" + etages = list(self.etages) + if en_cours is not None and en_cours not in etages: + etages.append(en_cours) + return { + # L'outil qui a écrit ce rapport. Sans lui, « deep_qemu + # --detruire » prenait le rapport le plus récent — qui pouvait + # être celui d'une descente Proxmox — et détruisait d'après lui. + "outil": self.OUTIL, + "demandee": self.plan["demandee"], + "atteignable": self.plan["atteignable"], + "atteinte": sum(1 for e in etages if e.get("ok")), + "interrompu": interrompu, + # Sans ce champ, un rapport d'essai à blanc se lisait comme une + # descente réussie — et « --detruire » s'en servait. + "dry_run": self.dry_run, + "etages": etages, + } + + def _sauver(self, en_cours=None): + """Écrit le rapport PARTIEL, dès qu'une VM existe. + + Il ne s'écrivait qu'à la fin. Une descente tuée au quatrième étage — + c'est arrivé — laissait quatre machines réelles et « --detruire » + répondait « aucun rapport : rien à défaire » : le seul enregistrement + du couple (alias du parent, VMID) mourait avec le processus. Il fallait + alors les retrouver et les détruire à la main, c'est-à-dire par leur + nom, ce que tout le reste de ce fichier s'applique à ne pas faire. + + Marqué « interrompu » jusqu'au bout : un rapport partiel ne doit jamais + se lire comme une descente terminée. + """ + if self.dry_run or not self.chemin_json: + return + temporaire = self.chemin_json + ".tmp" + try: + etat = self._etat(interrompu=True, en_cours=en_cours) + # Le PID de la descente qui écrit : c'est ce qui distingue un + # rapport ABANDONNÉ d'un rapport en cours d'écriture. Le rapport + # final, lui, n'en porte pas — la descente est finie. + etat["pid"] = os.getpid() + with open(temporaire, "w", encoding="utf-8") as fh: + json.dump(etat, fh, indent=2) + os.replace(temporaire, self.chemin_json) + except OSError as err: + self.dire(f" ⚠ rapport non écrit : {err}") + + def rapport(self, interrompu=False): + etat = self._etat(interrompu) + atteint = etat["atteinte"] + print("") + if self.dry_run: + self.dire( + f" plan annoncé sur {len(self.etages)} étage(s) —" + " rien n'a été créé" + ) + else: + self.dire( + f" profondeur atteinte : {atteint}" + f" / {self.plan['demandee']}" + ) + # Deux causes très différentes rendaient le même « 5 / 10 » : la + # machine trop petite pour dix, ou un étage tombé en route. La + # première n'est pas un défaut du code, la seconde si. + if atteint == self.plan["atteignable"] < self.plan["demandee"]: + self.dire( + f" (plan borné à {self.plan['atteignable']} par le" + f" {self.plan['arret']} : tout le plan a tenu)" + ) + elif atteint < self.plan["atteignable"]: + self.dire( + f" (le plan annonçait {self.plan['atteignable']} :" + " un étage est tombé, voir plus haut)" + ) + for e in self.etages: + if self.dry_run: + marque, detail = "·", "plan" + else: + marque = "✓" if e["ok"] else "✗" + detail = ( + f"{e.get('secondes', '—')} s" if e["ok"] else e["etape"] + ) + self.dire(f" {marque} étage {e['niveau']:2d} {detail}") + return etat + + +def _lance_une_descente(pid): + """`pid` exécute-t-il UN des scripts de descente — pas seulement le + nomme-t-il ? + + Par ARGUMENT, jamais par sous-chaîne. Constaté sur cette machine : un + « pgrep -f deep_proxmox.py » posé dans une boucle de surveillance donne un + shell dont la ligne de commande contient le motif, et le contrôle comptait + ce shell comme une descente — deux faux positifs sur trois. Un argument + qui SE TERMINE par le nom du fichier, lui, ne peut venir que d'un + interpréteur qu'on a lancé dessus. + """ + try: + with open(f"/proc/{int(pid)}/cmdline", "rb") as fh: + arguments = fh.read().split(b"\0") + except (OSError, ValueError): + return False + # Les DEUX scripts : deux descentes de piles différentes se disputent la + # RAM, le disque et ~/.ssh/config aussi sûrement que deux de la même. + attendus = tuple(nom.encode() for nom in SCRIPTS) + return any(a.endswith(attendus) for a in arguments) + + +def descente_vivante(pid): + """Le processus `pid` est-il une descente EN COURS ? + + Le PID seul ne suffirait pas : les numéros se réutilisent, et rien ne dit + qu'un rapport vieux d'une semaine ne porte pas le PID d'un shell + d'aujourd'hui. La ligne de commande est donc lue aussi. + """ + return bool(pid) and _lance_une_descente(pid) + + +def autre_descente(): + """Les PID des AUTRES descentes vivantes. Le sien est exclu. + + Le garde-fou du rapport — un PID dans le fichier — ne protège que les + descentes lancées APRÈS son écriture : celle qui tournait déjà avait + chargé l'ancien module en mémoire et n'écrira jamais de PID. Constaté sur + une descente réelle de dix étages, à l'étage 4. Ce contrôle-ci ne dépend + d'aucun rapport : détruire pendant qu'une descente tourne n'est jamais + juste, quel que soit le rapport choisi. + + /proc plutôt que pgrep : « pgrep -f deep_proxmox » attrape le shell qui + l'invoque, et on croit alors voir survivre un processus qui n'existe pas. + """ + moi = os.getpid() + vivants = [] + try: + entrees = os.listdir("/proc") + except OSError: + return vivants + for entree in entrees: + if not entree.isdigit() or int(entree) == moi: + continue + if _lance_une_descente(entree): + vivants.append(int(entree)) + return vivants + + +class Famille: + """Ce qu'une pile doit dire d'elle aux fonctions qui détruisent. + + Trois choses, et pas une de plus : son nom d'outil — qui filtre les + rapports —, le préfixe de ses machines, et comment on défait une machine + imbriquée chez son parent. + """ + + def __init__(self, outil, nom_base, detruire_une): + self.outil = outil + self.nom_base = nom_base + self.detruire_une = detruire_une + + +def dernier_rapport(outil="", prefixe=""): + """Le rapport le plus récent qui NOMME quelque chose à défaire, ou {}. + + C'est le SEUL enregistrement de ce que la descente a créé : un couple + (alias du parent, VMID) par étage. Détruire d'après lui, et non d'après + les noms, est toute la différence entre défaire son propre travail et + effacer une machine qui se trouve porter un nom voisin. + + Deux rapports sont ÉCARTÉS, et chacun l'est pour un accident précis : + + * celui d'une descente VIVANTE. Depuis que le rapport s'écrit VM par VM, + la descente en cours en a un sur le disque, et c'est le plus récent : + « --detruire » aurait détruit l'arbre sous le processus qui installait + encore, emportant des heures de mesure. Avant, la descente en cours + n'avait aucun rapport et la question ne se posait pas — le correctif a + créé le danger. + + * celui qui n'a RIEN créé. Un second lancement qui meurt à l'étage 1 — + « le disque existe déjà » — écrit un rapport vide sous un horodatage + plus tardif. Il masquait le partiel qui nommait les VM réelles : + « 0 VM imbriquée(s) », puis « virsh undefine --remove-all-storage » sur + l'étage 1, dont le disque contient les étages 2 et suivants — jamais + arrêtés, jamais nommés. + """ + dossier = os.path.expanduser("~/.erplibre/longtest") + try: + fichiers = sorted( + f for f in os.listdir(dossier) if f.endswith(".json") + ) + except OSError: + return {} + for nom in reversed(fichiers): + chemin = os.path.join(dossier, nom) + try: + with open(chemin, encoding="utf-8") as fh: + rapport = json.load(fh) + except (OSError, ValueError): + continue + if rapport.get("dry_run"): + continue # un plan n'a rien créé + # Le rapport d'une AUTRE pile. Le dossier et le motif « *.json » sont + # partagés : sans ce filtre, « deep_qemu --detruire » prenait le + # rapport le plus récent — pouvant être celui d'une descente Proxmox — + # et lançait « virsh undefine » d'après lui. + # + # Un rapport écrit AVANT que ce champ existe n'a pas d'outil. Le + # refuser le rendrait indéfaisable, et laisser passer ramènerait le + # danger : c'est le NOM DE FICHIER qui tranche, puisqu'il porte déjà + # le préfixe de la pile — « deep-pve-20260828-…json ». + if outil: + declare = rapport.get("outil") + if declare != outil and not ( + declare is None and prefixe and nom.startswith(prefixe) + ): + continue + if descente_vivante(rapport.get("pid")): + dire(f" ⏳ descente EN COURS ({rapport['pid']}) : {nom} ignoré") + continue + if not (rapport.get("etages") or []): + continue # rien créé : ne pas masquer un rapport qui nomme des VM + rapport["fichier"] = chemin + return rapport + return {} + + +def identite_de(etage): + """L'identifiant de la machine SUR SON PARENT, ou "". + + Un VMID chez Proxmox, un UUID libvirt ailleurs — d'où une chaîne, et non + un entier. « vmid » est l'ancien nom du champ : les rapports écrits avant + ce changement le portent encore, et ils doivent rester défaisables. + """ + valeur = etage.get("identite") or etage.get("vmid") + return "" if valeur in (None, "") else str(valeur) + + +def a_defaire(rapport, nom_base=""): + """[(niveau, parent_alias, vmid, nom)] du plus PROFOND au plus haut. + + Trié sur le niveau LU dans le rapport, pas déduit du nom. La version + d'avant comptait les « + » de l'alias — or `alias_etage` remplace le « + » + du parent par un « - », donc chaque alias en portait exactement UN et le + tri ne triait rien. La destruction partait du plus HAUT : « qm destroy + --purge » sur l'étage 2 emportait le disque contenant les étages 3 et + suivants, sans les avoir arrêtés ni nommés. + """ + etages = [ + e + for e in (rapport.get("etages") or []) + if identite_de(e) and e.get("parent_alias") + # « cree » est le seul champ qui dise que la machine est à NOUS. Les + # deux autres conditions ne protégeaient que par accident : elles + # tenaient parce que rien ne décrivait un hôte emprunté. Depuis qu'une + # descente peut PARTIR d'une machine existante, il faut le dire. + and e.get("cree", True) + ] + etages.sort(key=lambda e: -int(e["niveau"])) + return [ + ( + int(e["niveau"]), + e["parent_alias"], + identite_de(e), + # Le nom ÉCRIT par la descente. Le déduire du numéro d'étage + # supposait que nom_etage ne changera jamais — un rapport ancien + # aurait alors nommé des machines qui ne sont pas les siennes. + # Le nom ÉCRIT par la descente. Le déduire du numéro d'étage + # supposait que nom_etage ne changera jamais — un rapport ancien + # aurait alors nommé des machines qui ne sont pas les siennes. + e.get("nom") + or (nom_base and nom_etage(int(e["niveau"]), nom_base)), + ) + for e in etages + ] + + +def detruire_etage1(journal, nom, dry_run=False, attendu=None, cree=True): + """Le domaine libvirt du premier étage — le SEUL qui en soit un. + + La boucle d'avant tournait sur trente niveaux avec une condition morte, et + sa branche « niveau == 1 » était vraie même quand la descente n'avait + jamais rien créé : « virsh undefine --remove-all-storage » partait alors + sur un domaine qui pouvait être n'importe quoi, sortie capturée, sans un + mot. + + `attendu` : l'UUID que le rapport a noté à la création. C'est LUI qui + identifie la machine, pas son nom. Un nom se réutilise — la VM d'une + descente précédente qu'on voulait garder, ou une machine sans rapport qui + porte celui-là — et « --remove-all-storage » efface un disque pour de bon. + Un rapport ancien n'a pas d'UUID : on procède alors comme avant, par le + nom, faute de mieux, mais en le disant. + """ + # `nom` est OBLIGATOIRE : une fonction qui lance + # « virsh undefine --remove-all-storage » ne devine pas sa cible. Elle le + # faisait — `nom_etage(1)` — et le repli désignait la machine numéro 1 de + # la pile, quelle que soit celle dont parlait le rapport. + existe = subprocess.run( + ["sudo", "-n", "virsh", "dominfo", nom], + capture_output=True, + text=True, + ) + if existe.returncode: + dire(f" — {nom} : aucun domaine libvirt", journal) + return True + if attendu: + vu = Descente.uuid_libvirt(nom) + if vu != attendu: + dire( + f" ✗ {nom} : UUID {vu or '—'} au lieu de {attendu} —" + " ce n'est PAS notre machine, rien touché", + journal, + ) + return False + elif cree: + dire( + f" ⚠ {nom} : rapport sans UUID, identifié par son NOM", journal + ) + else: + # Ni UUID, ni preuve que la machine est à nous : on ne touche à rien. + # Une descente PARTIE d'un hôte existant n'a jamais d'UUID libvirt + # local ; le repli par le nom aurait effacé un homonyme, disques + # compris, avec « --remove-all-storage ». + dire( + f" — {nom} : pas créé par cette descente, rien touché", journal + ) + return True + if dry_run: + dire( + f" [à blanc] virsh undefine {nom} --remove-all-storage", journal + ) + return True + subprocess.run( + ["sudo", "virsh", "destroy", nom], capture_output=True, text=True + ) + res = subprocess.run( + [ + "sudo", + "virsh", + "undefine", + nom, + "--nvram", + "--remove-all-storage", + ], + capture_output=True, + text=True, + ) + if res.returncode: + dire( + f" ✗ virsh undefine {nom} : {res.stderr.strip()[:160]}", journal + ) + return False + dire(f" ✓ {nom} (libvirt)", journal) + return True + + +def detruire(famille, journal=None, dry_run=False): + """Défait ce que le DERNIER rapport dit avoir créé, du plus profond. + + Rien d'autre. La version d'avant prenait toute entrée ~/.ssh/config dont + le nom contenait « deep-pve », puis sur son rebond détruisait toute VM + dont le nom contenait « deep-pve » — une machine de labo appelée + « deep-pve-lab » sur un hyperviseur de production tombait dedans. + """ + # Avant tout : refuser tant qu'une descente tourne. Elle installe encore + # sur les machines qu'on s'apprête à détruire, et son rapport peut être + # celui qu'on vient de choisir. + autres = autre_descente() + if autres: + dire( + f" ⛔ une descente tourne ({', '.join(map(str, autres))}) :" + " rien ne sera détruit.", + journal, + ) + dire(" Attendre qu'elle finisse, ou l'arrêter d'abord.", journal) + return 1 + rapport = dernier_rapport(famille.outil, famille.nom_base) + if not rapport: + dire(" aucun rapport de descente : rien à défaire.", journal) + dire( + " (les entrées ~/.ssh/config orphelines : menu de nettoyage)", + journal, + ) + return 0 + liste = a_defaire(rapport, famille.nom_base) + dire(f" rapport : {rapport.get('fichier')}", journal) + dire(f" {len(liste)} VM imbriquée(s) + l'étage 1 :", journal) + for niveau, parent_alias, identite, nom in liste: + dire( + f" étage {niveau:2d} {nom} ({identite}) sur {parent_alias}", + journal, + ) + etage1_nom = next( + ( + e.get("nom") + for e in (rapport.get("etages") or []) + if int(e.get("niveau", 0)) == 1 + ), + None, + ) + dire( + f" étage 1 {etage1_nom or nom_etage(1, famille.nom_base)}" + " (libvirt)", + journal, + ) + if dry_run: + dire("\n --dry-run : rien ne sera détruit.", journal) + return 0 + # Une confirmation, parce que « --purge » emporte les disques et que le + # menu lançait cette option d'une seule touche. + reponse = input("\n Détruire tout cela ? (tapez OUI) : ").strip() + if reponse != "OUI": + dire(" annulé.", journal) + return 1 + faits = sum( + 1 + for niveau, parent_alias, identite, nom in liste + if famille.detruire_une(parent_alias, identite, nom, journal) + ) + # « if not … : faits -= 1 » : un succès de l'étage 1 n'ajoutait RIEN, + # alors que le total est len(liste) + 1. Le décompte était décalé de un + # dans TOUS les cas — une destruction complète annonçait « il reste des + # machines » et sortait 1, si bien que le seul avertissement censé + # prévenir qu'un disque de plusieurs dizaines de Go reste alloué + # s'affichait toujours, et qu'on apprenait à ne plus le lire. + etage1 = next( + ( + e + for e in (rapport.get("etages") or []) + if int(e.get("niveau", 0)) == 1 + ), + {}, + ) + racine = detruire_etage1( + journal, + attendu=etage1.get("uuid"), + nom=etage1.get("nom") or nom_etage(1, famille.nom_base), + cree=bool(etage1.get("cree", True)), + ) + if racine: + faits += 1 + retirer_alias(rapport, journal, famille.nom_base) + if racine: + # L'étage 1 est un DISQUE, et tout le reste vit dedans. « virsh + # undefine --remove-all-storage » l'a effacé : les étages injoignables + # — leur parent était éteint — ont disparu avec, qu'on ait pu leur + # parler ou non. Annoncer « il reste des machines » dans ce cas était + # faux dans l'autre sens, et un avertissement faux ne se lit plus. + reste = len(liste) + 1 - faits + dire( + f"\n {len(liste) + 1} / {len(liste) + 1} défait(s)." + + ( + f" ({reste} injoignable(s), emporté(s) avec le disque de" + " l'étage 1.)" + if reste + else "" + ), + journal, + ) + return 0 + dire( + f"\n {faits} / {len(liste) + 1} défait(s)." + " ⚠ l'étage 1 est DEBOUT : ce qu'il contient vit encore.", + journal, + ) + return 1 + + +def retirer_alias(rapport, journal=None, nom_base=""): + """Retire de ~/.ssh/config les entrées de la descente défaite. + + Sans cela, elles survivaient aux machines : des entrées mortes dont le + ProxyJump désigne un hôte qui n'existe plus, et qu'on retrouve plus tard + sans savoir à quoi elles servaient. + """ + alias = [] + for etage in rapport.get("etages") or []: + # Seulement les nôtres. L'entrée ssh d'un hôte EMPRUNTÉ appartient à + # l'utilisateur : il l'a écrite pour sa propre machine et elle lui sert + # ailleurs. + if not etage.get("cree", True): + continue + nom = etage.get("alias") + if not nom: + # L'étage abandonné avant l'écriture de son alias : le calculer, + # il est déterminé par (niveau, alias du parent). + parent = etage.get("parent_alias") + if parent: + nom = alias_etage(int(etage["niveau"]), parent, nom_base) + if nom and nom not in alias: + alias.append(nom) + if not alias: + return + try: + from script.todo.todo import TODO + + TODO.__new__(TODO)._write_ssh_config_entry( + [], "erplibre", "", also_drop=tuple(alias) + ) + except Exception as err: # noqa: BLE001 - jamais bloquer la destruction + dire(f" ⚠ entrées ~/.ssh/config non retirées : {err}", journal) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 35c7e9f..2d10209 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -24,7 +24,7 @@ from script.todo.todo_i18n import t LONGTEST_DIR = "long_test" -class long_testMenuMixin: +class LongTestMenuMixin: def _longtest_script(self, nom): """Chemin d'un test long, ou "" s'il n'est pas là.""" chemin = os.path.join(os.getcwd(), LONGTEST_DIR, nom) diff --git a/script/todo/todo.py b/script/todo/todo.py index f27bd02..0a5fb21 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -28,7 +28,7 @@ from script.config import config_file from script.execute import execute from script.todo import todo_prefs from script.todo.database_manager import DatabaseManager -from script.todo.longtest_menu import long_testMenuMixin +from script.todo.longtest_menu import LongTestMenuMixin from script.todo.proxmox_menu import ProxmoxMenuMixin from script.todo.qemu_access import QemuAccessMixin from script.todo.qemu_deploy import QemuDeployMixin @@ -97,7 +97,7 @@ class TODO( QemuManageMixin, QemuAccessMixin, ProxmoxMenuMixin, - long_testMenuMixin, + LongTestMenuMixin, ): def __init__(self): self.dir_path = None diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 1592262..03cc362 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -27,6 +27,12 @@ from script.todo.todo import TODO # noqa: E402 RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) PYTHON = os.path.join(RACINE, ".venv.erplibre/bin/python") +# Le moteur vit dans son propre module depuis qu'il est partagé entre +# deep_proxmox et deep_qemu. Bouchonner « deep_proxmox.dernier_rapport » ne +# ferait plus rien : c'est descente.detruire qui appelle descente.dernier_rapport. +sys.path.insert(0, os.path.join(RACINE, "long_test")) +import descente as moteur # noqa: E402 + class TestLaFrontiere(unittest.TestCase): """long_test est hors de portée du lanceur unitaire, et ce n'est pas un @@ -301,14 +307,17 @@ class TestDefaireSansEffacerAutreChose(unittest.TestCase): {"niveau": 3, "vmid": 100, "parent_alias": "b"}, ] } - niveaux = [n for n, _p, _v, _nom in self.dp.a_defaire(rapport)] + niveaux = [ + n + for n, _p, _v, _nom in self.dp.a_defaire(rapport, self.dp.NOM_BASE) + ] self.assertEqual(niveaux, [4, 3, 2]) def test_a_level_without_a_vmid_is_not_guessed(self): # Un étage abandonné avant « qm create » n'a rien créé : ne rien # inventer à sa place. rapport = {"etages": [{"niveau": 2}, {"niveau": 3, "vmid": 101}]} - self.assertEqual(len(self.dp.a_defaire(rapport)), 0) + self.assertEqual(len(self.dp.a_defaire(rapport, self.dp.NOM_BASE)), 0) def test_the_alias_chain_really_flattens_the_plus(self): # La cause du tri mort, énoncée pour qu'on ne la réintroduise pas. @@ -394,7 +403,9 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): d.ecrire_alias = lambda *a, **k: None d.attendre_ssh = lambda cible, delai, parent=None: 1 d.redemarrer_et_verifier = lambda cible: True - d.reparer_pmxcfs = lambda cible: True + d.remettre_debout = lambda cible: True + d.preparer_systeme = lambda cible: True + d.controler = lambda cible: True def installer(cible): appels.append(cible) @@ -402,7 +413,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): raise KeyboardInterrupt("descente tuée") return True - d.installer_proxmox = installer + d.installer = installer with contextlib.redirect_stdout(io.StringIO()): with self.assertRaises(KeyboardInterrupt): d.parcourir() @@ -414,10 +425,10 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): # Le couple (parent, VMID) des étages imbriqués créés : c'est de lui # seul que « --detruire » se sert. self.assertEqual( - self.dp.a_defaire(rapport), + self.dp.a_defaire(rapport, self.dp.NOM_BASE), [ - (3, "deep-pve-1+deep-pve-2", 103, self.dp.nom_etage(3)), - (2, "deep-pve-1", 102, self.dp.nom_etage(2)), + (3, "deep-pve-1+deep-pve-2", "103", self.dp.nom_etage(3)), + (2, "deep-pve-1", "102", self.dp.nom_etage(2)), ], ) @@ -427,7 +438,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): ne le regardait même pas.""" rapport = self._descente_tuee(a_l_etage=1) self.assertTrue(rapport["etages"]) - self.assertEqual(self.dp.a_defaire(rapport), []) + self.assertEqual(self.dp.a_defaire(rapport, self.dp.NOM_BASE), []) def test_a_partial_report_never_reads_as_a_finished_descent(self): rapport = self._descente_tuee(a_l_etage=3) @@ -476,7 +487,7 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): # laissait en place, et le test d'après lisait le bouchon. self._vrais = { nom: getattr(deep_proxmox, nom) - for nom in ("autre_deep_proxmox", "dernier_rapport") + for nom in ("autre_descente", "dernier_rapport") } def tearDown(self): @@ -512,8 +523,8 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): ] ) self.addCleanup(faux.kill) - self.assertFalse(self.dp._lance_ce_script(faux.pid)) - self.assertNotIn(faux.pid, self.dp.autre_deep_proxmox()) + self.assertFalse(self.dp._lance_une_descente(faux.pid)) + self.assertNotIn(faux.pid, self.dp.autre_descente()) def _fausse_descente(self): """Un processus qui exécute VRAIMENT un « deep_proxmox.py ». @@ -526,7 +537,7 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): proc = subprocess.Popen([sys.executable, faux]) self.addCleanup(proc.kill) for _ in range(60): - if self.dp._lance_ce_script(proc.pid): + if self.dp._lance_une_descente(proc.pid): return proc.pid time.sleep(0.05) self.skipTest("le processus témoin n'a pas démarré") @@ -534,7 +545,7 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): def test_a_living_descent_is_seen_in_proc(self): pid = self._fausse_descente() self.assertTrue(self.dp.descente_vivante(pid)) - self.assertIn(pid, self.dp.autre_deep_proxmox()) + self.assertIn(pid, self.dp.autre_descente()) def test_the_report_of_a_living_descent_is_skipped(self): """Sans ce filtre, « --detruire » choisissait le rapport de la @@ -583,7 +594,7 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): ) with contextlib.redirect_stdout(io.StringIO()): rapport = self.dp.dernier_rapport() - self.assertEqual(len(self.dp.a_defaire(rapport)), 2) + self.assertEqual(len(self.dp.a_defaire(rapport, self.dp.NOM_BASE)), 2) self.assertTrue(rapport["fichier"].endswith("20260101-000000.json")) def test_a_dry_run_report_still_never_wins(self): @@ -607,10 +618,10 @@ class TestNeJamaisDetruireSousUneDescenteVivante(unittest.TestCase): def test_destroying_refuses_while_a_descent_runs(self): appels = [] - self.dp.autre_deep_proxmox = lambda: [4242] - self.dp.dernier_rapport = lambda: appels.append("lu") or {} + moteur.autre_descente = lambda: [4242] + moteur.dernier_rapport = lambda outil="": appels.append("lu") or {} with contextlib.redirect_stdout(io.StringIO()) as sortie: - code = self.dp.detruire(None, dry_run=False) + code = self.dp.detruire(self.dp.FAMILLE, None, dry_run=False) self.assertEqual(code, 1) # Le rapport n'est même pas LU : on ne demande rien, on ne propose # rien, et surtout on n'attend pas un « OUI » sur un arbre vivant. @@ -637,10 +648,13 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): # ferait une méthode d'instance, et « self.uuid_libvirt(nom) » # passerait deux arguments à une fonction qui en prend un. La fuite # tombait sur les tests SUIVANTS. - self.vrai_uuid = deep_proxmox.Descente.__dict__["uuid_libvirt"] + # Le crochet vit sur la classe de BASE, dans descente.py : c'est + # elle qu'il faut détourner, pas la sous-classe Proxmox. + self.moteur = moteur + self.vrai_uuid = moteur.Descente.__dict__["uuid_libvirt"] self.addCleanup(setattr, deep_proxmox.subprocess, "run", self.vrai_run) self.addCleanup( - setattr, deep_proxmox.Descente, "uuid_libvirt", self.vrai_uuid + setattr, moteur.Descente, "uuid_libvirt", self.vrai_uuid ) self.lances = [] @@ -656,9 +670,13 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): def test_a_homonym_with_another_uuid_is_left_alone(self): self._virsh() - self.dp.Descente.uuid_libvirt = staticmethod(lambda nom: "AUTRE-UUID") + self.moteur.Descente.uuid_libvirt = staticmethod( + lambda nom: "AUTRE-UUID" + ) with contextlib.redirect_stdout(io.StringIO()) as sortie: - res = self.dp.detruire_etage1(None, attendu="LE-NOTRE") + res = self.dp.detruire_etage1( + None, "deep-pve-1", attendu="LE-NOTRE" + ) self.assertFalse(res) self.assertIn("PAS notre machine", sortie.getvalue()) # Aucun undefine, aucun destroy : seule la lecture a eu lieu. @@ -666,9 +684,13 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): def test_our_own_machine_is_destroyed(self): self._virsh() - self.dp.Descente.uuid_libvirt = staticmethod(lambda nom: "LE-NOTRE") + self.moteur.Descente.uuid_libvirt = staticmethod( + lambda nom: "LE-NOTRE" + ) with contextlib.redirect_stdout(io.StringIO()): - res = self.dp.detruire_etage1(None, attendu="LE-NOTRE") + res = self.dp.detruire_etage1( + None, "deep-pve-1", attendu="LE-NOTRE" + ) self.assertTrue(res) self.assertTrue( any( @@ -683,14 +705,16 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): plutôt que de laisser croire qu'on a vérifié.""" self._virsh() with contextlib.redirect_stdout(io.StringIO()) as sortie: - res = self.dp.detruire_etage1(None) + res = self.dp.detruire_etage1(None, "deep-pve-1") self.assertTrue(res) self.assertIn("identifié par son NOM", sortie.getvalue()) def test_an_absent_domain_is_not_an_error(self): self._virsh(dominfo=1) with contextlib.redirect_stdout(io.StringIO()): - self.assertTrue(self.dp.detruire_etage1(None, attendu="X")) + self.assertTrue( + self.dp.detruire_etage1(None, "deep-pve-1", attendu="X") + ) def test_the_name_comes_from_the_report_not_from_the_level(self): """Le déduire du numéro d'étage supposait que nom_etage ne changera @@ -706,15 +730,15 @@ class TestLEtage1SIdentifiePasParSonNom(unittest.TestCase): ] } self.assertEqual( - self.dp.a_defaire(rapport), - [(2, "a", 102, "nom-ecrit-a-la-creation")], + self.dp.a_defaire(rapport, self.dp.NOM_BASE), + [(2, "a", "102", "nom-ecrit-a-la-creation")], ) def test_a_report_without_a_name_falls_back_on_the_level(self): rapport = {"etages": [{"niveau": 3, "vmid": 103, "parent_alias": "b"}]} self.assertEqual( - self.dp.a_defaire(rapport), - [(3, "b", 103, self.dp.nom_etage(3))], + self.dp.a_defaire(rapport, self.dp.NOM_BASE), + [(3, "b", "103", self.dp.nom_etage(3))], ) @@ -759,7 +783,7 @@ class TestLaCauseDUnMontageAbsent(unittest.TestCase): } 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 "}) + res = self.d.remettre_debout({"target": "h", "sudo": "sudo "}) return res, sortie.getvalue() def test_a_failing_unit_is_named_with_its_journal(self): @@ -889,9 +913,11 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): ) d.ecrire_alias = lambda *a, **k: None d.attendre_ssh = lambda cible, delai, parent=None: 1 - d.installer_proxmox = lambda cible: True + d.installer = lambda cible: True d.redemarrer_et_verifier = lambda cible: True - d.reparer_pmxcfs = lambda cible: True + d.remettre_debout = lambda cible: True + d.preparer_systeme = lambda cible: True + d.controler = lambda cible: True # La création note son VMID, puis MEURT — comme « qm resize » sur un # stockage plein. @@ -905,8 +931,8 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): 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))], + self.dp.a_defaire(rapport, self.dp.NOM_BASE), + [(2, "deep-pve-1", "142", self.dp.nom_etage(2))], ) def test_the_vmid_is_announced_before_the_creating_commands(self): @@ -1003,6 +1029,187 @@ class TestNePasAttendreUneMaisonDisparue(unittest.TestCase): self.assertEqual(self.d.attendre_ssh(enfant, 60), 0) +class TestDeuxPilesNeSeMelangentPas(unittest.TestCase): + """Le dossier des rapports et le motif « *.json » sont PARTAGÉS. + + Depuis qu'il y a deux tests longs, « deep_qemu --detruire » prendrait le + rapport le plus récent — pouvant être celui d'une descente Proxmox — et + lancerait « virsh undefine » d'après des VMID de Proxmox.""" + + def setUp(self): + self.maison = tempfile.mkdtemp(prefix="longtest-piles-") + self.dossier = os.path.join(self.maison, ".erplibre/longtest") + os.makedirs(self.dossier) + self._vrai = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.addCleanup(shutil.rmtree, self.maison, ignore_errors=True) + + def tearDown(self): + if self._vrai is not None: + os.environ["HOME"] = self._vrai + + def _ecrire(self, nom, rapport): + with open( + os.path.join(self.dossier, nom), "w", encoding="utf-8" + ) as fh: + json.dump(rapport, fh) + + def _etage(self): + return [{"niveau": 2, "identite": "102", "parent_alias": "a"}] + + def test_each_tool_only_sees_its_own_reports(self): + self._ecrire( + "deep-pve-20260101-000000.json", + { + "dry_run": False, + "outil": "deep_proxmox", + "etages": self._etage(), + }, + ) + self._ecrire( + "deep-qemu-20260102-000000.json", + {"dry_run": False, "outil": "deep_qemu", "etages": self._etage()}, + ) + with contextlib.redirect_stdout(io.StringIO()): + pve = moteur.dernier_rapport("deep_proxmox", "deep-pve") + qemu = moteur.dernier_rapport("deep_qemu", "deep-qemu") + self.assertTrue( + pve["fichier"].endswith("deep-pve-20260101-000000.json") + ) + self.assertTrue( + qemu["fichier"].endswith("deep-qemu-20260102-000000.json") + ) + + def test_an_older_report_without_a_tool_is_placed_by_its_filename(self): + """Les rapports écrits avant que ce champ existe n'ont pas d'outil. + Les refuser les rendrait indéfaisables ; les accepter sans regarder + ramènerait le danger. Le nom de fichier tranche.""" + self._ecrire( + "deep-pve-20260101-000000.json", + {"dry_run": False, "etages": self._etage()}, + ) + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(moteur.dernier_rapport("deep_proxmox", "deep-pve")) + self.assertFalse(moteur.dernier_rapport("deep_qemu", "deep-qemu")) + + def test_a_report_is_never_handed_to_the_wrong_tool(self): + self._ecrire( + "deep-pve-20260103-000000.json", + { + "dry_run": False, + "outil": "deep_proxmox", + "etages": self._etage(), + }, + ) + with contextlib.redirect_stdout(io.StringIO()): + self.assertFalse(moteur.dernier_rapport("deep_qemu", "deep-qemu")) + + def test_the_lock_looks_for_every_descent_script(self): + """Deux descentes de piles différentes se disputent la RAM, le disque + et ~/.ssh/config aussi sûrement que deux de la même.""" + import inspect + + src = inspect.getsource(moteur._lance_une_descente) + self.assertIn("SCRIPTS", src) + self.assertIn("deep_proxmox.py", moteur.SCRIPTS) + self.assertIn("deep_qemu.py", moteur.SCRIPTS) + + +class TestNeDetruirePasCeQuOnNaPasCree(unittest.TestCase): + """Une descente peut PARTIR d'une machine existante. + + Ce qui protégeait jusqu'ici un hôte non créé était un effet de bord : + a_defaire exigeait deux clés que seule une descente écrit. Depuis qu'un + hôte emprunté peut figurer au rapport, il faut le DIRE.""" + + def setUp(self): + sys.path.insert(0, os.path.join(RACINE, "long_test")) + import deep_proxmox + + self.dp = deep_proxmox + + def test_a_borrowed_level_is_never_in_the_destroy_list(self): + rapport = { + "etages": [ + { + "niveau": 1, + "identite": "9", + "parent_alias": "x", + "cree": False, + }, + { + "niveau": 2, + "identite": "102", + "parent_alias": "a", + "cree": True, + }, + ] + } + niveaux = [ + n for n, _p, _i, _nom in moteur.a_defaire(rapport, "deep-pve") + ] + self.assertEqual(niveaux, [2]) + + def test_a_borrowed_root_is_not_undefined_by_name(self): + """Une descente partie d'un hôte existant n'a JAMAIS d'UUID libvirt + local. Le repli par le nom aurait effacé un homonyme, disques + compris.""" + lances = [] + + def faux(argv, **kw): + import types + + lances.append(" ".join(argv)) + return types.SimpleNamespace(returncode=0, stdout="", stderr="") + + vrai = moteur.subprocess.run + moteur.subprocess.run = faux + self.addCleanup(setattr, moteur.subprocess, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + res = moteur.detruire_etage1(None, "machine-a-moi", cree=False) + self.assertTrue(res) + self.assertIn("pas créé par cette descente", sortie.getvalue()) + self.assertFalse( + [c for c in lances if "undefine" in c or "destroy" in c], lances + ) + + def test_a_borrowed_alias_stays_in_the_users_ssh_config(self): + vus = {} + import script.todo.todo as module_todo + + vrai = module_todo.TODO._write_ssh_config_entry + module_todo.TODO._write_ssh_config_entry = ( + lambda self, host, user, ip, **kw: vus.update( + drop=kw.get("also_drop") + ) + ) + self.addCleanup( + setattr, module_todo.TODO, "_write_ssh_config_entry", vrai + ) + with contextlib.redirect_stdout(io.StringIO()): + moteur.retirer_alias( + { + "etages": [ + {"niveau": 1, "alias": "mon-proxmox", "cree": False}, + {"niveau": 2, "alias": "mon-proxmox+deep-pve-2"}, + ] + }, + nom_base="deep-pve", + ) + self.assertEqual(vus["drop"], ("mon-proxmox+deep-pve-2",)) + + def test_destroying_the_level_one_requires_a_name(self): + """« virsh undefine --remove-all-storage » ne devine pas sa cible. Le + repli nom_etage(1) désignait la machine numéro 1 de la pile, quelle + que soit celle dont parlait le rapport.""" + import inspect + + signature = inspect.signature(moteur.detruire_etage1) + self.assertIs( + signature.parameters["nom"].default, inspect.Parameter.empty + ) + + class TestLeDecompteDeLaDestruction(unittest.TestCase): """« if not detruire_etage1(…) : faits -= 1 » — un succès de l'étage 1 n'ajoutait RIEN, alors que le total est len(liste) + 1. @@ -1017,19 +1224,26 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): import deep_proxmox self.dp = deep_proxmox + # Pris ET rendus sur le MOTEUR. La première version les prenait sur + # deep_proxmox et les rendait là aussi, alors qu'elle les posait sur + # descente : les bouchons fuyaient sur tous les tests suivants, qui + # inspectaient une lambda au lieu de la vraie fonction. self._vrais = { - nom: getattr(deep_proxmox, nom) + nom: getattr(moteur, nom) for nom in ( - "autre_deep_proxmox", + "autre_descente", "dernier_rapport", - "detruire_une", "detruire_etage1", "retirer_alias", ) } - deep_proxmox.autre_deep_proxmox = lambda: [] - deep_proxmox.retirer_alias = lambda *a, **k: None - deep_proxmox.dernier_rapport = lambda: { + self._vraie_detruire_une = self.dp.FAMILLE.detruire_une + self.addCleanup( + setattr, self.dp.FAMILLE, "detruire_une", self._vraie_detruire_une + ) + moteur.autre_descente = lambda: [] + moteur.retirer_alias = lambda *a, **k: None + moteur.dernier_rapport = lambda outil="", prefixe="": { "fichier": "/x.json", "etages": [ {"niveau": 3, "vmid": 103, "parent_alias": "a+b"}, @@ -1044,7 +1258,7 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): def tearDown(self): for nom, vrai in self._vrais.items(): - setattr(self.dp, nom, vrai) + setattr(moteur, nom, vrai) import builtins builtins.input = self._entree @@ -1053,10 +1267,10 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): import builtins builtins.input = lambda _prompt="": "OUI" - self.dp.detruire_une = lambda *a, **k: une - self.dp.detruire_etage1 = lambda *a, **k: etage1_ok + self.dp.FAMILLE.detruire_une = lambda *a, **k: une + moteur.detruire_etage1 = lambda *a, **k: etage1_ok with contextlib.redirect_stdout(io.StringIO()) as sortie: - code = self.dp.detruire(None, dry_run=False) + code = self.dp.detruire(self.dp.FAMILLE, None, dry_run=False) return code, sortie.getvalue() def test_a_complete_destruction_reports_success(self): @@ -1071,7 +1285,7 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): machines ». Or « virsh undefine --remove-all-storage » sur l'étage 1 efface le disque où ils VIVENT. L'avertissement était faux dans l'autre sens, et un avertissement faux ne se lit plus.""" - self.dp.detruire_une = lambda *a, **k: False + self.dp.FAMILLE.detruire_une = lambda *a, **k: False code, texte = self._lancer(etage1_ok=True, une=False) self.assertEqual(code, 0) self.assertIn("3 / 3", texte) @@ -1088,8 +1302,10 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): """Elles survivaient aux machines : des entrées mortes dont le ProxyJump désigne un hôte qui n'existe plus.""" retires = [] - self.dp.retirer_alias = lambda rapport, journal=None: retires.append( - [e.get("alias") for e in rapport["etages"]] + moteur.retirer_alias = ( + lambda rapport, journal=None, nom_base="": retires.append( + [e.get("alias") for e in rapport["etages"]] + ) ) self._lancer(etage1_ok=True) self.assertEqual(len(retires), 1) @@ -1113,12 +1329,13 @@ class TestLeDecompteDeLaDestruction(unittest.TestCase): # La VRAIE fonction : setUp en a posé un bouchon pour les autres # tests de cette classe. self._vrais["retirer_alias"]( - { + nom_base=self.dp.NOM_BASE, + rapport={ "etages": [ {"niveau": 1, "alias": "deep-pve-1"}, {"niveau": 2, "parent_alias": "deep-pve-1"}, ] - } + }, ) self.assertEqual( vus["drop"], From 8ff88f03d2dcc95d56cea323c4ee7de1709c86a3 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 02:43:53 -0400 Subject: [PATCH 22/26] =?UTF-8?q?[ADD]=20long=5Ftest=20:=20deep=5Fqemu,=20?= =?UTF-8?q?et=20la=20preuve=20que=20KVM=20est=20bien=20l=C3=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le pendant de deep_proxmox : des QEMU dans des QEMU. Le ralentissement du quatrième étage vient du PROCESSEUR, mais le coût par étage vient de ce qu'on installe — un nœud Proxmox pose un noyau, corosync, ceph et une interface web là où un hôte libvirt pose libvirtd. Les deux mesures ensemble séparent ce qui tient au matériel de ce qui tient à la pile. Ce test ne peut pas se contenter de descendre. deploy_qemu.py ne passe jamais « --cpu host-passthrough » et, quand /dev/kvm manque, il n'échoue PAS : il pose « --virt-type qemu », avertit sur une ligne et crée une VM entièrement ÉMULÉE — sept minutes et demie de démarrage, aucun code de retour pour le dire. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication, et rendrait un chiffre plus flatteur et faux. Chaque étage doit donc PROUVER : /dev/kvm lisible, « nested » à Y, et le domaine de l'enfant en type='kvm'. Ce qui n'a pas été lu vaut NON — un /sys/module absent, c'est un module non chargé, pas une permission. nesting_plan reçoit ses coûts : les constantes vCPU décrivent la physique de l'imbrication et valent pour les deux piles, les six nombres qui chiffrent un Proxmox non. Un étage QEMU demande 2 Go et 20 Go, contre 4 et 25. 26 tests, six garde-fous morts sous mutation. --- EN --- The counterpart to deep_proxmox: QEMU inside QEMU. The fourth level's slowdown comes from the PROCESSOR, but the per-level cost comes from what you install — a Proxmox node lays down a kernel, corosync, ceph and a web UI where a libvirt host lays down libvirtd. Together the two measurements separate what is due to the hardware from what is due to the stack. This test cannot merely descend. deploy_qemu.py never passes "--cpu host-passthrough" and, when /dev/kvm is missing, it does NOT fail: it sets "--virt-type qemu", warns on one line and creates a fully EMULATED VM — seven and a half minutes to boot, no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting, and return a more flattering, false number. Every level must therefore PROVE: /dev/kvm readable, "nested" at Y, and the child's domain type='kvm'. What was not read counts as NO — an absent /sys/module means an unloaded module, not a permission problem. nesting_plan takes its costs: the vCPU constants describe the physics of nesting and hold for both stacks, the six numbers that price a Proxmox do not. A QEMU level asks 2 GB and 20 GB against 4 and 25. 26 tests, six guards die under mutation. Assisted-by: claude-opus-5 (cherry picked from commit 39682cb1ce9648db91261387cae88c40c2a67837) --- long_test/deep_proxmox.py | 76 +------ long_test/deep_qemu.py | 442 +++++++++++++++++++++++++++++++++++++ long_test/descente.py | 78 +++++++ script/proxmox/nesting.py | 69 +++++- test/test_deep_qemu.py | 333 ++++++++++++++++++++++++++++ test/test_todo_longtest.py | 9 +- 6 files changed, 926 insertions(+), 81 deletions(-) create mode 100644 long_test/deep_qemu.py create mode 100644 test/test_deep_qemu.py diff --git a/long_test/deep_proxmox.py b/long_test/deep_proxmox.py index 3569855..c440314 100755 --- a/long_test/deep_proxmox.py +++ b/long_test/deep_proxmox.py @@ -28,8 +28,6 @@ le même défaut sur trois VM de suite. ./long_test/deep_proxmox.py --detruire # défaire ce qui a été posé """ -import argparse -import json import os import re import shlex @@ -41,12 +39,12 @@ RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) sys.path.insert(0, os.path.join(RACINE, "long_test")) -from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 import descente # noqa: E402 from descente import ( # noqa: E402,F401 DELAIS, + mener, _lance_une_descente, Famille, a_defaire, @@ -402,74 +400,12 @@ FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une) def principal(argv=None): - parseur = argparse.ArgumentParser( - description="Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?" + return mener( + argv, + "Jusqu'à quel étage un Proxmox dans un Proxmox tient-il ?", + FAMILLE, + Descente, ) - # Trois par défaut, et c'est une MESURE, pas une prudence : les trois - # premiers étages coûtent 280, 495 et 1 064 secondes — une demi-heure en - # tout. Le quatrième en a coûté 7 h 18 d'installation et 4 h 20 d'amorçage - # sur la même machine. Un défaut à dix promettait ce qu'aucune machine ne - # peut tenir ; la profondeur reste un paramètre, et c'est à qui la demande - # de savoir ce qu'il demande. - parseur.add_argument("--depth", type=int, default=3) - parseur.add_argument("--dry-run", action="store_true") - parseur.add_argument("--detruire", action="store_true") - args = parseur.parse_args(argv) - - journal = os.path.expanduser( - f"~/.erplibre/longtest/deep-pve-{time.strftime('%Y%m%d-%H%M%S')}.log" - ) - os.makedirs(os.path.dirname(journal), exist_ok=True) - if args.detruire: - # « --dry-run » était ignoré ici : la prudence naturelle avant une - # destruction détruisait pour de vrai. - return detruire(FAMILLE, journal, dry_run=args.dry_run) - - coeurs, ram, disque = capacite_hote() - print( - f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," - f" {disque} Go de disque" - ) - plan = nesting.nesting_plan(args.depth, coeurs, ram, disque) - print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") - for n in plan["niveaux"]: - print( - f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" - f" {n['disque']:>5} Go" - ) - if plan["arret"]: - # Les TROIS plafonds, pas seulement celui qui borne : sans eux on - # ajoute la ressource nommée sans savoir de combien, ni laquelle - # bornera ensuite. - plafonds = " · ".join( - f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() - ) - print( - f"\n ⚠ demandée {plan['demandee']}, atteignable" - f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" - f"\n profondeur permise par chaque ressource : {plafonds}" - ) - if not plan["niveaux"]: - if args.depth < 1: - print(f"\n profondeur demandée : {args.depth} — rien à faire.\n") - return 0 - print("\n ✗ pas même un étage ne tient sur cette machine.\n") - return 1 - print(f"\n journal : {journal}") - if args.dry_run: - print(" --dry-run : rien ne sera créé.\n") - chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") - descente = Descente(plan, journal, args.dry_run, chemin) - rapport = descente.parcourir() - with open(chemin, "w", encoding="utf-8") as fh: - json.dump(rapport, fh, indent=2) - print(f"\n rapport : {chemin}\n") - # En essai à blanc, c'est le PLAN qui est complet ou non — aucune - # profondeur n'a été atteinte. Hors essai, « non nul » ne suffisait pas : - # une descente morte au deuxième étage sur dix rendait 0. - if args.dry_run: - return 0 if rapport["atteignable"] == rapport["demandee"] else 1 - return 0 if rapport["atteinte"] == rapport["demandee"] else 1 if __name__ == "__main__": diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py new file mode 100644 index 0000000..a7b2466 --- /dev/null +++ b/long_test/deep_qemu.py @@ -0,0 +1,442 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Jusqu'à quel étage une QEMU dans une QEMU tient-elle ? + +Le pendant de `deep_proxmox.py`, et sa raison d'être : le ralentissement du +quatrième étage vient du PROCESSEUR — de ce que coûte une sortie de VM sous +pagination imbriquée — mais le coût par étage, lui, vient de ce qu'on installe. +Un nœud Proxmox pose un noyau, corosync, ceph et une interface web ; un hôte +libvirt nu pose libvirtd et qemu-kvm. Les deux mesures ensemble séparent ce qui +tient au matériel de ce qui tient à la pile, deux choses que la seule mesure +Proxmox confond. + +CE QUE CE TEST DOIT PROUVER AVANT DE MESURER + +`deploy_qemu.py` ne passe jamais « --cpu host-passthrough » : il s'en remet au +défaut de virt-install. Et quand /dev/kvm manque, il n'échoue pas — il pose +« --virt-type qemu », avertit sur une ligne, et crée une VM ENTIÈREMENT ÉMULÉE. +Un étage émulé démarre en sept minutes et demie au lieu de quelques secondes, +et rien dans le code de retour ne le dit. + +Sans garde, ce script mesurerait donc de la TCG empilée en croyant mesurer de +la virtualisation imbriquée — et rendrait un chiffre plus flatteur, et faux. +D'où le contrôle de chaque étage : /dev/kvm lisible, « nested » à Y, et le +domaine de l'enfant en type='kvm' avec un CPU host-passthrough. Un étage qui +échoue à cela arrête la descente au lieu de la prolonger dans le vide. + + ./long_test/deep_qemu.py # trois étages + ./long_test/deep_qemu.py --depth 5 # en demander plus, sciemment + ./long_test/deep_qemu.py --dry-run # le plan, rien de créé + ./long_test/deep_qemu.py --detruire # défaire ce qui a été posé +""" + +import os +import re +import shlex +import subprocess +import sys + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) +sys.path.insert(0, os.path.join(RACINE, "long_test")) + +from script.proxmox import nesting # noqa: E402 +from script.proxmox import proxmox_deploy as pve # noqa: E402 + +import descente # noqa: E402 +from descente import ( # noqa: E402,F401 + DELAIS, + Famille, + a_defaire, + alias_etage as _alias_etage, + autre_descente, + capacite_hote, + cle_publique, + dernier_rapport, + descente_vivante, + detruire, + detruire_etage1, + dire, + identite_de, + mener, + module_qemu, + nom_etage as _nom_etage, + retirer_alias, +) + +# Une Debian nue : c'est elle qui recevra libvirt et qemu-kvm. +DISTRO = "debian" +NOM_BASE = "deep-qemu" +OUTIL = "deep_qemu" + +# Le script est envoyé par scp, comme install_proxmox.sh l'est pour Proxmox : +# c'est NOTRE code qu'on veut éprouver, et il n'importe que la bibliothèque +# standard, donc il tourne dans un invité nu sans rien d'autre. +LOCAL_CLI = "script/qemu/deploy_qemu.py" +DISTANT_CLI = "/tmp/deploy_qemu.py" +CLE_DISTANTE = "/root/.ssh/longtest.pub" + +# Trois faits, trois lignes, et l'ABSENCE d'une ligne vaut « non ». Écrit pour +# dash : /bin/sh sur Debian n'est pas bash. +CONTROLE_CMD = ( + "if [ -r /dev/kvm ]; then echo KVM=oui; else echo KVM=non; fi; " + "cat /sys/module/kvm_amd/parameters/nested 2>/dev/null" + " | sed s/^/NESTED=/; " + "cat /sys/module/kvm_intel/parameters/nested 2>/dev/null" + " | sed s/^/NESTED=/; " + "df --output=avail -BG /var/lib/libvirt/images 2>/dev/null" + " | tail -1 | sed s/^/DISQUE=/" +) + +RESEAU_CMD = ( + "virsh -c qemu:///system net-info default 2>&1 | sed s/^/NET:/; " + "systemctl is-active libvirtd 2>/dev/null | sed s/^/UNITE:/" +) + + +def nom_etage(niveau): + return _nom_etage(niveau, NOM_BASE) + + +def alias_etage(niveau, parent_alias): + return _alias_etage(niveau, parent_alias, NOM_BASE) + + +def parse_controle(texte): + """Ce que le contrôle a VU. Ce qui n'a pas été lu vaut « non ». + + L'absence d'une ligne n'est jamais un oui : si + /sys/module/kvm_amd/parameters/nested n'existe pas, c'est que le module + n'est pas chargé, et l'étage suivant serait émulé. + """ + propre = pve.strip_ssh_noise(texte or "") + nested = re.search(r"^NESTED=(\S+)", propre, re.M) + disque = re.search(r"^DISQUE=\s*(\d+)", propre, re.M) + return { + "kvm": bool(re.search(r"^KVM=oui\s*$", propre, re.M)), + # « Y » ou « 1 » selon les versions du module. + "nested": bool(nested and nested.group(1).strip() in ("Y", "1")), + "disque_go": int(disque.group(1)) if disque else 0, + } + + +def parse_reseau(texte): + """Le réseau libvirt « default » est-il actif, et libvirtd debout ?""" + propre = pve.strip_ssh_noise(texte or "") + actif = re.search(r"^NET:\s*Active:\s*(\S+)", propre, re.M | re.I) + unite = re.search(r"^UNITE:(\S+)", propre, re.M) + return { + "reseau": bool(actif and actif.group(1).lower() == "yes"), + "libvirtd": bool(unite and unite.group(1).strip() == "active"), + } + + +def parse_domifaddr(texte): + """La première adresse IPv4 d'un domaine, sans son masque, ou "". + + virsh écrit un tableau ; on ne prend que les lignes qui annoncent « ipv4 », + et jamais la ligne d'en-tête ni les tirets. + """ + for ligne in pve.strip_ssh_noise(texte or "").splitlines(): + champs = ligne.split() + if len(champs) >= 4 and champs[2].lower() == "ipv4": + return champs[3].split("/")[0] + return "" + + +def parse_domaine(xml): + """Le domaine tourne-t-il sous KVM, et son CPU passe-t-il l'hôte ? + + Les deux comptent, et pour la même raison : un domaine type='qemu' est + ÉMULÉ, et un CPU qui ne passe pas les drapeaux de l'hôte ne porte pas la + virtualisation — l'étage suivant serait émulé à son tour, sept minutes et + demie de démarrage, sans qu'aucun code de retour ne le dise. + """ + propre = pve.strip_ssh_noise(xml or "") + domaine = re.search(r"]*\btype=['\"](\w+)['\"]", propre) + cpu = re.search(r"]*\bmode=['\"]([\w-]+)['\"]", propre) + return { + "type": domaine.group(1) if domaine else "", + "cpu": cpu.group(1) if cpu else "", + } + + +class Descente(descente.Descente): + """Les verbes de QEMU/KVM. Le reste est dans `descente.Descente`.""" + + OUTIL = OUTIL + NOM_BASE = NOM_BASE + DISTRO = DISTRO + + # ------------------------------------------------------------------ # + def _envoyer_cli(self, hote): + """Pose NOTRE deploy_qemu.py sur l'hôte. Rend True s'il y est. + + Refait à chaque besoin plutôt qu'une fois : /tmp est vidé au + démarrage sur bien des systèmes, et l'installation redémarre. + """ + local = os.path.join(RACINE, LOCAL_CLI) + if self.dry_run: + print(f" scp {local} :{DISTANT_CLI}") + return True + argv = pve.ssh_argv(hote, "")[:-1] # les options, sans la commande + cible = argv[-1] + options = argv[1:-1] + res = subprocess.run( + ["scp", "-q"] + options + [local, f"{cible}:{DISTANT_CLI}"], + capture_output=True, + text=True, + timeout=300, + ) + if res.returncode: + self.dire(f" ✗ scp : {res.stderr.strip()[:200]}") + return False + return True + + def installer(self, hote): + """libvirt et qemu-kvm, par « --setup-host ». + + Le code de retour ne prouve RIEN ici : « --setup-host » rend 0 même + quand il s'est contenté de PROGRAMMER un redémarrage. C'est l'étape + suivante — redémarrer et constater que la machine est revenue — qui + prouve quelque chose, et le contrôle de fin d'étage qui prouve que KVM + est là. + """ + if self.dry_run: + print(f" {DISTANT_CLI} --setup-host") + return True + if not self._envoyer_cli(hote): + return False + code, _o = self.executer( + hote, + f"python3 {DISTANT_CLI} --setup-host --assume-yes", + self.delai("install"), + "deploy_qemu --setup-host", + montrer=True, + ) + return code == 0 + + def noyau_convient(self, noyau): + """Tout noyau convient : c'est le REDÉMARRAGE qui compte, pas ce + qu'on redémarre. + + Il charge les modules KVM et applique l'appartenance au groupe + libvirt, qui ne prend effet qu'à la SESSION SUIVANTE — sans lui, + virt-install retombe sur qemu:///session, où le réseau « default » + n'existe pas. Le moteur prouve déjà que la machine a vraiment + redémarré en comparant l'instant de démarrage. + """ + return bool(noyau) + + def remettre_debout(self, hote): + """libvirtd actif et le réseau « default » démarré.""" + if self.dry_run: + print(" libvirtd + réseau default") + return True + # Idempotent : sur un hôte déjà en ordre, les deux commandes ne font + # rien et rendent 0. + self.executer( + hote, + "systemctl enable --now libvirtd 2>/dev/null;" + " virsh -c qemu:///system net-start default 2>/dev/null;" + " virsh -c qemu:///system net-autostart default 2>/dev/null; true", + self.delai("reparation"), + "libvirtd", + ) + _c, out = self.executer( + hote, RESEAU_CMD, self.delai("controle"), "réseau" + ) + vu = parse_reseau(out) + self.dire( + f" libvirtd {'actif' if vu['libvirtd'] else 'ABSENT'}," + f" réseau default {'actif' if vu['reseau'] else 'ABSENT'}" + ) + return vu["libvirtd"] and vu["reseau"] + + def controler(self, hote): + """CET étage peut-il héberger le suivant SANS l'émuler ? + + Le contrôle qui donne son sens à la mesure. Sans lui, un étage sans + KVM ne casse pas : il bascule en émulation et continue. La descente + irait plus « profond » en mesurant tout autre chose — de la TCG + empilée, pas de la virtualisation imbriquée. + """ + if self.dry_run: + print(" /dev/kvm + nested=Y + place disque") + return True + code, out = self.executer( + hote, CONTROLE_CMD, self.delai("controle"), "kvm" + ) + if code: + self.dire(" ✗ contrôle KVM illisible : rien conclu") + return False + vu = parse_controle(out) + self.dire( + f" /dev/kvm {'oui' if vu['kvm'] else 'NON'}," + f" nested {'oui' if vu['nested'] else 'NON'}," + f" {vu['disque_go']} Go libres" + ) + if not vu["kvm"]: + self.dire(" ✗ pas de /dev/kvm : l'étage suivant serait ÉMULÉ") + return False + if not vu["nested"]: + self.dire( + " ✗ virtualisation imbriquée absente :" + " l'étage suivant serait ÉMULÉ" + ) + return False + return True + + def preparer_parent(self, parent): + """Ce qu'il faut du parent : son réseau. C'est tout. + + Rien à construire, contrairement à Proxmox, où il faut poser un pont + et un NAT dans /etc/network/interfaces : libvirt fournit déjà + « default », avec NAT, bail DHCP ET résolveur dnsmasq. Le défaut qui a + coûté cher là-bas — une VM en adresse fixe qui route mais ne résout + rien, et une installation qui meurt sur « apt update » sans que rien + ne l'explique — ne peut pas se produire ici. + """ + if self.dry_run: + print(" réseau default du parent") + return ("default",) + code, out = self.executer( + parent, RESEAU_CMD, self.delai("controle"), "réseau" + ) + if code: + self.dire(" ✗ état du réseau illisible : rien conclu") + return None + vu = parse_reseau(out) + if not vu["reseau"]: + self.dire( + " ✗ le réseau « default » du parent n'est pas actif" + ) + return None + return ("default",) + + def creer_enfant(self, parent, niveau, res, prepare, noter=None): + """Une VM dans le parent, par NOTRE deploy_qemu.py. + + L'ordre compte, et il n'est pas celui de Proxmox. Là-bas l'adresse est + exigée AVANT la création (« --ipconfig0 ») ; ici libvirt ne la donne + qu'APRÈS le démarrage. On note donc l'identité — le nom du domaine, + qui est déterminé — avant la première commande qui peut créer quoi que + ce soit, faute de quoi une création échouée à mi-chemin laisserait une + machine que le rapport ne nomme nulle part. + """ + (reseau,) = prepare + nom = self.nom_etage(niveau) + if noter: + noter(nom) + if not self._envoyer_cli(parent): + return None, None + pub = cle_publique() + if pub and not self.dry_run: + with open(pub, encoding="utf-8") as fh: + contenu = fh.read().strip() + self.executer( + parent, + f"mkdir -p /root/.ssh && printf '%s\\n'" + f" {shlex.quote(contenu)} > {CLE_DISTANTE}", + DELAIS["controle"], + "clé", + ) + creation = ( + f"python3 {DISTANT_CLI} --distro {DISTRO} --name {nom}" + f" --vcpus {res['vcpu']} --memory {res['ram']}" + f" --disk-size {res['disque']}G --network network={reseau}" + f" --ssh-key {CLE_DISTANTE} --assume-yes" + ) + code, _o = self.executer( + parent, + creation, + self.delai("creation"), + "deploy_qemu", + montrer=True, + ) + if code and not self.dry_run: + return None, None + if self.dry_run: + return nom, "10.10.10.150" + # Le domaine est-il vraiment accéléré ? « deploy_qemu » n'échoue PAS + # quand KVM manque : il pose --virt-type qemu et continue. Un étage + # émulé fausserait toute la mesure sans rien dire. + _c, xml = self.executer( + parent, + f"virsh -c qemu:///system dumpxml {nom}", + self.delai("controle"), + "dumpxml", + ) + vu = parse_domaine(xml) + if vu["type"] != "kvm": + self.dire( + f" ✗ domaine type='{vu['type'] or '?'}' : cette VM est" + " ÉMULÉE, la mesure ne voudrait rien dire" + ) + return None, None + self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}") + _c, sortie = self.executer( + parent, + f"virsh -c qemu:///system domifaddr {nom} --source lease", + self.delai("ssh"), + "domifaddr", + ) + adresse = parse_domifaddr(sortie) + if not adresse: + self.dire(" ✗ créée, mais sans adresse : rien à joindre") + return None, None + self.dire(f" {nom} : {adresse}") + return nom, adresse + + +def detruire_une(parent_alias, identite, nom, journal): + """Arrête puis détruit UNE VM chez son parent, par son NOM et son UUID. + + Par égalité STRICTE du nom : un filtre par sous-chaîne aurait pris une + « deep-qemu-lab » de production, et « --remove-all-storage » efface un + disque pour de bon. + """ + parent = {"target": parent_alias, "sudo": "sudo ", "jump": ""} + code, out = pve.run( + parent, "virsh -c qemu:///system list --all --name", 180 + ) + if code: + dire(f" ✗ {parent_alias} injoignable : rien touché", journal) + return False + presents = [ + ligne.strip() + for ligne in pve.strip_ssh_noise(out).splitlines() + if ligne.strip() + ] + if nom not in presents: + dire(f" — {nom} : absent de {parent_alias}", journal) + return True + pve.run(parent, f"virsh -c qemu:///system destroy {nom}", 300) + code, _o = pve.run( + parent, + f"virsh -c qemu:///system undefine {nom} --nvram --remove-all-storage", + 600, + ) + if code: + dire(f" ✗ {nom} sur {parent_alias} : undefine a échoué", journal) + return False + dire(f" ✓ {nom} ({identite}) sur {parent_alias}", journal) + return True + + +FAMILLE = Famille(OUTIL, NOM_BASE, detruire_une) + + +def principal(argv=None): + return mener( + argv, + "Jusqu'à quel étage une QEMU dans une QEMU tient-elle ?", + FAMILLE, + Descente, + nesting.COUTS_QEMU, + ) + + +if __name__ == "__main__": + sys.exit(principal()) diff --git a/long_test/descente.py b/long_test/descente.py index 7ea17fe..827c8a5 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -19,6 +19,7 @@ remettre les services debout, comment contrôler qu'un étage peut héberger le suivant. Le reste est ici, écrit une fois. """ +import argparse import json import os import re @@ -30,6 +31,7 @@ import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) +from script.proxmox import nesting # noqa: E402 from script.proxmox import proxmox_deploy as pve # noqa: E402 # Les scripts qui lancent une descente. Le verrou les cherche TOUS : deux @@ -684,6 +686,82 @@ def autre_descente(): return vivants +def mener(argv, description, famille, classe, couts=None): + """La ligne de commande, le plan, la descente et le rapport. + + Identique d'une pile à l'autre à quatre choses près : ce qu'on annonce, la + famille — qui nomme les fichiers et filtre les rapports —, la classe qui + porte les verbes, et ce que coûte un étage. + """ + parseur = argparse.ArgumentParser(description=description) + # Trois par défaut, et c'est une MESURE, pas une prudence : les trois + # premiers étages coûtent 280, 495 et 1 064 secondes — une demi-heure en + # tout. Le quatrième en a coûté 7 h 18 d'installation et 4 h 20 d'amorçage + # sur la même machine. Un défaut à dix promettait ce qu'aucune machine ne + # peut tenir ; la profondeur reste un paramètre, et c'est à qui la demande + # de savoir ce qu'il demande. + parseur.add_argument("--depth", type=int, default=3) + parseur.add_argument("--dry-run", action="store_true") + parseur.add_argument("--detruire", action="store_true") + args = parseur.parse_args(argv) + + journal = os.path.expanduser( + f"~/.erplibre/longtest/{famille.nom_base}" + f"-{time.strftime('%Y%m%d-%H%M%S')}.log" + ) + os.makedirs(os.path.dirname(journal), exist_ok=True) + if args.detruire: + # « --dry-run » était ignoré ici : la prudence naturelle avant une + # destruction détruisait pour de vrai. + return detruire(famille, journal, dry_run=args.dry_run) + + coeurs, ram, disque = capacite_hote() + print( + f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f" {disque} Go de disque" + ) + plan = nesting.nesting_plan(args.depth, coeurs, ram, disque, couts) + print(f"\n {'étage':>5} {'vCPU':>4} {'RAM':>9} {'disque':>8}") + for n in plan["niveaux"]: + print( + f" {n['niveau']:>5} {n['vcpu']:>4} {n['ram']:>6} Mo" + f" {n['disque']:>5} Go" + ) + if plan["arret"]: + # Les TROIS plafonds, pas seulement celui qui borne : sans eux on + # ajoute la ressource nommée sans savoir de combien, ni laquelle + # bornera ensuite. + plafonds = " · ".join( + f"{nom} {valeur}" for nom, valeur in plan["plafonds"].items() + ) + print( + f"\n ⚠ demandée {plan['demandee']}, atteignable" + f" {plan['atteignable']} — c'est le {plan['arret']} qui borne" + f"\n profondeur permise par chaque ressource : {plafonds}" + ) + if not plan["niveaux"]: + if args.depth < 1: + print(f"\n profondeur demandée : {args.depth} — rien à faire.\n") + return 0 + print("\n ✗ pas même un étage ne tient sur cette machine.\n") + return 1 + print(f"\n journal : {journal}") + if args.dry_run: + print(" --dry-run : rien ne sera créé.\n") + chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") + descente = classe(plan, journal, args.dry_run, chemin) + rapport = descente.parcourir() + with open(chemin, "w", encoding="utf-8") as fh: + json.dump(rapport, fh, indent=2) + print(f"\n rapport : {chemin}\n") + # En essai à blanc, c'est le PLAN qui est complet ou non — aucune + # profondeur n'a été atteinte. Hors essai, « non nul » ne suffisait pas : + # une descente morte au deuxième étage sur dix rendait 0. + if args.dry_run: + return 0 if rapport["atteignable"] == rapport["demandee"] else 1 + return 0 if rapport["atteinte"] == rapport["demandee"] else 1 + + class Famille: """Ce qu'une pile doit dire d'elle aux fonctions qui détruisent. diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 4c2c979..14cab13 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -144,11 +144,61 @@ HOTE_RESERVE_VCPU = 2 PROFONDEUR_SURE = 2 +class Couts: + """Ce que coûte UN étage de la pile qu'on empile. + + Les constantes vCPU décrivent la physique de l'imbrication — la pagination + imbriquée frappe un QEMU nu comme un nœud Proxmox — et valent donc pour + toutes les piles. Ces six nombres-ci, non : cinq démons PVE demandent + 2 Gio quand libvirtd seul tient dans un, et un nœud Proxmox occupe 5,6 Go + de disque contre 3 pour une Debian et qemu-kvm. + """ + + def __init__( + self, + ram_par_etage, + disque_par_etage, + ram_cible, + disque_cible, + ram_min, + disque_min, + ): + self.ram_par_etage = ram_par_etage + self.disque_par_etage = disque_par_etage + self.ram_cible = ram_cible + self.disque_cible = disque_cible + self.ram_min = ram_min + self.disque_min = disque_min + + +COUTS_PVE = Couts( + ram_par_etage=PVE_RAM_MO, + disque_par_etage=PVE_DISQUE_GO, + ram_cible=PVE_RAM_CIBLE_MO, + disque_cible=PVE_DISQUE_CIBLE_GO, + ram_min=RAM_MIN_MO, + disque_min=DISQUE_MIN_GO, +) + +# Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine +# n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer +# son enfant — d'où un disque cible qui n'est pas si petit. +COUTS_QEMU = Couts( + ram_par_etage=1024, + disque_par_etage=6, + ram_cible=2048, + disque_cible=20, + ram_min=1024, + disque_min=12, +) + + def nesting_plan( profondeur: int, cpu_hote: int, ram_dispo_mo: int, disque_libre_go: int, + couts: "Couts" = None, ) -> dict: """Les ressources de chaque étage, dimensionnées DEPUIS LE BAS. @@ -180,6 +230,7 @@ def nesting_plan( étages et en réussir six que d'en promettre dix et mourir au septième sans savoir pourquoi. """ + couts = couts or COUTS_PVE reserve = max(HOTE_RESERVE_RAM_MO, int(ram_dispo_mo) // HOTE_RESERVE_PART) budget_ram = ((int(ram_dispo_mo) - reserve) // 1024) * 1024 budget_disque = int(disque_libre_go) - HOTE_RESERVE_DISQUE_GO @@ -194,8 +245,8 @@ def nesting_plan( quelle que soit la profondeur. """ return ( - PVE_RAM_CIBLE_MO + (d - 1) * PVE_RAM_MO, - PVE_DISQUE_CIBLE_GO + (d - 1) * PVE_DISQUE_GO, + couts.ram_cible + (d - 1) * couts.ram_par_etage, + couts.disque_cible + (d - 1) * couts.disque_par_etage, VCPU_METAL if d > 1 else VCPU_IMBRIQUE, ) @@ -205,8 +256,10 @@ def nesting_plan( # coût suivait la profondeur demandée — nesting_plan(10**6, …) tournait un # million de tours pour rendre le même plan. plafonds = { - "ram": (budget_ram - PVE_RAM_CIBLE_MO) // PVE_RAM_MO + 1, - "disque": (budget_disque - PVE_DISQUE_CIBLE_GO) // PVE_DISQUE_GO + 1, + "ram": (budget_ram - couts.ram_cible) // couts.ram_par_etage + 1, + "disque": (budget_disque - couts.disque_cible) + // couts.disque_par_etage + + 1, # Le processeur ne borne plus la profondeur : les étages imbriqués # gardent une largeur fixe, seul le premier compte sur le métal. Il # borne encore à ZÉRO une machine trop petite pour ce premier étage. @@ -246,10 +299,10 @@ def nesting_plan( # quelqu'un baisse une CIBLE un jour. Sans eux, ils n'étaient plus # lus par personne et les tests qui les vérifiaient passaient # d'eux-mêmes. - "ram": max(RAM_MIN_MO, PVE_RAM_CIBLE_MO) - + (atteignable - niveau) * PVE_RAM_MO, - "disque": max(DISQUE_MIN_GO, PVE_DISQUE_CIBLE_GO) - + (atteignable - niveau) * PVE_DISQUE_GO, + "ram": max(couts.ram_min, couts.ram_cible) + + (atteignable - niveau) * couts.ram_par_etage, + "disque": max(couts.disque_min, couts.disque_cible) + + (atteignable - niveau) * couts.disque_par_etage, } for niveau in range(1, atteignable + 1) ] diff --git a/test/test_deep_qemu.py b/test/test_deep_qemu.py new file mode 100644 index 0000000..859453b --- /dev/null +++ b/test/test_deep_qemu.py @@ -0,0 +1,333 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +"""Le test long QEMU-dans-QEMU, et le garde qui donne un sens à sa mesure. + +`deploy_qemu.py` n'échoue PAS quand KVM manque : il pose « --virt-type qemu » +et crée une VM entièrement émulée, sept minutes et demie de démarrage, sans +qu'aucun code de retour ne le dise. Une descente qui ne le vérifierait pas +irait plus « profond » en mesurant de la TCG empilée — un chiffre plus +flatteur, et faux. + +Ces tests-ci ne créent aucune machine : ils lisent des sorties de `virsh` et de +`/sys` telles qu'elles arrivent vraiment, et vérifient ce qu'on en conclut. +""" + +import contextlib +import io +import os +import sys +import unittest + +RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, RACINE) +sys.path.insert(0, os.path.join(RACINE, "long_test")) +sys.argv = ["todo.py"] + +import deep_qemu # noqa: E402 + + +class TestLireCeQueVirshEcrit(unittest.TestCase): + """Des sorties RÉELLES, prises sur la machine, pas inventées.""" + + def test_the_address_table_has_a_header_and_a_mask(self): + vrai = ( + " Name MAC address Protocol Address\n" + "----------------------------------------------------------\n" + " vnet3 52:54:00:79:78:a4 ipv4 192.168.123.118/24\n" + ) + self.assertEqual(deep_qemu.parse_domifaddr(vrai), "192.168.123.118") + + def test_a_domain_without_a_lease_yet_gives_nothing(self): + vide = ( + " Name MAC address Protocol Address\n" + "----------------------------------------------------------\n" + " vnet0 52:54:00:aa:bb:cc N/A N/A\n" + ) + self.assertEqual(deep_qemu.parse_domifaddr(vide), "") + self.assertEqual(deep_qemu.parse_domifaddr(""), "") + self.assertEqual(deep_qemu.parse_domifaddr(None), "") + + def test_an_emulated_domain_is_recognised(self): + emule = "\n deep-qemu-2\n" + self.assertEqual(deep_qemu.parse_domaine(emule)["type"], "qemu") + + def test_an_accelerated_domain_and_its_cpu_mode(self): + vrai = ( + "\n" + " deep-qemu-2\n" + " \n" + ) + vu = deep_qemu.parse_domaine(vrai) + self.assertEqual(vu["type"], "kvm") + self.assertEqual(vu["cpu"], "host-passthrough") + + def test_an_unreadable_dumpxml_claims_nothing(self): + vu = deep_qemu.parse_domaine("error: failed to get domain") + self.assertEqual(vu["type"], "") + self.assertEqual(vu["cpu"], "") + + +class TestUnEtageQuiNeSaitPasHeberger(unittest.TestCase): + """Ce qui n'a pas été lu vaut NON. + + Si /sys/module/kvm_amd/parameters/nested n'existe pas, c'est que le module + n'est pas chargé — et l'étage suivant serait émulé. Traiter l'absence + comme un oui rendrait le contrôle décoratif.""" + + def test_a_missing_nested_line_is_not_a_yes(self): + vu = deep_qemu.parse_controle("KVM=oui\nDISQUE=120G\n") + self.assertTrue(vu["kvm"]) + self.assertFalse(vu["nested"]) + self.assertEqual(vu["disque_go"], 120) + + def test_both_spellings_of_yes_are_accepted(self): + for valeur in ("Y", "1"): + with self.subTest(valeur=valeur): + vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n") + self.assertTrue(vu["nested"]) + + def test_nested_off_is_read_as_off(self): + for valeur in ("N", "0"): + with self.subTest(valeur=valeur): + vu = deep_qemu.parse_controle(f"KVM=oui\nNESTED={valeur}\n") + self.assertFalse(vu["nested"]) + + def test_no_kvm_device_at_all(self): + vu = deep_qemu.parse_controle("KVM=non\nNESTED=Y\nDISQUE=50G\n") + self.assertFalse(vu["kvm"]) + + def test_an_empty_answer_asserts_nothing(self): + vu = deep_qemu.parse_controle("") + self.assertFalse(vu["kvm"]) + self.assertFalse(vu["nested"]) + self.assertEqual(vu["disque_go"], 0) + + def test_the_probe_is_written_for_dash(self): + """/bin/sh est dash sur Debian : « set -o pipefail » y répond + « Illegal option » et sort à la première ligne.""" + for interdit in ("[[", "pipefail", "$(", "&&\n"): + self.assertNotIn(interdit, deep_qemu.CONTROLE_CMD, interdit) + + +class TestLeControleArreteLaDescente(unittest.TestCase): + """Un étage sans KVM ne casse pas : il bascule en émulation et continue. + C'est ce silence-là que le contrôle doit rompre.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 2 + + def _repond(self, sortie, code=0): + self.d.executer = lambda h, c, delai, etiquette="", **k: (code, sortie) + + def test_a_level_without_kvm_stops_the_descent(self): + self._repond("KVM=non\nNESTED=Y\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("serait ÉMULÉ", sortie.getvalue()) + + def test_a_level_without_nesting_stops_the_descent(self): + self._repond("KVM=oui\nNESTED=N\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("imbriquée absente", sortie.getvalue()) + + def test_an_unreadable_probe_concludes_nothing(self): + # Une lecture qui échoue ne dit pas « pas de KVM » : elle ne dit rien. + self._repond("", code=255) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.controler({"target": "h"})) + self.assertIn("illisible", sortie.getvalue()) + + def test_a_healthy_level_passes(self): + """Le contrôle NÉGATIF : sans lui, ce garde interdirait toute + descente.""" + self._repond("KVM=oui\nNESTED=Y\nDISQUE=90G\n") + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(self.d.controler({"target": "h"})) + + +class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): + """« deploy_qemu » rend 0 en créant une VM émulée. La descente doit s'en + apercevoir à la création, pas après sept minutes de démarrage.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 2 + self.d._envoyer_cli = lambda hote: True + + def _machine(self, xml, adresse=" x y ipv4 10.0.0.9/24"): + def executer(hote, cmd, delai, etiquette="", **k): + if "dumpxml" in cmd: + return 0, xml + if "domifaddr" in cmd: + return 0, adresse + return 0, "" + + self.d.executer = executer + + def test_an_emulated_child_is_refused(self): + self._machine("x") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + identite, adresse = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertIsNone(identite) + self.assertIsNone(adresse) + self.assertIn("ÉMULÉE", sortie.getvalue()) + + def test_an_accelerated_child_is_kept(self): + self._machine( + "x" + "" + ) + with contextlib.redirect_stdout(io.StringIO()): + identite, adresse = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertEqual(identite, "deep-qemu-2") + self.assertEqual(adresse, "10.0.0.9") + + def test_the_identity_is_noted_before_anything_is_created(self): + """Une création échouée à mi-chemin laisserait sinon une machine que + le rapport ne nomme nulle part — et que --detruire ne peut pas + défaire.""" + vus = [] + self._machine("") + with contextlib.redirect_stdout(io.StringIO()): + self.d.creer_enfant( + {"target": "p"}, + 4, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + noter=vus.append, + ) + # Notée, alors même que la création a été REFUSÉE ensuite. + self.assertEqual(vus, ["deep-qemu-4"]) + + def test_a_child_without_an_address_is_refused(self): + self._machine("", adresse=" x y N/A N/A") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + identite, _a = self.d.creer_enfant( + {"target": "p"}, + 2, + {"vcpu": 2, "ram": 2048, "disque": 20}, + ("default",), + ) + self.assertIsNone(identite) + self.assertIn("sans adresse", sortie.getvalue()) + + +class TestNeDetruireQueLeSien(unittest.TestCase): + """« virsh undefine --remove-all-storage » efface un disque pour de bon.""" + + def setUp(self): + self.vrai = deep_qemu.pve.run + self.addCleanup(setattr, deep_qemu.pve, "run", self.vrai) + self.lances = [] + + def _parent_avec(self, noms): + def faux(hote, remote, timeout=120): + self.lances.append(remote) + if "list --all --name" in remote: + return 0, "\n".join(noms) + "\n" + return 0, "" + + deep_qemu.pve.run = faux + + def test_a_name_that_merely_contains_ours_is_left_alone(self): + self._parent_avec(["deep-qemu-lab", "autre"]) + with contextlib.redirect_stdout(io.StringIO()): + res = deep_qemu.detruire_une( + "p", "deep-qemu-2", "deep-qemu-2", None + ) + self.assertTrue(res) # absente, donc rien à faire + self.assertFalse([c for c in self.lances if "undefine" in c]) + + def test_our_own_machine_is_stopped_then_undefined(self): + self._parent_avec(["deep-qemu-2"]) + with contextlib.redirect_stdout(io.StringIO()): + res = deep_qemu.detruire_une( + "p", "deep-qemu-2", "deep-qemu-2", None + ) + self.assertTrue(res) + ordre = [c for c in self.lances if "destroy" in c or "undefine" in c] + self.assertEqual(len(ordre), 2) + self.assertIn("destroy", ordre[0]) + self.assertIn("--remove-all-storage", ordre[1]) + + def test_an_unreachable_parent_touches_nothing(self): + deep_qemu.pve.run = lambda h, r, t=120: (255, "") + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse( + deep_qemu.detruire_une("p", "deep-qemu-2", "deep-qemu-2", None) + ) + self.assertIn("rien touché", sortie.getvalue()) + + +class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase): + """Ce qui doit être identique doit l'être, et ce qui doit différer aussi.""" + + def setUp(self): + import deep_proxmox + + self.pve, self.qemu = deep_proxmox, deep_qemu + + def test_they_share_one_engine(self): + import descente + + for module in (self.pve, self.qemu): + self.assertTrue(issubclass(module.Descente, descente.Descente)) + + def test_every_hook_is_implemented_by_both(self): + import descente + + crochets = ( + "preparer_parent", + "creer_enfant", + "installer", + "noyau_convient", + "remettre_debout", + "controler", + ) + for module in (self.pve, self.qemu): + for crochet in crochets: + self.assertIsNot( + getattr(module.Descente, crochet), + getattr(descente.Descente, crochet), + f"{module.OUTIL} n'implémente pas {crochet}", + ) + + def test_they_never_share_a_name_a_tool_or_a_report(self): + self.assertNotEqual(self.pve.OUTIL, self.qemu.OUTIL) + self.assertNotEqual(self.pve.NOM_BASE, self.qemu.NOM_BASE) + self.assertNotEqual( + self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une + ) + + def test_the_qemu_stack_asks_for_less(self): + """libvirtd seul tient dans un gibioctet là où cinq démons PVE en + demandent deux.""" + from script.proxmox import nesting + + pve = nesting.nesting_plan(3, 28, 39000, 150) + qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU) + self.assertLess(qemu["niveaux"][0]["ram"], pve["niveaux"][0]["ram"]) + self.assertLess( + qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"] + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index 03cc362..cc6fd5b 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -248,8 +248,10 @@ class TestLaProfondeurParDefaut(unittest.TestCase): _sys.path.insert(0, os.path.join(RACINE, "long_test")) import deep_proxmox - src = inspect.getsource(deep_proxmox.principal) + src = inspect.getsource(moteur.mener) self.assertIn('"--depth", type=int, default=3', src) + # Et les deux piles passent bien par là. + self.assertIn("mener(", inspect.getsource(deep_proxmox.principal)) def test_the_menu_defaults_to_three(self): import inspect @@ -352,8 +354,9 @@ class TestDefaireSansEffacerAutreChose(unittest.TestCase): # Une confirmation explicite, pas un « o/N » : le menu lançait cette # option d'une seule touche. self.assertIn("OUI", src) - principal = inspect.getsource(self.dp.principal) - self.assertIn("dry_run=args.dry_run", principal) + # La ligne de commande vit dans le moteur depuis qu'elle est + # identique d'une pile à l'autre. + self.assertIn("dry_run=args.dry_run", inspect.getsource(moteur.mener)) class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): From 032556544e030fe23be9c8bfb4560ed0430b1618 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 03:29:01 -0400 Subject: [PATCH 23/26] =?UTF-8?q?[ADD]=20long=5Ftest=20:=20partir=20d'un?= =?UTF-8?q?=20h=C3=B4te=20existant,=20et=20le=20menu=20des=20deux=20piles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà coûte cinq minutes ET un étage d'imbrication — donc de la lenteur, puisque c'est elle qu'on mesure. « --hote » part d'un hôte existant ; le menu le propose sans le rechercher, l'hôte Proxmox déjà retenu étant lu par _pve_host(ask=False). Trois conséquences que le code ne tirait pas : - le plan se dimensionne sur la RACINE, lue par ssh. Le dimensionner sur la machine locale quand les étages vivent ailleurs annoncerait des étages qui ne tiennent pas ; - les délais comptent la profondeur ABSOLUE. Un enfant de niveau 1 posé dans une racine déjà au troisième étage est en réalité au quatrième, et héritait de délais quatre fois trop courts — le défaut même que « delai » raconte avoir corrigé ; - la racine n'est pas un étage atteint. L'y compter décalait de un le total et le code de sortie ; elle va dans une clé à part, et jamais « cree ». « sudo » est DÉDUIT de « id -u » et non supposé, et une racine illisible fait renoncer au lieu d'inventer une capacité. Le menu offre les deux piles et défait chacune séparément — elles partagent le dossier des rapports mais chacune ne connaît que les siens. Un test vérifie que toute entrée affichée a son branchement : ils sont couplés par position, sans garde. 80 tests, quatre garde-fous morts sous mutation. --- EN --- Creating a head VM to host a hypervisor you already own costs five minutes AND one level of nesting — that is, slowness, which is the very thing being measured. "--hote" starts from an existing host; the menu offers it without searching, reading the already-chosen Proxmox host via _pve_host(ask=False). Three consequences the code did not draw: - the plan is sized on the ROOT, read over ssh. Sizing it on the local machine while the levels live elsewhere would announce levels that do not fit; - delays count ABSOLUTE depth. A level-1 child placed in a root already at the third level is really at the fourth, and inherited delays four times too short — the very defect "delai" recounts having fixed; - the root is not a level reached. Counting it shifted the total and the exit code by one; it goes in its own key, and never as "cree". "sudo" is DEDUCED from "id -u" rather than assumed, and an unreadable root makes us give up instead of inventing a capacity. The menu offers both stacks and undoes each separately — they share the report directory but each knows only its own. A test checks that every displayed entry has its branch: they are coupled by position, with no guard. 80 tests, four guards die under mutation. Assisted-by: claude-opus-5 (cherry picked from commit c2ab1a968346458925f55fc95619eb4ebd9d3efa) --- long_test/descente.py | 174 ++++++++++++++++++++++++++++-- script/todo/longtest_menu.py | 121 +++++++++++++++++---- script/todo/todo_i18n.py | 32 ++++++ test/test_todo_longtest.py | 198 +++++++++++++++++++++++++++++++++++ 4 files changed, 498 insertions(+), 27 deletions(-) diff --git a/long_test/descente.py b/long_test/descente.py index 827c8a5..eb34f07 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -85,6 +85,50 @@ def capacite_hote(): return coeurs, ram, disque +CAPACITE_CMD = ( + "nproc | sed s/^/COEURS=/; " + "grep MemAvailable /proc/meminfo | sed s/^/MEM=/; " + "df --output=avail -BG /var/lib/libvirt/images 2>/dev/null" + " | tail -1 | sed s/^/DISQUE=/" +) + + +def parse_capacite(texte): + """(cœurs, RAM en Mo, disque en Go) lus chez l'hôte, ou (0, 0, 0). + + Zéro quand la ligne manque, jamais une valeur inventée : un plan + dimensionné sur une capacité supposée annoncerait des étages qui ne + tiennent pas. + """ + propre = pve.strip_ssh_noise(texte or "") + + def lire(motif, diviseur=1): + trouve = re.search(motif, propre, re.M) + return int(trouve.group(1)) // diviseur if trouve else 0 + + return ( + lire(r"^COEURS=\s*(\d+)"), + # « MEM=MemAvailable: 7056288 kB » : le sed colle un « = », pas un + # deux-points. L'expression attendait le second et rendait zéro — un + # plan dimensionné sur zéro mébioctet n'annonce aucun étage. + lire(r"^MEM=MemAvailable:\s*(\d+)", 1024), + lire(r"^DISQUE=\s*(\d+)"), + ) + + +def capacite_distante(hote): + """Ce dont dispose la RACINE quand la descente en emprunte une. + + `capacite_hote()` lit la machine LOCALE. Partir d'un hôte distant sans + lire le sien dimensionnerait le plan d'après une machine qui n'héberge + rien — on annoncerait dix étages sur un serveur qui n'en porte pas deux. + """ + code, out = pve.run(dict(hote, sudo=""), CAPACITE_CMD, 120) + if code: + return 0, 0, 0 + return parse_capacite(out) + + def module_qemu(): """deploy_qemu.py chargé comme module : il porte le catalogue d'images.""" import importlib.util @@ -126,6 +170,12 @@ class Descente: demande à la pile qui en hérite — les six crochets plus bas. """ + # Une descente sans racine part d'une VM qu'elle crée elle-même : c'est + # le cas ordinaire, et ces défauts le disent au niveau de la CLASSE plutôt + # que du constructeur, pour qu'une instance montée à la main les ait aussi. + racine = None + profondeur_racine = 0 + # Ce que chaque pile déclare. OUTIL = "" # « deep_proxmox » : écrit au rapport, filtre --detruire NOM_BASE = "" # « deep-pve » : préfixe des noms de machines @@ -175,7 +225,25 @@ class Descente: def alias_etage(self, niveau, parent_alias): return alias_etage(niveau, parent_alias, self.NOM_BASE) - def __init__(self, plan, journal, dry_run=False, chemin_json=None): + def __init__( + self, + plan, + journal, + dry_run=False, + chemin_json=None, + racine=None, + profondeur_racine=0, + ): + # `racine` : un hôte qui EXISTE DÉJÀ, chez qui la descente s'installe + # au lieu de créer sa propre machine de tête. Il n'est pas un étage — + # il n'est pas compté, pas détruit, et son entrée ~/.ssh/config est + # celle de l'utilisateur. + self.racine = racine + # Sa profondeur d'imbrication à LUI. Sans elle, le premier enfant + # d'une racine déjà au troisième étage héritait des délais du premier : + # quatre fois trop courts, exactement le défaut que `delai` raconte + # avoir corrigé. + self.profondeur_racine = profondeur_racine self.plan = plan self.journal = journal self.chemin_json = chemin_json @@ -200,7 +268,11 @@ class Descente: d'hyperviseur à traverser, mais un facteur illimité rendrait un échec réel indiscernable d'une attente sans fin. """ - facteur = min(max(1, self.niveau_courant), 5) ** 2 + # La profondeur ABSOLUE : celle de la racine plus celle de l'étage. + # Un enfant de niveau 1 posé dans une racine déjà au troisième étage + # est en réalité au quatrième, et ses délais doivent le savoir. + profondeur = self.profondeur_racine + max(1, self.niveau_courant) + facteur = min(profondeur, 5) ** 2 return DELAIS[etape] * facteur # ---------------------------------------------------------------- # @@ -404,8 +476,10 @@ class Descente: return "" if res.returncode else res.stdout.strip() def parcourir(self): - parent = None - parent_alias = "" + # Sans racine, le premier étage est une VM qu'on crée en local. Avec + # une racine, TOUS les étages sont des enfants — le premier compris. + parent = self.racine + parent_alias = (self.racine or {}).get("target", "") for res in self.plan["niveaux"]: niveau = res["niveau"] self.niveau_courant = niveau @@ -420,7 +494,7 @@ class Descente: f" ── étage {niveau} : {res['vcpu']} vCPU," f" {res['ram']} Mo, {res['disque']} Go" ) - if niveau == 1: + if parent is None: nom = self.creer_etage1(res) if not nom: self.etages.append(etage) @@ -546,6 +620,18 @@ class Descente: if en_cours is not None and en_cours not in etages: etages.append(en_cours) return { + # La racine EMPRUNTÉE, si la descente en avait une. Hors de + # « etages » : elle n'est pas un étage atteint, et l'y mettre + # décalait de un le compte et le code de sortie. + "racine": ( + { + "alias": self.racine.get("target"), + "profondeur": self.profondeur_racine, + "cree": False, + } + if self.racine + else None + ), # L'outil qui a écrit ce rapport. Sans lui, « deep_qemu # --detruire » prenait le rapport le plus récent — qui pouvait # être celui d'une descente Proxmox — et détruisait d'après lui. @@ -686,6 +772,42 @@ def autre_descente(): return vivants +def joindre_racine(cible, jump=""): + """Le dict d'hôte d'une racine empruntée, ou None si elle ne répond pas. + + `sudo` est DÉDUIT, pas supposé : sur un hôte joint en root, préfixer les + commandes de « sudo » échoue là où l'image n'en a pas, et sur un hôte + joint en utilisateur, ne pas le mettre échoue partout. + """ + hote = {"target": cible, "jump": jump or "", "sudo": ""} + code, out = pve.run(hote, "id -u", 60) + if code: + dire(f" ✗ {cible} : injoignable en ssh.") + return None + if pve.strip_ssh_noise(out).strip() != "0": + hote["sudo"] = "sudo " + code, _o = pve.run(hote, "true", 60) + if code: + dire(f" ✗ {cible} : sudo demande un mot de passe.") + return None + return hote + + +def profondeur_de(cible): + """La profondeur d'imbrication de `cible`, d'après sa chaîne de rebonds. + + C'est la seule mesure dont on dispose de l'extérieur, et elle est exacte + pour les hôtes que nous avons déployés : c'est nous qui écrivons ces + entrées, un ProxyJump par étage. + """ + try: + from script.todo.todo import TODO + + return nesting.depth_from_jumps(TODO._ssh_jump_depth(cible)) + except Exception: # noqa: BLE001 - une profondeur inconnue vaut 1 + return 1 + + def mener(argv, description, famille, classe, couts=None): """La ligne de commande, le plan, la descente et le rapport. @@ -703,6 +825,18 @@ def mener(argv, description, famille, classe, couts=None): parseur.add_argument("--depth", type=int, default=3) parseur.add_argument("--dry-run", action="store_true") parseur.add_argument("--detruire", action="store_true") + # Partir d'un hôte qu'on POSSÈDE DÉJÀ. Créer une VM de tête pour héberger + # un hyperviseur qu'on a sous la main coûte cinq minutes et un étage + # d'imbrication — donc de la lenteur — pour rien. + parseur.add_argument( + "--hote", + default="", + help="partir d'un hôte existant (alias ssh ou user@adresse)" + " au lieu de créer une VM de premier étage", + ) + parseur.add_argument( + "--jump", default="", help="rebond ssh pour joindre --hote" + ) args = parseur.parse_args(argv) journal = os.path.expanduser( @@ -715,9 +849,25 @@ def mener(argv, description, famille, classe, couts=None): # destruction détruisait pour de vrai. return detruire(famille, journal, dry_run=args.dry_run) - coeurs, ram, disque = capacite_hote() + racine, profondeur_racine = None, 0 + if args.hote: + racine = joindre_racine(args.hote, args.jump) + if racine is None: + return 1 + profondeur_racine = profondeur_de(args.hote) + # La capacité de la RACINE, pas celle d'ici. Dimensionner le plan sur + # la machine locale quand les étages vivent ailleurs annoncerait des + # étages qui ne tiennent pas. + coeurs, ram, disque = capacite_distante(racine) + if not coeurs: + print(f"\n ✗ {args.hote} : capacité illisible.\n") + return 1 + print(f"\n racine : {args.hote}, déjà au niveau {profondeur_racine}") + else: + coeurs, ram, disque = capacite_hote() + ou = args.hote or "machine locale" print( - f"\n machine : {coeurs} cœurs, {ram} Mo disponibles," + f"\n {ou} : {coeurs} cœurs, {ram} Mo disponibles," f" {disque} Go de disque" ) plan = nesting.nesting_plan(args.depth, coeurs, ram, disque, couts) @@ -749,7 +899,15 @@ def mener(argv, description, famille, classe, couts=None): if args.dry_run: print(" --dry-run : rien ne sera créé.\n") chemin = journal[:-4] + ("-dryrun.json" if args.dry_run else ".json") - descente = classe(plan, journal, args.dry_run, chemin) + descente = classe( + plan, journal, args.dry_run, chemin, racine, profondeur_racine + ) + # La racine est-elle en état d'héberger ? Le même contrôle que celui de + # fin d'étage — un hôte emprunté n'a pas été préparé par nous, et rien ne + # garantit que sa pile est debout. + if racine and not args.dry_run and not descente.controler(racine): + print(f"\n ✗ {args.hote} ne peut pas héberger d'étage.\n") + return 1 rapport = descente.parcourir() with open(chemin, "w", encoding="utf-8") as fh: json.dump(rapport, fh, indent=2) diff --git a/script/todo/longtest_menu.py b/script/todo/longtest_menu.py index 2d10209..018584b 100644 --- a/script/todo/longtest_menu.py +++ b/script/todo/longtest_menu.py @@ -56,36 +56,119 @@ class LongTestMenuMixin: ) }, {"prompt_description": t("Nested Proxmox depth: run it")}, + { + "prompt_description": t( + "Nested QEMU depth: plan only (dry-run)" + ) + }, + {"prompt_description": t("Nested QEMU depth: run it")}, {"prompt_description": t("Undo what the descent created")}, ] + # Chaque choix : le script, et s'il faut demander d'où l'on part. + scripts = { + "1": ("deep_proxmox.py", True), + "2": ("deep_proxmox.py", True), + "3": ("deep_qemu.py", True), + "4": ("deep_qemu.py", True), + } help_info = self.fill_help_info(choices) while True: status = click.prompt(help_info) print() if status == "0": return False - if status == "1": - self._longtest_run( - "deep_proxmox.py", - f"--depth {self._longtest_depth()} --dry-run", - ) - elif status == "2": - # La profondeur est DEMANDÉE : c'est le seul réglage du test, - # et il décide de sa durée — dix étages, c'est une nuit. - self._longtest_run( - "deep_proxmox.py", f"--depth {self._longtest_depth()}" - ) - elif status == "3": - # Le script demande « OUI » avant de détruire, mais il liste - # d'abord : on lui fait faire cette liste À BLANC pour que le - # choix « 3 » d'une touche ne mène pas directement à un - # « qm destroy --purge ». - self._longtest_run("deep_proxmox.py", "--detruire --dry-run") - if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): - self._longtest_run("deep_proxmox.py", "--detruire") + if status in scripts: + script, demander = scripts[status] + # La profondeur est DEMANDÉE : c'est le réglage qui décide de + # la durée — au-delà de trois étages, tout est 15 à 30 fois + # plus lent, et cinq se comptent en heures. + args = f"--depth {self._longtest_depth()}" + if demander: + args += self._longtest_depart(script) + if status in ("1", "3"): + args += " --dry-run" + self._longtest_run(script, args) + elif status == "5": + self._longtest_defaire() else: print(t("Command not found !")) + def _longtest_defaire(self): + """Défaire, chaque pile la sienne. + + Les deux scripts partagent le dossier des rapports mais chacun ne + connaît que les siens : lancer les deux ne peut pas faire détruire à + l'un ce que l'autre a créé. + + Le script demande « OUI » avant de détruire, mais il LISTE d'abord : + on lui fait faire cette liste à blanc pour qu'un choix d'une touche ne + mène pas directement à un « qm destroy --purge ». + """ + for script in ("deep_proxmox.py", "deep_qemu.py"): + self._longtest_run(script, "--detruire --dry-run") + if self._is_yes(input(f"\n{t('Destroy all that? (y/N): ')}")): + self._longtest_run(script, "--detruire") + + def _longtest_depart(self, script): + """D'où part la descente : une VM neuve, ou un hôte qu'on a déjà. + + Créer une machine de tête pour héberger un hyperviseur qu'on possède + déjà coûte cinq minutes ET un étage d'imbrication — donc de la + lenteur, puisque c'est justement elle qu'on mesure. + + L'hôte déjà retenu est proposé sans qu'on ait à le rechercher : c'est + `_pve_host(ask=False)`, qui ne demande rien et ne dit rien s'il n'y en + a pas. + """ + connu = None + if script == "deep_proxmox.py": + try: + connu = self._pve_host(ask=False) + except Exception: # noqa: BLE001 - une préférence illisible + connu = None + print(f"\n{t('Where does the descent start?')}") + print(f" [1] {t('Create a fresh QEMU VM as level one')} *") + if connu: + print(f" [2] {t('Start from:')} {self._pve_label(connu)}") + print(f" [3] {t('Start from another existing host')}") + choix = input(t("Choice (1-3, default 1): ")).strip() + if choix == "2" and connu: + return self._longtest_args_hote(connu) + if choix == "3": + hote = ( + self._pve_pick_host() + if script == "deep_proxmox.py" + else self._longtest_hote_manuel() + ) + if hote: + return self._longtest_args_hote(hote) + print(t("Cancelled.")) + return "" + + @staticmethod + def _longtest_args_hote(hote): + """Les options que le script attend, à partir d'un dict d'hôte.""" + args = f" --hote {hote['target']}" + if hote.get("jump"): + args += f" --jump {hote['jump']}" + return args + + def _longtest_hote_manuel(self): + """Un hôte libvirt de départ, saisi à la main. + + Pas de sélecteur vérifié comme pour Proxmox : ce qu'on veut ici, c'est + un hôte qui porte KVM, et c'est le script qui le CONSTATE au premier + contrôle — /dev/kvm et l'imbrication — plutôt que le menu qui le + suppose. + """ + cible = input(t("Address (user@host, blank = cancel): ")).strip() + if not cible: + return None + return { + "target": cible, + "jump": input(t("SSH jump host (blank = none): ")).strip(), + } + def _longtest_depth(self): """Profondeur demandée. Trois par défaut, parce que trois marche. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d0df4f4..d12f08e 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3454,6 +3454,38 @@ TRANSLATIONS = { "fr": "Script introuvable :", "en": "Script not found:", }, + "Nested QEMU depth: plan only (dry-run)": { + "fr": "Profondeur QEMU imbriqué : le plan seulement (à blanc)", + "en": "Nested QEMU depth: plan only (dry-run)", + }, + "Nested QEMU depth: run it": { + "fr": "Profondeur QEMU imbriqué : le lancer", + "en": "Nested QEMU depth: run it", + }, + "Where does the descent start?": { + "fr": "D'où part la descente ?", + "en": "Where does the descent start?", + }, + "Create a fresh QEMU VM as level one": { + "fr": "Créer une VM QEMU neuve comme premier étage", + "en": "Create a fresh QEMU VM as level one", + }, + "Start from:": { + "fr": "Partir de :", + "en": "Start from:", + }, + "Start from another existing host": { + "fr": "Partir d'un autre hôte existant", + "en": "Start from another existing host", + }, + "Choice (1-3, default 1): ": { + "fr": "Choix (1-3, défaut 1) : ", + "en": "Choice (1-3, default 1): ", + }, + "Address (user@host, blank = cancel): ": { + "fr": "Adresse (utilisateur@hôte, vide = annuler) : ", + "en": "Address (user@host, blank = cancel): ", + }, "Depth (default 3): ": { "fr": "Profondeur (défaut 3) : ", "en": "Depth (default 3): ", diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index cc6fd5b..a7be68d 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -1032,6 +1032,204 @@ class TestNePasAttendreUneMaisonDisparue(unittest.TestCase): self.assertEqual(self.d.attendre_ssh(enfant, 60), 0) +class TestLeMenuDesDeuxTests(unittest.TestCase): + """La liste des choix et le dispatch sont couplés PAR POSITION, sans + garde : ajouter une entrée sans son branchement donne un menu qui affiche + une option et répond « commande inconnue ».""" + + def setUp(self): + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.todo = TODO.__new__(TODO) + + def test_every_listed_choice_has_a_branch(self): + """fill_help_info numérote à partir de 1 : le choix n° i doit être + traité, sinon le menu affiche une option et répond « commande + inconnue ».""" + import inspect + + src = inspect.getsource(self.todo.prompt_execute_longtest) + entrees = src.count('"prompt_description"') + self.assertGreaterEqual(entrees, 5, "le menu a perdu des entrées") + for i in range(1, entrees + 1): + self.assertIn( + f'"{i}"', src, f"le choix {i} est affiché mais pas traité" + ) + # Et rien au-delà : un branchement sans entrée est un choix caché. + self.assertNotIn(f'"{entrees + 1}"', src) + + def test_both_stacks_are_offered(self): + import inspect + + src = inspect.getsource(self.todo.prompt_execute_longtest) + self.assertIn("deep_proxmox.py", src) + self.assertIn("deep_qemu.py", src) + + def test_undoing_asks_each_stack_separately(self): + """Chacun ne connaît que ses rapports : lancer les deux ne peut pas + faire détruire à l'un ce que l'autre a créé.""" + import inspect + + src = inspect.getsource(self.todo._longtest_defaire) + self.assertIn("deep_proxmox.py", src) + self.assertIn("deep_qemu.py", src) + # À BLANC d'abord, toujours : un choix d'une touche ne doit pas mener + # droit à « qm destroy --purge ». + self.assertLess( + src.index("--detruire --dry-run"), src.index('"--detruire"') + ) + + def test_the_host_options_are_built_from_the_host_dict(self): + self.assertEqual( + self.todo._longtest_args_hote({"target": "pve9", "jump": ""}), + " --hote pve9", + ) + self.assertEqual( + self.todo._longtest_args_hote({"target": "vm", "jump": "porte"}), + " --hote vm --jump porte", + ) + + def test_a_fresh_vm_is_the_default_answer(self): + """Toute réponse hors plage retombe sur l'option 1 : le menu ne doit + jamais partir d'un hôte qu'on n'a pas désigné.""" + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + self.todo._pve_host = lambda ask=True: None + for reponse in ("", "1", "n'importe quoi", "9"): + with self.subTest(reponse=reponse): + builtins.input = lambda _p="", r=reponse: r + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + self.todo._longtest_depart("deep_proxmox.py"), "" + ) + + def test_the_known_host_is_offered_without_being_searched(self): + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + demande = [] + self.todo._pve_host = lambda ask=True: demande.append(ask) or { + "target": "root@10.0.0.5", + "jump": "", + "version": "9.2.11", + } + builtins.input = lambda _p="": "2" + with contextlib.redirect_stdout(io.StringIO()) as sortie: + args = self.todo._longtest_depart("deep_proxmox.py") + self.assertEqual(args, " --hote root@10.0.0.5") + # Sans rien demander : c'est tout l'intérêt de _pve_host(ask=False). + self.assertEqual(demande, [False]) + self.assertIn("9.2.11", sortie.getvalue()) + + def test_a_qemu_descent_does_not_ask_for_a_proxmox(self): + """Le sélecteur Proxmox VÉRIFIE « pveversion » : le proposer pour une + descente QEMU refuserait un hôte libvirt parfaitement bon.""" + import builtins + + vrai = builtins.input + self.addCleanup(setattr, builtins, "input", vrai) + reponses = iter(["3", "erplibre@10.0.0.7", ""]) + builtins.input = lambda _p="": next(reponses) + self.todo._pve_pick_host = lambda: self.fail( + "sélecteur Proxmox appelé" + ) + with contextlib.redirect_stdout(io.StringIO()): + args = self.todo._longtest_depart("deep_qemu.py") + self.assertEqual(args, " --hote erplibre@10.0.0.7") + + +class TestPartirDunHoteExistant(unittest.TestCase): + """Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà + coûte cinq minutes ET un étage d'imbrication — donc de la lenteur.""" + + def test_the_remote_capacity_is_read_as_the_machine_writes_it(self): + # Sortie RÉELLE, prise sur un hôte du parc. + vrai = "COEURS=8\nMEM=MemAvailable: 7056288 kB\nDISQUE= 7G\n" + self.assertEqual(moteur.parse_capacite(vrai), (8, 6890, 7)) + + def test_a_missing_line_reads_as_zero_not_as_a_guess(self): + """Un plan dimensionné sur une capacité SUPPOSÉE annoncerait des + étages qui ne tiennent pas.""" + self.assertEqual(moteur.parse_capacite(""), (0, 0, 0)) + self.assertEqual(moteur.parse_capacite("COEURS=4\n"), (4, 0, 0)) + + def test_the_capacity_probe_is_written_for_dash(self): + for interdit in ("[[", "pipefail", "$("): + self.assertNotIn(interdit, moteur.CAPACITE_CMD, interdit) + + def test_sudo_is_deduced_not_assumed(self): + """Sur un hôte joint en root, préfixer de « sudo » échoue là où + l'image n'en a pas ; en utilisateur, ne pas le mettre échoue + partout.""" + reponses = {} + moteur.pve.run = lambda h, r, t=120: reponses.get(r, (0, "")) + self.addCleanup(setattr, moteur.pve, "run", moteur.pve.run) + vrai = moteur.pve.run + + reponses["id -u"] = (0, "0\n") + self.assertEqual(moteur.joindre_racine("h")["sudo"], "") + reponses["id -u"] = (0, "1000\n") + self.assertEqual(moteur.joindre_racine("h")["sudo"], "sudo ") + moteur.pve.run = vrai + + def test_an_unreachable_root_is_refused_not_guessed(self): + vrai = moteur.pve.run + moteur.pve.run = lambda h, r, t=120: (255, "") + self.addCleanup(setattr, moteur.pve, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertIsNone(moteur.joindre_racine("nulle-part")) + self.assertIn("injoignable", sortie.getvalue()) + + def test_the_delays_count_the_absolute_depth(self): + """Un enfant de niveau 1 posé dans une racine DÉJÀ au troisième étage + est en réalité au quatrième. Sans cela il héritait des délais du + premier : quatre fois trop courts.""" + d = moteur.Descente.__new__(moteur.Descente) + d.niveau_courant = 1 + d.profondeur_racine = 0 + seul = d.delai("ssh") + d.profondeur_racine = 3 + self.assertEqual(d.delai("ssh"), seul * 16) + + def test_a_borrowed_root_is_never_a_level_that_was_reached(self): + """L'y compter décalerait de un le total ET le code de sortie.""" + d = moteur.Descente.__new__(moteur.Descente) + d.plan = {"demandee": 2, "atteignable": 2} + d.etages = [{"niveau": 1, "ok": True}] + d.dry_run = False + d.OUTIL = "deep_proxmox" + d.racine = {"target": "mon-proxmox"} + d.profondeur_racine = 2 + etat = d._etat(interrompu=False) + self.assertEqual(etat["atteinte"], 1) + self.assertEqual(len(etat["etages"]), 1) + # Elle est dite, mais à part — et jamais « cree ». + self.assertEqual(etat["racine"]["alias"], "mon-proxmox") + self.assertEqual(etat["racine"]["profondeur"], 2) + self.assertFalse(etat["racine"]["cree"]) + + def test_without_a_root_the_report_says_so(self): + d = moteur.Descente.__new__(moteur.Descente) + d.plan = {"demandee": 1, "atteignable": 1} + d.etages = [] + d.dry_run = False + d.OUTIL = "deep_proxmox" + self.assertIsNone(d._etat(interrompu=False)["racine"]) + + def test_the_first_level_is_local_only_without_a_root(self): + """Avec une racine, TOUS les étages sont des enfants — le premier + compris. Sans elle, le premier est une VM créée en local.""" + import inspect + + src = inspect.getsource(moteur.Descente.parcourir) + self.assertIn("if parent is None:", src) + self.assertNotIn("if niveau == 1:", src) + + class TestDeuxPilesNeSeMelangentPas(unittest.TestCase): """Le dossier des rapports et le motif « *.json » sont PARTAGÉS. From 5af6c94c0a685622e15f30b7190dc3cd3e6b3647 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 03:52:21 -0400 Subject: [PATCH 24/26] =?UTF-8?q?[FIX]=20deep=5Fqemu=20:=20listes=20apt,?= =?UTF-8?q?=20sous-r=C3=A9seau=20par=20=C3=A9tage,=20=C3=A9tape=20muette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts trouvés en une heure par le premier lancement réel — c'est ce qu'un test d'intégration doit produire. 1. « --setup-host » a échoué en ZÉRO seconde sur « Unable to locate package qemu-system-x86 », alors que le paquet existe : la VM venait de démarrer et ses listes ne portaient que bookworm-security. Le message envoyait chercher des paquets, pas des listes. Même parade qu'install_proxmox.sh — arrêter apt-daily, puis réessayer. 2. Le réseau « default » de libvirt sert 192.168.122.0/24 à TOUS les étages. L'étage 2, dont l'adresse VENAIT de ce réseau, voyait son propre net-start refusé : « Network is already in use by interface enp1s0 ». Un invité qui vit dans un réseau ne peut pas servir le même. Chaque étage prend le sien, déduit de sa profondeur absolue, et le REDÉFINIT avant de le démarrer. 3. Le mien : l'extraction du moteur avait coupé preparer_systeme sur le « return False » de sa boucle, sans son « return True ». La fonction rendait None, donc l'étape échouait SANS RIEN DIRE, et les deux piles étaient cassées. L'essai à blanc ne pouvait pas le voir — il sort avant. Un test d'AST interdit désormais qu'une étape retombe sur None. long_test/ était introuvable hors du menu : une ligne dans CLAUDE.md, trois entrées au CHANGELOG, deux sections au README. --- EN --- Three defects found in one hour by the first real run — which is what an integration test is for. 1. "--setup-host" failed in ZERO seconds on "Unable to locate package qemu-system-x86" though the package exists: the VM had just booted and its lists carried only bookworm-security. The message sent us looking for packages, not for lists. Same remedy as install_proxmox.sh — stop apt-daily, then retry. 2. libvirt's "default" network serves 192.168.122.0/24 at EVERY level. Level 2, whose own address CAME from that network, had its net-start refused: "Network is already in use by interface enp1s0". A guest living inside a network cannot serve the same one. Each level takes its own, derived from its absolute depth, and REDEFINES it before starting it. 3. Mine: extracting the engine had cut preparer_systeme at its loop's "return False", without the final "return True". The function returned None, so the step failed SAYING NOTHING, and both stacks were broken. The dry run could not see it — it exits earlier. An AST test now forbids a step from falling through to None. long_test/ was undiscoverable outside the menu: one line in CLAUDE.md, three CHANGELOG entries, two README sections. Assisted-by: claude-opus-5 (cherry picked from commit e46bad408143f7511a04ffdc6a20efdb785f4b5e) --- CHANGELOG.base.md | 6 ++ CHANGELOG.fr.md | 3 + CHANGELOG.md | 3 + CLAUDE.md | 4 + long_test/README.base.md | 112 ++++++++++++++++++++++++++++ long_test/README.fr.md | 59 ++++++++++++++- long_test/README.md | 55 ++++++++++++++ long_test/deep_qemu.py | 81 +++++++++++++++++++- long_test/descente.py | 5 ++ test/test_deep_qemu.py | 147 +++++++++++++++++++++++++++++++++++++ test/test_todo_longtest.py | 88 ++++++++++++++++++++++ 11 files changed, 558 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index c1c52df..e6ca1f3 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -41,6 +41,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Ajouté +- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works +- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO +- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed - Support Odoo migration database and module with TODO - Support multi version odoo switch on same workspace - Script for hardening the installation @@ -122,6 +125,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi +- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, le quatrième 4 h 20 d'amorçage plus 7 h 18 d'installation — tout y est 15 à 30 fois plus lent, et c'est là que les fabricants cessent de documenter l'imbrication. La profondeur est un paramètre et vaut trois par défaut, parce que trois marche +- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON +- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée - Support de la migration de base de données et de modules Odoo avec TODO - Support du changement multi-version Odoo sur le même espace de travail - Script pour le renforcement de la sécurité de l'installation diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 49284e2..f2f4838 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -15,6 +15,9 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi ## Ajouté +- `long_test/` — des tests qui créent de vraies machines et durent des heures, tenus hors de `test/` pour que le lanceur unitaire reste lançable en quelques secondes. `deep_proxmox.py` empile des Proxmox dans des Proxmox, `deep_qemu.py` des QEMU dans des QEMU, et les deux partagent un moteur. Mesuré sur 28 cœurs : trois étages coûtent 34 minutes, le quatrième 4 h 20 d'amorçage plus 7 h 18 d'installation — tout y est 15 à 30 fois plus lent, et c'est là que les fabricants cessent de documenter l'imbrication. La profondeur est un paramètre et vaut trois par défaut, parce que trois marche +- deep_qemu PROUVE KVM à chaque étage au lieu de le supposer : `deploy_qemu.py` ne passe jamais `--cpu host-passthrough` et, quand /dev/kvm manque, il n'échoue pas — il pose `--virt-type qemu` et crée une VM entièrement ÉMULÉE, sept minutes et demie de démarrage, sans qu'aucun code de retour ne le dise. Sans garde, la descente mesurerait de la TCG empilée en croyant mesurer de l'imbrication. Chaque étage doit montrer `/dev/kvm`, `nested=Y` et un domaine enfant en `type='kvm'` ; ce qui n'a pas été lu vaut NON +- Les deux tests longs acceptent `--hote` pour partir d'une machine qu'on possède déjà, au lieu de créer une VM de tête pour héberger un hyperviseur qu'on a sous la main — cela coûte cinq minutes ET un étage d'imbrication. Le plan se dimensionne alors sur la RACINE, lue par ssh ; les délais comptent la profondeur ABSOLUE ; et la racine n'est jamais un étage atteint, jamais détruite, et son entrée ~/.ssh/config n'est jamais retirée - Support de la migration de base de données et de modules Odoo avec TODO - Support du changement multi-version Odoo sur le même espace de travail - Script pour le renforcement de la sécurité de l'installation diff --git a/CHANGELOG.md b/CHANGELOG.md index 54a57d5..343ac53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,9 @@ Recreating the virtual environment, use installation guide from tool `make`. ## Added +- `long_test/` — tests that create real machines and take hours, kept out of `test/` so the unit runner stays runnable in seconds. `deep_proxmox.py` stacks Proxmox in Proxmox, `deep_qemu.py` stacks QEMU in QEMU, and they share one engine. Measured on 28 cores: three levels cost 34 minutes, the fourth 4 h 20 of boot plus 7 h 18 of install — everything there is 15 to 30 times slower, and that is where the vendors stop documenting nesting. The depth is a parameter and defaults to three, because three works +- deep_qemu proves KVM at every level instead of assuming it: `deploy_qemu.py` never passes `--cpu host-passthrough` and, when /dev/kvm is missing, it does not fail — it sets `--virt-type qemu` and creates a fully EMULATED VM, seven and a half minutes to boot, with no exit code to say so. Unguarded, the descent would measure stacked TCG while believing it measured nesting. Each level must show `/dev/kvm`, `nested=Y` and a child domain in `type='kvm'`; what was not read counts as NO +- Both long tests take `--hote` to start from a machine you already own, rather than creating a head VM to host a hypervisor you have on hand — that costs five minutes AND one level of nesting. The plan is then sized on the ROOT, read over ssh; the delays count ABSOLUTE depth; and the root is never a level reached, never destroyed, and its ~/.ssh/config entry is never removed - Support Odoo migration database and module with TODO - Support multi version odoo switch on same workspace - Script for hardening the installation diff --git a/CLAUDE.md b/CLAUDE.md index 6a995d3..7281dc7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -25,6 +25,10 @@ Version Odoo par défaut : **18.0** (support officiel ERPLibre 1.6.0) - Pour les commits : suivre le format `[TYPE] description` (ex: `[FIX]`, `[UPD]`, `[ADD]`, `[REM]`) - Pour la documentation : modifier les `.base.md`, jamais les `.md` ou `.fr.md` directement - Outil mmg disponible via `source .venv.erplibre/bin/activate && mmg` +- Les tests qui créent de VRAIES machines vivent dans `long_test/` et non dans + `test/` : le lanceur unitaire balaie `test/test_*.py` et doit rester lançable + en quelques secondes, même sans virtualisation. Ils durent des heures et se + défont par `--detruire` — voir `long_test/README.md` ## Core Principles diff --git a/long_test/README.base.md b/long_test/README.base.md index 9b7ccb0..295b45e 100644 --- a/long_test/README.base.md +++ b/long_test/README.base.md @@ -132,6 +132,61 @@ The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and reach six than to promise ten and die at the seventh without knowing why. +## deep_qemu.py — how deep does QEMU-in-QEMU go? + +The same descent, a different stack — and the pair is the point. The fourth +level's slowdown comes from the **processor**: what a VM exit costs under +nested paging. The per-level *cost*, though, comes from what you install. A +Proxmox node lays down a kernel, corosync, ceph and a web UI; a libvirt host +lays down `libvirtd` and `qemu-kvm`. Measured together, the two separate what +is due to the hardware from what is due to the stack — two things the Proxmox +measurement alone confounds. + +### What this test must prove before it measures anything + +`deploy_qemu.py` never passes `--cpu host-passthrough`, and when `/dev/kvm` is +missing it does **not** fail: it sets `--virt-type qemu`, warns on one line, +and creates a fully **emulated** VM. Seven and a half minutes to boot, and no +exit code says so. + +Unguarded, this script would measure stacked TCG while believing it measured +nesting — and return a more flattering number that means nothing. So every +level must prove, not assume: + +* `/dev/kvm` is readable; +* `/sys/module/kvm_amd|kvm_intel/parameters/nested` reads `Y`; +* the child's domain is ``, checked right after creation. + +**What was not read counts as NO.** An absent `/sys/module` file means an +unloaded module, not a permissions problem. A level that fails these stops the +descent instead of prolonging it into the void. + +## Starting from a host you already have + +Both scripts take `--hote`. Creating a head VM to host a hypervisor you +already own costs five minutes *and* one level of nesting — that is, slowness, +which is the very thing being measured. + +``` +./long_test/deep_proxmox.py --hote root@10.0.0.5 # an existing Proxmox +./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # an existing libvirt host +``` + +Three things follow, and they are not decorative: + +* the plan is sized on the **root**, read over ssh — sizing it on the local + machine while the levels live elsewhere would announce levels that do not + fit; +* the delays count **absolute** depth: a level-1 child placed in a root that + is already at the third level is really at the fourth; +* the root is **never** a level reached, and **never** destroyed. A borrowed + host has no local libvirt UUID, so `--detruire` refuses to fall back on its + name — `virsh undefine --remove-all-storage` erases a disk for good. + +The menu offers the host already chosen without searching for it, and undoes +each stack separately: they share the report directory, but each knows only +its own reports. + # long_test — des tests qui créent de vraies machines @@ -269,3 +324,60 @@ Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer six étages et en réussir six que d'en promettre dix et mourir au septième sans savoir pourquoi. + +## deep_qemu.py — jusqu'à quel étage une QEMU dans une QEMU tient-elle ? + +La même descente, une autre pile — et c'est le couple qui compte. Le +ralentissement du quatrième étage vient du **processeur** : de ce que coûte une +sortie de VM sous pagination imbriquée. Le *coût* par étage, lui, vient de ce +qu'on installe. Un nœud Proxmox pose un noyau, corosync, ceph et une interface +web ; un hôte libvirt pose `libvirtd` et `qemu-kvm`. Mesurées ensemble, les +deux séparent ce qui tient au matériel de ce qui tient à la pile — deux choses +que la seule mesure Proxmox confond. + +### Ce que ce test doit prouver avant de mesurer quoi que ce soit + +`deploy_qemu.py` ne passe jamais `--cpu host-passthrough`, et quand +`/dev/kvm` manque il n'échoue **pas** : il pose `--virt-type qemu`, avertit sur +une ligne, et crée une VM entièrement **émulée**. Sept minutes et demie de +démarrage, et aucun code de retour ne le dit. + +Sans garde, ce script mesurerait de la TCG empilée en croyant mesurer de +l'imbrication — et rendrait un chiffre plus flatteur qui ne veut rien dire. +Chaque étage doit donc prouver, et non supposer : + +* `/dev/kvm` est lisible ; +* `/sys/module/kvm_amd|kvm_intel/parameters/nested` vaut `Y` ; +* le domaine de l'enfant est ``, vérifié juste après sa + création. + +**Ce qui n'a pas été lu vaut NON.** Un fichier `/sys/module` absent, c'est un +module non chargé, pas un problème de permission. Un étage qui échoue à cela +arrête la descente au lieu de la prolonger dans le vide. + +## Partir d'un hôte qu'on possède déjà + +Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un +hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage +d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. + +``` +./long_test/deep_proxmox.py --hote root@10.0.0.5 # un Proxmox existant +./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # un hôte libvirt existant +``` + +Trois choses en découlent, et elles ne sont pas décoratives : + +* le plan se dimensionne sur la **racine**, lue par ssh — le dimensionner sur + la machine locale quand les étages vivent ailleurs annoncerait des étages qui + ne tiennent pas ; +* les délais comptent la profondeur **absolue** : un enfant de niveau 1 posé + dans une racine déjà au troisième étage est en réalité au quatrième ; +* la racine n'est **jamais** un étage atteint, et **jamais** détruite. Un hôte + emprunté n'a pas d'UUID libvirt local, donc `--detruire` refuse de se rabattre + sur son nom — `virsh undefine --remove-all-storage` efface un disque pour de + bon. + +Le menu propose l'hôte déjà retenu sans le rechercher, et défait chaque pile +séparément : elles partagent le dossier des rapports, mais chacune ne connaît +que les siens. diff --git a/long_test/README.fr.md b/long_test/README.fr.md index b87c56b..939f62d 100644 --- a/long_test/README.fr.md +++ b/long_test/README.fr.md @@ -134,4 +134,61 @@ mêmes 32 Mio, c'est-à-dire simplement la taille des fichiers d'amorçage. Le plan est affiché **avant** que quoi que ce soit ne soit créé, et le script ne promet jamais une profondeur qu'il sait irréalisable — mieux vaut annoncer six étages et en réussir six que d'en promettre dix et mourir au septième sans -savoir pourquoi. \ No newline at end of file +savoir pourquoi. + +## deep_qemu.py — jusqu'à quel étage une QEMU dans une QEMU tient-elle ? + +La même descente, une autre pile — et c'est le couple qui compte. Le +ralentissement du quatrième étage vient du **processeur** : de ce que coûte une +sortie de VM sous pagination imbriquée. Le *coût* par étage, lui, vient de ce +qu'on installe. Un nœud Proxmox pose un noyau, corosync, ceph et une interface +web ; un hôte libvirt pose `libvirtd` et `qemu-kvm`. Mesurées ensemble, les +deux séparent ce qui tient au matériel de ce qui tient à la pile — deux choses +que la seule mesure Proxmox confond. + +### Ce que ce test doit prouver avant de mesurer quoi que ce soit + +`deploy_qemu.py` ne passe jamais `--cpu host-passthrough`, et quand +`/dev/kvm` manque il n'échoue **pas** : il pose `--virt-type qemu`, avertit sur +une ligne, et crée une VM entièrement **émulée**. Sept minutes et demie de +démarrage, et aucun code de retour ne le dit. + +Sans garde, ce script mesurerait de la TCG empilée en croyant mesurer de +l'imbrication — et rendrait un chiffre plus flatteur qui ne veut rien dire. +Chaque étage doit donc prouver, et non supposer : + +* `/dev/kvm` est lisible ; +* `/sys/module/kvm_amd|kvm_intel/parameters/nested` vaut `Y` ; +* le domaine de l'enfant est ``, vérifié juste après sa + création. + +**Ce qui n'a pas été lu vaut NON.** Un fichier `/sys/module` absent, c'est un +module non chargé, pas un problème de permission. Un étage qui échoue à cela +arrête la descente au lieu de la prolonger dans le vide. + +## Partir d'un hôte qu'on possède déjà + +Les deux scripts acceptent `--hote`. Créer une VM de tête pour héberger un +hyperviseur qu'on a sous la main coûte cinq minutes *et* un étage +d'imbrication — donc de la lenteur, puisque c'est justement elle qu'on mesure. + +``` +./long_test/deep_proxmox.py --hote root@10.0.0.5 # un Proxmox existant +./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # un hôte libvirt existant +``` + +Trois choses en découlent, et elles ne sont pas décoratives : + +* le plan se dimensionne sur la **racine**, lue par ssh — le dimensionner sur + la machine locale quand les étages vivent ailleurs annoncerait des étages qui + ne tiennent pas ; +* les délais comptent la profondeur **absolue** : un enfant de niveau 1 posé + dans une racine déjà au troisième étage est en réalité au quatrième ; +* la racine n'est **jamais** un étage atteint, et **jamais** détruite. Un hôte + emprunté n'a pas d'UUID libvirt local, donc `--detruire` refuse de se rabattre + sur son nom — `virsh undefine --remove-all-storage` efface un disque pour de + bon. + +Le menu propose l'hôte déjà retenu sans le rechercher, et défait chaque pile +séparément : elles partagent le dossier des rapports, mais chacune ne connaît +que les siens. \ No newline at end of file diff --git a/long_test/README.md b/long_test/README.md index 4686619..c5bcb10 100644 --- a/long_test/README.md +++ b/long_test/README.md @@ -126,3 +126,58 @@ size of the boot files. The plan is printed **before** anything is created, and the script never promises a depth it knows will not fit — better to announce six levels and reach six than to promise ten and die at the seventh without knowing why. + +## deep_qemu.py — how deep does QEMU-in-QEMU go? + +The same descent, a different stack — and the pair is the point. The fourth +level's slowdown comes from the **processor**: what a VM exit costs under +nested paging. The per-level *cost*, though, comes from what you install. A +Proxmox node lays down a kernel, corosync, ceph and a web UI; a libvirt host +lays down `libvirtd` and `qemu-kvm`. Measured together, the two separate what +is due to the hardware from what is due to the stack — two things the Proxmox +measurement alone confounds. + +### What this test must prove before it measures anything + +`deploy_qemu.py` never passes `--cpu host-passthrough`, and when `/dev/kvm` is +missing it does **not** fail: it sets `--virt-type qemu`, warns on one line, +and creates a fully **emulated** VM. Seven and a half minutes to boot, and no +exit code says so. + +Unguarded, this script would measure stacked TCG while believing it measured +nesting — and return a more flattering number that means nothing. So every +level must prove, not assume: + +* `/dev/kvm` is readable; +* `/sys/module/kvm_amd|kvm_intel/parameters/nested` reads `Y`; +* the child's domain is ``, checked right after creation. + +**What was not read counts as NO.** An absent `/sys/module` file means an +unloaded module, not a permissions problem. A level that fails these stops the +descent instead of prolonging it into the void. + +## Starting from a host you already have + +Both scripts take `--hote`. Creating a head VM to host a hypervisor you +already own costs five minutes *and* one level of nesting — that is, slowness, +which is the very thing being measured. + +``` +./long_test/deep_proxmox.py --hote root@10.0.0.5 # an existing Proxmox +./long_test/deep_qemu.py --hote erplibre@10.0.0.7 # an existing libvirt host +``` + +Three things follow, and they are not decorative: + +* the plan is sized on the **root**, read over ssh — sizing it on the local + machine while the levels live elsewhere would announce levels that do not + fit; +* the delays count **absolute** depth: a level-1 child placed in a root that + is already at the third level is really at the fourth; +* the root is **never** a level reached, and **never** destroyed. A borrowed + host has no local libvirt UUID, so `--detruire` refuses to fall back on its + name — `virsh undefine --remove-all-storage` erases a disk for good. + +The menu offers the host already chosen without searching for it, and undoes +each stack separately: they share the report directory, but each knows only +its own reports. diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py index a7b2466..3dffe04 100644 --- a/long_test/deep_qemu.py +++ b/long_test/deep_qemu.py @@ -89,6 +89,60 @@ CONTROLE_CMD = ( " | tail -1 | sed s/^/DISQUE=/" ) +# Les listes apt AVANT toute installation. Constaté au premier lancement +# réel : « --setup-host » a échoué en ZÉRO seconde sur « Unable to locate +# package qemu-system-x86 », alors que le paquet existe. La VM venait de +# démarrer, ses listes ne portaient que « bookworm-security », et un +# apt-get update les a complétées d'un coup. +# +# Deux causes, une seule parade : cloud-init n'a pas fini de composer +# /etc/apt, et apt-daily tient le verrou des listes au premier démarrage. +# install_proxmox.sh a la même parade, et pour la même raison — arrêter les +# minuteries, puis réessayer. +PREPARE_APT_CMD = ( + "systemctl stop apt-daily.service apt-daily-upgrade.service" + " apt-daily.timer apt-daily-upgrade.timer >/dev/null 2>&1;" + " i=1; while [ $i -le 12 ]; do" + " DEBIAN_FRONTEND=noninteractive apt-get update && exit 0;" + " echo APT-RETRY=$i; sleep 15; i=$((i+1)); done; exit 1" +) + +# Le réseau « default » de libvirt sert 192.168.122.0/24, à TOUS les étages. +# Constaté au premier essai réel : l'étage 2, dont l'adresse était +# 192.168.122.45 — servie par le « default » de son parent — a vu son propre +# « net-start default » refusé net : +# +# error: internal error: Network is already in use by interface enp1s0 +# +# Un invité qui vit DANS un réseau ne peut pas servir le même. Chaque étage +# reçoit donc son propre sous-réseau, déduit de sa PROFONDEUR : deux étages ne +# peuvent pas tomber sur le même, et rien n'est à deviner. +# +# 131 et au-delà : 122 est celui de libvirt et 123 celui de la machine où ce +# test a été écrit. Les éviter tous les deux coûte un octet. +RESEAU_BASE = 131 + + +def cidr_pour(profondeur): + """Le troisième octet du sous-réseau d'un étage. Déterminé, jamais tiré.""" + return f"192.168.{RESEAU_BASE + max(0, int(profondeur) - 1)}" + + +def reseau_xml(prefixe, nom="default"): + """Le réseau NAT d'un étage, en une ligne — pas de heredoc. + + Une seule ligne parce qu'elle traverse deux couches de quoting pour + atterrir dans dash : un heredoc n'y survivrait pas. + """ + return ( + f"{nom}" + f"" + f"" + f"" + f"" + ) + + RESEAU_CMD = ( "virsh -c qemu:///system net-info default 2>&1 | sed s/^/NET:/; " "systemctl is-active libvirtd 2>/dev/null | sed s/^/UNITE:/" @@ -208,6 +262,18 @@ class Descente(descente.Descente): return True if not self._envoyer_cli(hote): return False + # Les listes d'abord. Sans elles, « --setup-host » échoue en zéro + # seconde sur des paquets qui existent — et le message parle de + # paquets introuvables, pas de listes vides. + code, _o = self.executer( + hote, + PREPARE_APT_CMD, + self.delai("install"), + "apt-get update", + ) + if code: + self.dire(" ✗ listes apt : le verrou reste tenu") + return False code, _o = self.executer( hote, f"python3 {DISTANT_CLI} --setup-host --assume-yes", @@ -234,16 +300,23 @@ class Descente(descente.Descente): if self.dry_run: print(" libvirtd + réseau default") return True - # Idempotent : sur un hôte déjà en ordre, les deux commandes ne font - # rien et rendent 0. + # Son sous-réseau à LUI, sinon « net-start default » se heurte à + # l'adresse que son parent lui a servie. + profondeur = self.profondeur_racine + max(1, self.niveau_courant) + xml = reseau_xml(cidr_pour(profondeur)) self.executer( hote, "systemctl enable --now libvirtd 2>/dev/null;" - " virsh -c qemu:///system net-start default 2>/dev/null;" - " virsh -c qemu:///system net-autostart default 2>/dev/null; true", + " virsh -c qemu:///system net-destroy default 2>/dev/null;" + " virsh -c qemu:///system net-undefine default 2>/dev/null;" + f" printf '%s' {shlex.quote(xml)} > /tmp/reseau.xml;" + " virsh -c qemu:///system net-define /tmp/reseau.xml;" + " virsh -c qemu:///system net-start default;" + " virsh -c qemu:///system net-autostart default; true", self.delai("reparation"), "libvirtd", ) + self.dire(f" réseau {cidr_pour(profondeur)}.0/24") _c, out = self.executer( hote, RESEAU_CMD, self.delai("controle"), "réseau" ) diff --git a/long_test/descente.py b/long_test/descente.py index eb34f07..9762723 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -409,6 +409,11 @@ class Descente: if code or "-KO" in pve.strip_ssh_noise(sortie): self.dire(f" ✗ {etiquette}") return False + # Ce « return True » manquait, et l'étape échouait donc SANS RIEN + # DIRE : « ✗ étage 1 systeme » et pas une ligne de cause. Une fonction + # qui rend None là où l'appelant attend un booléen ne ment pas à + # moitié — elle dit « non ». + return True def creer_etage1(self, res): """Une VM locale, par la CLI QEMU/KVM. Le seul étage sur du métal.""" diff --git a/test/test_deep_qemu.py b/test/test_deep_qemu.py index 859453b..6e7bf99 100644 --- a/test/test_deep_qemu.py +++ b/test/test_deep_qemu.py @@ -110,6 +110,153 @@ class TestUnEtageQuiNeSaitPasHeberger(unittest.TestCase): self.assertNotIn(interdit, deep_qemu.CONTROLE_CMD, interdit) +class TestLesListesAptAvantToute(unittest.TestCase): + """Constaté au premier lancement réel : « --setup-host » a échoué en ZÉRO + seconde sur « Unable to locate package qemu-system-x86 », alors que le + paquet existe. La VM venait de démarrer, ses listes ne portaient que + « bookworm-security », et un apt-get update les a complétées d'un coup. + + Le message parlait de paquets introuvables, pas de listes vides : c'est + exactement le genre de diagnostic qui envoie chercher au mauvais endroit. + """ + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 1 + self.d._envoyer_cli = lambda hote: True + self.faits = [] + + def _repond(self, code_apt=0): + def executer(hote, cmd, delai, etiquette="", **k): + self.faits.append(etiquette) + if etiquette == "apt-get update": + return code_apt, "" + return 0, "" + + self.d.executer = executer + + def test_the_lists_are_refreshed_before_the_install(self): + self._repond() + with contextlib.redirect_stdout(io.StringIO()): + self.assertTrue(self.d.installer({"target": "h"})) + self.assertEqual( + self.faits, ["apt-get update", "deploy_qemu --setup-host"] + ) + + def test_an_apt_lock_that_never_lets_go_stops_the_level(self): + """Installer sur des listes vides donnerait « paquet introuvable » — + un diagnostic qui envoie chercher au mauvais endroit.""" + self._repond(code_apt=1) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(self.d.installer({"target": "h"})) + self.assertIn("verrou reste tenu", sortie.getvalue()) + self.assertNotIn("deploy_qemu --setup-host", self.faits) + + def test_the_daily_timers_are_stopped_first(self): + """apt-daily tient le verrou des listes au premier démarrage. + install_proxmox.sh a la même parade, et pour la même raison.""" + self.assertIn("apt-daily.timer", deep_qemu.PREPARE_APT_CMD) + self.assertIn("apt-daily.service", deep_qemu.PREPARE_APT_CMD) + + def test_it_retries_rather_than_giving_up_at_once(self): + self.assertIn("while", deep_qemu.PREPARE_APT_CMD) + self.assertIn("sleep", deep_qemu.PREPARE_APT_CMD) + + def test_the_apt_probe_is_written_for_dash(self): + for interdit in ("[[", "pipefail", "seq "): + self.assertNotIn(interdit, deep_qemu.PREPARE_APT_CMD, interdit) + + +class TestChaqueEtageSonSousReseau(unittest.TestCase): + """Le « default » de libvirt sert 192.168.122.0/24 à TOUS les étages. + + Constaté au premier essai réel : l'étage 2, dont l'adresse était + 192.168.122.45 — servie par le « default » de son parent — a vu son propre + « net-start default » refusé net : + + error: internal error: Network is already in use by interface enp1s0 + + Un invité qui vit DANS un réseau ne peut pas servir le même.""" + + def test_two_levels_never_share_a_subnet(self): + vus = [deep_qemu.cidr_pour(p) for p in range(1, 11)] + self.assertEqual(len(set(vus)), 10, vus) + + def test_it_avoids_libvirts_own_and_the_hosts(self): + """122 est celui de libvirt, 123 celui de la machine où ce test a été + écrit : tomber sur l'un ou l'autre recréerait la collision.""" + for profondeur in range(1, 11): + prefixe = deep_qemu.cidr_pour(profondeur) + self.assertNotIn(prefixe, ("192.168.122", "192.168.123")) + + def test_the_subnet_is_derived_not_drawn(self): + # Deux appels pour la même profondeur donnent le même : rien de tiré + # au hasard, sinon --detruire et le diagnostic ne se retrouveraient pas. + self.assertEqual(deep_qemu.cidr_pour(3), deep_qemu.cidr_pour(3)) + + def test_a_depth_of_zero_or_less_still_gives_a_subnet(self): + for profondeur in (0, -1): + self.assertTrue(deep_qemu.cidr_pour(profondeur).startswith("192.")) + + def test_the_network_xml_is_one_line(self): + """Elle traverse deux couches de quoting pour atterrir dans dash : un + heredoc n'y survivrait pas.""" + xml = deep_qemu.reseau_xml("192.168.131") + self.assertNotIn("\n", xml) + self.assertIn("default", xml) + self.assertIn("192.168.131.1", xml) + self.assertIn("mode='nat'", xml) + + def test_the_dhcp_range_lives_in_its_own_subnet(self): + xml = deep_qemu.reseau_xml("192.168.137") + self.assertIn("start='192.168.137.10'", xml) + self.assertIn("end='192.168.137.200'", xml) + # Et la passerelle n'est pas dans la plage servie. + self.assertIn("address='192.168.137.1'", xml) + + def test_the_level_redefines_before_starting(self): + """« net-start » sur un réseau dont le sous-réseau collisionne échoue : + il faut le REDÉFINIR, pas seulement le démarrer.""" + faits = [] + d = deep_qemu.Descente.__new__(deep_qemu.Descente) + d.dry_run = False + d.journal = None + d.niveau_courant = 2 + d.profondeur_racine = 0 + + def executer(hote, cmd, delai, etiquette="", **k): + faits.append(cmd) + return 0, "NET: Active: yes\nUNITE:active\n" + + d.executer = executer + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertTrue(d.remettre_debout({"target": "h"})) + pose = faits[0] + self.assertLess(pose.index("net-undefine"), pose.index("net-define")) + self.assertLess(pose.index("net-define"), pose.index("net-start")) + # Le sous-réseau de CET étage, pas un autre. + self.assertIn("192.168.132", pose) + self.assertIn("192.168.132.0/24", sortie.getvalue()) + + def test_a_borrowed_root_shifts_every_subnet(self): + """Partir d'une racine déjà au troisième étage : le premier enfant est + au quatrième, et doit prendre le sous-réseau du quatrième.""" + faits = [] + d = deep_qemu.Descente.__new__(deep_qemu.Descente) + d.dry_run = False + d.journal = None + d.niveau_courant = 1 + d.profondeur_racine = 3 + d.executer = lambda h, c, delai, e="", **k: ( + faits.append(c) or (0, "NET: Active: yes\nUNITE:active\n") + ) + with contextlib.redirect_stdout(io.StringIO()): + d.remettre_debout({"target": "h"}) + self.assertIn(deep_qemu.cidr_pour(4), faits[0]) + + class TestLeControleArreteLaDescente(unittest.TestCase): """Un étage sans KVM ne casse pas : il bascule en émulation et continue. C'est ce silence-là que le contrôle doit rompre.""" diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index a7be68d..d87120d 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -1142,6 +1142,94 @@ class TestLeMenuDesDeuxTests(unittest.TestCase): self.assertEqual(args, " --hote erplibre@10.0.0.7") +class TestAucuneEtapeNeRepondNone(unittest.TestCase): + """Une étape qui rend None dit « non » à l'appelant, sans dire pourquoi. + + Vécu : l'extraction du moteur avait coupé `preparer_systeme` sur le + « return False » de sa boucle, sans son « return True » final. La descente + affichait « ✗ étage 1 systeme » et pas une ligne de cause — et l'essai à + blanc ne pouvait pas le voir, puisqu'il sort avant. Les deux piles étaient + cassées, aucun test ne l'a vu.""" + + CROCHETS = ( + "preparer_parent", + "creer_enfant", + "installer", + "noyau_convient", + "remettre_debout", + "controler", + "preparer_systeme", + "redemarrer_et_verifier", + ) + + def test_no_step_can_fall_through_to_none(self): + """Par l'AST, sur les trois fichiers : le dernier énoncé du corps d'une + étape doit être un return ou un raise, jamais une boucle ou un if dont + on peut sortir.""" + import ast + + chutes = [] + for chemin in ( + "long_test/descente.py", + "long_test/deep_proxmox.py", + "long_test/deep_qemu.py", + ): + arbre = ast.parse( + open(os.path.join(RACINE, chemin), encoding="utf-8").read() + ) + for noeud in ast.walk(arbre): + if ( + isinstance(noeud, ast.FunctionDef) + and noeud.name in self.CROCHETS + ): + dernier = noeud.body[-1] + if isinstance( + dernier, (ast.For, ast.While, ast.If, ast.Try) + ): + chutes.append(f"{chemin}:{noeud.lineno} {noeud.name}") + self.assertEqual(chutes, []) + + def test_the_real_path_of_preparer_systeme_answers_true(self): + """Éprouvé sur le vrai chemin, pas seulement à blanc : c'est l'essai à + blanc qui masquait le défaut, en sortant avant.""" + d = moteur.Descente.__new__(moteur.Descente) + d.dry_run = False + d.journal = None + d.niveau_courant = 1 + d.executer = lambda h, c, delai, etiquette="", **k: (0, "OK") + vrai = moteur.pve.run + moteur.pve.run = lambda h, r, t=120: (0, "10.0.0.2 22 10.0.0.1 22") + self.addCleanup(setattr, moteur.pve, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()): + self.assertIs(d.preparer_systeme({"target": "h"}), True) + + def test_a_failing_repair_answers_false_and_says_which(self): + d = moteur.Descente.__new__(moteur.Descente) + d.dry_run = False + d.journal = None + d.niveau_courant = 1 + d.executer = lambda h, c, delai, etiquette="", **k: (0, "hosts-KO") + vrai = moteur.pve.run + moteur.pve.run = lambda h, r, t=120: (0, "10.0.0.2 22 10.0.0.1 22") + self.addCleanup(setattr, moteur.pve, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertIs(d.preparer_systeme({"target": "h"}), False) + # Et il DIT laquelle : un « ✗ » sans cause envoie chercher partout. + self.assertIn("gel cloud-init", sortie.getvalue()) + + def test_an_unknown_access_address_is_named(self): + d = moteur.Descente.__new__(moteur.Descente) + d.dry_run = False + d.journal = None + d.niveau_courant = 1 + vrai = moteur.pve.run + moteur.pve.run = lambda h, r, t=120: (0, "") + self.addCleanup(setattr, moteur.pve, "run", vrai) + with contextlib.redirect_stdout(io.StringIO()) as sortie: + self.assertFalse(d.preparer_systeme({"target": "h"})) + self.assertIn("adresse d'accès inconnue", sortie.getvalue()) + + class TestPartirDunHoteExistant(unittest.TestCase): """Créer une VM de tête pour héberger un hyperviseur qu'on possède déjà coûte cinq minutes ET un étage d'imbrication — donc de la lenteur.""" From 6e6e99c3041d5dd3f1e9bf85187d5209e0b850a6 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 06:05:59 -0400 Subject: [PATCH 25/26] =?UTF-8?q?[FIX]=20long=5Ftest=20:=20bail=20attendu,?= =?UTF-8?q?=20rallumage=20=C3=A0=20froid,=20marge=20m=C3=A9moire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois mesures faites sur une descente à cinq étages, et une conclusion de ma part corrigée par le contre-essai. Le bail DHCP se fait attendre. L'étage 3 était créé, en type='kvm', et « domifaddr » ne rendait rien : l'invité n'avait pas encore demandé son adresse — 87 s puis 94 s selon les tours, quand deploy_qemu s'accorde 90 s et rend 0 sans l'avoir trouvée. Lu une fois, cela ne prouvait rien. Un redémarrage demandé à l'invité peut le laisser bloqué dans son micrologiciel : RIP immobile 46 minutes, pas un octet lu, trois vCPU à fond. J'ai d'abord conclu que c'était la taille de la mémoire, parce que la même machine à 2 Go démarrait. Le contre-essai à 4 Go l'a réfuté : elle démarre aussi, à froid. La différence est le REDÉMARRAGE, pas la mémoire — à chaud elle reste dans l'UEFI, à froid elle charge son noyau en 60 à 90 s, à 2, 3 et 4 Go. La descente rallume donc une fois par le parent, et une seule : une boucle de rallumage cacherait un vrai échec. La mémoire de la pile QEMU est doublée pour une autre raison, mesurée elle aussi : l'étage 2 avec 5 Go hébergeait un invité de 4 Go et n'avait plus que 127 Mo de libre. Ce n'est pas le plancher qui compte, c'est l'écart. --- EN --- Three measurements from a five-level descent, and a conclusion of mine refuted by the counter-test. The DHCP lease takes its time. Level 3 was created, type='kvm', and "domifaddr" returned nothing: the guest had not yet asked for its address — 87 s then 94 s depending on the run, while deploy_qemu allows itself 90 s and returns 0 without having found it. Read once, that proved nothing. A reboot asked of the guest can leave it stuck in its firmware: static RIP for 46 minutes, not a byte read, three vCPU at full tilt. I first concluded it was the memory size, because the same machine booted at 2 GB. The counter-test at 4 GB refuted it: it boots too, cold. The difference is the REBOOT, not the memory — warm it stays in UEFI, cold it loads its kernel in 60 to 90 s, at 2, 3 and 4 GB. The descent therefore power-cycles once through the parent, and only once: a restart loop would hide a real failure. The QEMU stack's memory is doubled for another, also measured reason: level 2 with 5 GB hosted a 4 GB guest and had 127 MB left. It is not the floor that matters, it is the gap. Assisted-by: claude-opus-5 (cherry picked from commit e3f60e3ddf066eb76d434bbfe6b01f2271fb1874) --- long_test/deep_qemu.py | 63 +++++++++++++++++++--- long_test/descente.py | 32 ++++++++++- script/proxmox/nesting.py | 11 ++-- test/test_deep_qemu.py | 105 ++++++++++++++++++++++++++++++++++--- test/test_todo_longtest.py | 4 +- 5 files changed, 193 insertions(+), 22 deletions(-) diff --git a/long_test/deep_qemu.py b/long_test/deep_qemu.py index 3dffe04..12e83e6 100644 --- a/long_test/deep_qemu.py +++ b/long_test/deep_qemu.py @@ -36,6 +36,7 @@ import re import shlex import subprocess import sys +import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, RACINE) @@ -327,6 +328,31 @@ class Descente(descente.Descente): ) return vu["libvirtd"] and vu["reseau"] + def rallumer_a_froid(self, parent, nom): + """« virsh destroy » puis « start » : un processus QEMU neuf. + + Mesuré sur la machine bloquée : à chaud elle restait 46 minutes au + même pointeur d'instruction, dans son micrologiciel ; à froid elle a + chargé son noyau en 60 à 90 secondes, trois fois de suite, à 2, 3 et + 4 Go. Ce n'est donc pas la taille de la mémoire — c'est la façon de + redémarrer. + """ + if self.dry_run or not parent: + return False + self.executer( + parent, + f"virsh -c qemu:///system destroy {nom} 2>/dev/null; true", + self.delai("controle"), + "extinction", + ) + code, _o = self.executer( + parent, + f"virsh -c qemu:///system start {nom}", + self.delai("controle"), + "rallumage", + ) + return code == 0 + def controler(self, hote): """CET étage peut-il héberger le suivant SANS l'émuler ? @@ -388,6 +414,35 @@ class Descente(descente.Descente): return None return ("default",) + def attendre_adresse(self, parent, nom): + """Le bail DHCP de l'enfant, attendu. Rend l'adresse, ou "". + + ATTENDU, et non lu une fois. Constaté au troisième étage : le domaine + était créé, en type='kvm', et « domifaddr » ne rendait rien — l'invité + n'avait pas encore demandé son bail. Plus l'étage est profond, plus il + démarre lentement, et c'est justement ce qu'on mesure. + + `deploy_qemu` attend lui-même l'adresse — 90 secondes par défaut — puis + rend 0 quand il ne l'a pas trouvée. Son code de sortie ne prouve donc + rien ici non plus. + """ + debut = time.time() + delai = self.delai("ssh") + while time.time() - debut < delai: + _c, sortie = self.executer( + parent, + f"virsh -c qemu:///system domifaddr {nom} --source lease", + DELAIS["controle"], + "domifaddr", + ) + adresse = parse_domifaddr(sortie) + if adresse: + if time.time() - debut > 20: + self.dire(f" bail après {int(time.time() - debut)} s") + return adresse + time.sleep(15) + return "" + def creer_enfant(self, parent, niveau, res, prepare, noter=None): """Une VM dans le parent, par NOTRE deploy_qemu.py. @@ -449,13 +504,7 @@ class Descente(descente.Descente): ) return None, None self.dire(f" domaine kvm, cpu {vu['cpu'] or '?'}") - _c, sortie = self.executer( - parent, - f"virsh -c qemu:///system domifaddr {nom} --source lease", - self.delai("ssh"), - "domifaddr", - ) - adresse = parse_domifaddr(sortie) + adresse = self.attendre_adresse(parent, nom) if not adresse: self.dire(" ✗ créée, mais sans adresse : rien à joindre") return None, None diff --git a/long_test/descente.py b/long_test/descente.py index 9762723..1e67be2 100644 --- a/long_test/descente.py +++ b/long_test/descente.py @@ -209,6 +209,22 @@ class Descente: """Les services de la pile répondent-ils, une fois redémarrés ?""" raise NotImplementedError + def rallumer_a_froid(self, parent, nom): + """Éteindre puis rallumer la machine DEPUIS SON PARENT. Rend True si + la pile sait le faire. + + Un redémarrage demandé à l'invité — « systemctl reboot » — peut le + laisser bloqué dans son micrologiciel : constaté une fois au troisième + étage, pointeur d'instruction immobile pendant 46 minutes, pas un + octet lu. Un démarrage à FROID, lui, repart d'un processus neuf, et + les trois essais faits ensuite ont tous abouti. + + La cause du gel n'est pas établie ; le remède ne l'attend pas. Une + descente ne doit pas passer six heures à guetter une machine qui ne + reviendra jamais, quand son parent peut la rallumer. + """ + return False + def controler(self, hote): """Cet étage peut-il HÉBERGER le suivant ? @@ -337,7 +353,7 @@ class Descente: time.sleep(15) return None - def redemarrer_et_verifier(self, hote): + def redemarrer_et_verifier(self, hote, parent=None, nom=""): """Redémarre, attend le retour, exige le noyau voulu. L'installation pose le noyau sans redémarrer — lancée par ssh, un @@ -379,6 +395,13 @@ class Descente: f" noyau {noyau} après {int(time.time() - debut)} s" ) return True + # Une seule reprise, et seulement si le parent peut la donner : la + # machine est peut-être bloquée dans son micrologiciel, où un + # redémarrage demandé à l'invité ne la sortira jamais. Une fois, pas + # deux — une boucle de rallumage cacherait un vrai échec. + if parent and nom and self.rallumer_a_froid(parent, nom): + self.dire(" ↻ rallumée à froid par son parent") + return self.redemarrer_et_verifier(hote) self.dire(" ✗ pas revenue sur le noyau attendu") return False @@ -582,7 +605,12 @@ class Descente: # terminé sans qu'on sache s'il pouvait héberger le suivant. for etape, action in ( ("install", lambda: self.installer(cible)), - ("reboot", lambda: self.redemarrer_et_verifier(cible)), + ( + "reboot", + lambda: self.redemarrer_et_verifier( + cible, parent, etage.get("nom", "") + ), + ), ("systeme", lambda: self.preparer_systeme(cible)), ("services", lambda: self.remettre_debout(cible)), ("controle", lambda: self.controler(cible)), diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index 14cab13..ca32175 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -183,12 +183,17 @@ COUTS_PVE = Couts( # Un hôte libvirt nu : libvirtd et qemu-kvm, rien d'autre. Le poste qui domine # n'est plus le système mais l'IMAGE CLOUD que l'étage télécharge pour créer # son enfant — d'où un disque cible qui n'est pas si petit. +# +# La mémoire a été DOUBLÉE après une descente à cinq étages : l'étage 2, avec +# 5 Go, hébergeait un invité de 4 Go et il ne lui restait que 127 Mo de libre. +# Un parent qui ne garde pas de quoi respirer sert mal son enfant, et deux +# gibioctets d'écart plutôt qu'un lui laissent cette marge. COUTS_QEMU = Couts( - ram_par_etage=1024, + ram_par_etage=2048, disque_par_etage=6, - ram_cible=2048, + ram_cible=4096, disque_cible=20, - ram_min=1024, + ram_min=2048, disque_min=12, ) diff --git a/test/test_deep_qemu.py b/test/test_deep_qemu.py index 6e7bf99..7e1380d 100644 --- a/test/test_deep_qemu.py +++ b/test/test_deep_qemu.py @@ -308,15 +308,18 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.d.niveau_courant = 2 self.d._envoyer_cli = lambda hote: True - def _machine(self, xml, adresse=" x y ipv4 10.0.0.9/24"): + def _machine(self, xml, adresse="10.0.0.9"): + """Chaque test une seule chose : l'attente du bail est bouchonnée ici, + elle a sa propre classe. Sans ce bouchon, un enfant sans adresse + faisait tourner la vraie boucle d'attente — des heures.""" + def executer(hote, cmd, delai, etiquette="", **k): if "dumpxml" in cmd: return 0, xml - if "domifaddr" in cmd: - return 0, adresse return 0, "" self.d.executer = executer + self.d.attendre_adresse = lambda parent, nom: adresse def test_an_emulated_child_is_refused(self): self._machine("x") @@ -364,7 +367,7 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.assertEqual(vus, ["deep-qemu-4"]) def test_a_child_without_an_address_is_refused(self): - self._machine("", adresse=" x y N/A N/A") + self._machine("", adresse="") with contextlib.redirect_stdout(io.StringIO()) as sortie: identite, _a = self.d.creer_enfant( {"target": "p"}, @@ -376,6 +379,74 @@ class TestUneVmEmuleeNestPasUneMesure(unittest.TestCase): self.assertIn("sans adresse", sortie.getvalue()) +class TestLeBailSeFaitAttendre(unittest.TestCase): + """Constaté au troisième étage : le domaine était créé, en type='kvm', et + « domifaddr » ne rendait rien — l'invité n'avait pas encore demandé son + bail. Plus l'étage est profond, plus il démarre lentement, et c'est + justement ce qu'on mesure. + + `deploy_qemu` attend lui-même l'adresse puis rend 0 quand il ne l'a pas + trouvée : son code de sortie ne prouve rien ici non plus.""" + + def setUp(self): + self.d = deep_qemu.Descente.__new__(deep_qemu.Descente) + self.d.dry_run = False + self.d.journal = None + self.d.niveau_courant = 3 + self.d.profondeur_racine = 0 + vrai = deep_qemu.time.sleep + deep_qemu.time.sleep = lambda _s: None + self.addCleanup(setattr, deep_qemu.time, "sleep", vrai) + + def test_it_retries_until_the_lease_appears(self): + tours = {"n": 0} + + def executer(hote, cmd, delai, etiquette="", **k): + tours["n"] += 1 + if tours["n"] < 3: + return 0, " x y N/A N/A" + return 0, " vnet0 52:54:00:aa:bb:cc ipv4 192.168.133.42/24" + + self.d.executer = executer + with contextlib.redirect_stdout(io.StringIO()): + self.assertEqual( + self.d.attendre_adresse({"target": "p"}, "deep-qemu-3"), + "192.168.133.42", + ) + self.assertEqual(tours["n"], 3) + + def test_a_single_probe_would_have_missed_it(self): + """Le contrôle qui dit pourquoi la boucle existe : au premier tour, il + n'y a rien à lire. + + Borné DANS la sonde plutôt qu'en détournant l'horloge : détourner + time.time détourne aussi celle d'unittest, et le test ne finissait + plus. Vécu il y a dix minutes. + """ + tours = {"n": 0} + + def executer(hote, cmd, delai, etiquette="", **k): + tours["n"] += 1 + if tours["n"] > 4: + raise AssertionError("sondé sans fin") + return 0, " x y N/A N/A" + + self.d.executer = executer + with contextlib.redirect_stdout(io.StringIO()): + with self.assertRaises(AssertionError): + self.d.attendre_adresse({"target": "p"}, "deep-qemu-3") + # Plusieurs tours, pas un seul : c'est tout l'objet de la boucle. + self.assertGreater(tours["n"], 1) + + def test_the_wait_grows_with_the_depth(self): + """Le budget est celui du ssh à cet étage : un invité au quatrième + démarre des dizaines de fois plus lentement qu'au premier.""" + self.d.niveau_courant = 1 + court = self.d.delai("ssh") + self.d.niveau_courant = 4 + self.assertGreater(self.d.delai("ssh"), court) + + class TestNeDetruireQueLeSien(unittest.TestCase): """« virsh undefine --remove-all-storage » efface un disque pour de bon.""" @@ -463,18 +534,36 @@ class TestLesDeuxTestsLongsSeRessemblent(unittest.TestCase): self.pve.FAMILLE.detruire_une, self.qemu.FAMILLE.detruire_une ) - def test_the_qemu_stack_asks_for_less(self): - """libvirtd seul tient dans un gibioctet là où cinq démons PVE en - demandent deux.""" + def test_the_qemu_stack_asks_for_less_disk(self): + """Une Debian avec qemu-kvm occupe ~3 Go là où un nœud Proxmox en + prend 5,6 — et l'image cloud que l'étage télécharge pour son enfant + pèse plus lourd que son propre système.""" from script.proxmox import nesting pve = nesting.nesting_plan(3, 28, 39000, 150) qemu = nesting.nesting_plan(3, 28, 39000, 150, nesting.COUTS_QEMU) - self.assertLess(qemu["niveaux"][0]["ram"], pve["niveaux"][0]["ram"]) self.assertLess( qemu["niveaux"][0]["disque"], pve["niveaux"][0]["disque"] ) + def test_every_parent_keeps_room_to_breathe(self): + """La mémoire de la pile QEMU a été DOUBLÉE après mesure, et n'est + donc plus inférieure à celle de Proxmox : à cinq étages, l'étage 2 + avait 5 Go, hébergeait un invité de 4 Go, et il ne lui restait que + 127 Mo de libre. Ce qui compte n'est pas le plancher mais l'ÉCART — + un parent qui ne respire pas sert mal son enfant.""" + from script.proxmox import nesting + + niveaux = nesting.nesting_plan(5, 28, 39000, 200, nesting.COUTS_QEMU)[ + "niveaux" + ] + for parent, enfant in zip(niveaux, niveaux[1:]): + self.assertGreaterEqual( + parent["ram"] - enfant["ram"], + 2048, + f"étage {parent['niveau']} n'a pas 2 Go de marge", + ) + if __name__ == "__main__": unittest.main() diff --git a/test/test_todo_longtest.py b/test/test_todo_longtest.py index d87120d..a35de96 100644 --- a/test/test_todo_longtest.py +++ b/test/test_todo_longtest.py @@ -405,7 +405,7 @@ class TestUnRapportQuiSurvitAuProcessus(unittest.TestCase): 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 + d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True d.remettre_debout = lambda cible: True d.preparer_systeme = lambda cible: True d.controler = lambda cible: True @@ -917,7 +917,7 @@ class TestUneVmCreeeEstToujoursNommee(unittest.TestCase): d.ecrire_alias = lambda *a, **k: None d.attendre_ssh = lambda cible, delai, parent=None: 1 d.installer = lambda cible: True - d.redemarrer_et_verifier = lambda cible: True + d.redemarrer_et_verifier = lambda cible, parent=None, nom="": True d.remettre_debout = lambda cible: True d.preparer_systeme = lambda cible: True d.controler = lambda cible: True From 92a7634e4f8388678f1e324f1f3bc1b81cde6426 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 06:56:24 -0400 Subject: [PATCH 26/26] =?UTF-8?q?[UPD]=20imbrication=20:=20le=20plan=20QEM?= =?UTF-8?q?U=20vise=2024=20Go=20au=20premier=20=C3=A9tage?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cible de 6 Go et pas de 2 Go : le couple fait tomber le premier étage d'une descente à dix sur exactement 24 Go, ce qu'une machine à 36 Go porte. Descendre plus bas au fond ne servirait à rien. Ce qui compte n'est pas le plancher mais l'ÉCART entre un parent et son enfant : mesuré à 127 Mo de libre quand il n'était que d'un gibioctet, un parent qui ne respire pas sert mal la machine qu'il héberge. --- EN --- A 6 GB target and a 2 GB step: the pair makes the first level of a ten-level descent land on exactly 24 GB, which a 36 GB machine can carry. Going lower at the bottom would gain nothing. What matters is not the floor but the GAP between a parent and its child: measured at 127 MB free when it was only one gibibyte, a parent with no room to breathe serves badly the machine it hosts. Assisted-by: claude-opus-5 (cherry picked from commit 01aab4d06b10334df9036f5404c6ed9c4ba3356c) --- script/proxmox/nesting.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/script/proxmox/nesting.py b/script/proxmox/nesting.py index ca32175..1cbcab2 100644 --- a/script/proxmox/nesting.py +++ b/script/proxmox/nesting.py @@ -188,10 +188,15 @@ COUTS_PVE = Couts( # 5 Go, hébergeait un invité de 4 Go et il ne lui restait que 127 Mo de libre. # Un parent qui ne garde pas de quoi respirer sert mal son enfant, et deux # gibioctets d'écart plutôt qu'un lui laissent cette marge. +# Le couple (6 Go de cible, 2 Go par étage) n'est pas arbitraire : il fait +# tomber le PREMIER étage d'une descente à dix sur exactement 24 Go, ce qu'une +# machine à 36 Go peut porter. Descendre plus bas au fond ne servirait à rien — +# c'est l'écart entre parent et enfant qui compte, mesuré à 127 Mo de libre +# quand il n'était que d'un gibioctet. COUTS_QEMU = Couts( ram_par_etage=2048, disque_par_etage=6, - ram_cible=4096, + ram_cible=6144, disque_cible=20, ram_min=2048, disque_min=12,