From b46615f3cf274fba416f33bdcc4cf515669f6e7b Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Fri, 28 Aug 2026 01:31:36 -0400 Subject: [PATCH] =?UTF-8?q?[REF]=20long=5Ftest=20:=20moteur=20commun,=20s?= =?UTF-8?q?=C3=BBret=C3=A9=20d=C3=A9clar=C3=A9e,=20sixi=C3=A8me=20=C3=A9ta?= =?UTF-8?q?pe?= 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"],