From 48a1d5ce201578f7431c601969a30a85b3f76dce Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 00:43:40 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20cache=20et=20journal=20:=20h=C3=B4te=20?= =?UTF-8?q?imbriqu=C3=A9=20soustrait,=20d=C3=A9cision=20=C3=A9crite?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Un invité sans magasin de confiance ne peut RIEN recevoir, et sur un hôte Proxmox c'est l'HÔTE qu'il faut excepter : imbriqué, l'invité sort masqué derrière lui et le pont ne voit jamais sa propre adresse. Mesuré depuis l'invité : code 000 et vérification SSL 19, puis 200 et 0. Sans cela le gestionnaire de paquets se rabattait sur 564 dérivations à construire. Le journal par VM ne portait AUCUN message du menu : la cause vivait sur une console qui défile, et le fichier qu'on rouvre après l'échec n'avait que le symptôme. Et « db_drop_all » annonçait détruites des bases qui ne l'étaient pas, son code de retour jeté. --- EN --- A guest with no trust store can receive NOTHING, and on a Proxmox host it is the HOST that must be exempted: nested, the guest leaves masqueraded behind it and the bridge never sees its own address. Measured from the guest: code 000 and SSL verification 19, then 200 and 0. Without it the package manager fell back to building 564 derivations. The per-VM log carried NO message from the menu: the cause lived on a console that scrolls away, and the file reopened after failure held only the symptom. And "db_drop_all" announced databases as dropped that were not, its exit status discarded. Assisted-by: Claude Opus 5 --- script/database/db_drop_all.py | 12 ++- script/todo/proxmox_menu.py | 134 ++++++++++++++++++++++++++-- script/todo/qemu_deploy.py | 8 ++ script/todo/qemu_install_monitor.py | 14 ++- script/todo/todo_i18n.py | 20 +++++ test/test_database_tools.py | 69 ++++++++++++++ test/test_proxmox_form.py | 134 ++++++++++++++++++++++++++++ test/test_qemu_deploy_monitor.py | 22 +++++ 8 files changed, 406 insertions(+), 7 deletions(-) diff --git a/script/database/db_drop_all.py b/script/database/db_drop_all.py index 6ce1622..2531031 100755 --- a/script/database/db_drop_all.py +++ b/script/database/db_drop_all.py @@ -72,7 +72,17 @@ def main(): cmd_end += f' "./odoo_bin.sh db --drop --database {db_name}"' lst_db_name.append(db_name) if cmd_end: - execute_shell(cmd_all + cmd_end) + code, sortie = execute_shell(cmd_all + cmd_end) + if code: + # Une destruction qui annonce un succès qu'elle n'a pas obtenu + # est pire que celle qui échoue : l'opérateur passe à la suite en + # croyant ses bases parties. Le cas s'atteint dès que « parallel » + # manque du PATH — le shell rend 127 et pas une base n'est + # touchée, pendant que la liste s'affiche. + print("Database NOT deleted :", file=sys.stderr) + if sortie: + print(sortie, file=sys.stderr) + return code print("Database deleted :") for db_name in lst_db_name: print(db_name) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 91f3c37..13bf1d1 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -15,12 +15,14 @@ qu'elles réutilisent volontairement plutôt que de les redire.""" import os import re import shlex +import shutil import subprocess import time import click from script.todo import todo_prefs +from script.todo.qemu_privilege import virsh_argv from script.todo.todo_i18n import t @@ -1733,6 +1735,112 @@ class ProxmoxMenuMixin: return "" return self._qemu_cache_ca_path() + @staticmethod + def _pve_note(vm, ligne): + """Dit la ligne à l'écran ET la garde pour le journal de CETTE VM. + + La console défile et se perd ; le journal est ce qu'on rouvre quand + l'installation a échoué, parfois le lendemain. Une décision prise ici + — l'autorité du cache posée ou non, l'exception — n'explique la panne + que si elle atteint le second. Sans cela le journal ne porte que le + symptôme : des centaines de lignes de construction et un certificat + refusé, sans un mot sur ce qui l'a voulu. + """ + print(ligne) + vm.setdefault("notes", []).append(ligne.strip()) + + def _pve_cache_bypass_hote(self, host, vm): + """Soustrait au cache l'hôte Proxmox qui porte un invité sans magasin. + + Rend True quand l'exception est en place, False quand il n'y a rien à + faire ou qu'elle a échoué. + + POURQUOI LA MAC DE L'HÔTE, ET NON CELLE DE L'INVITÉ. Un invité + imbriqué sort MASQUÉ derrière son hôte : sur le pont d'ici, le cache + ne voit jamais que la MAC de l'hôte Proxmox, et c'est donc elle + qu'il faut excepter. Mesuré des deux côtés — sans l'exception, une + requête de l'invité vers cache.nixos.org rend code 000 et + vérification SSL 19 ; avec, code 200 et vérification 0. + + CE QUE COÛTE L'ABSENCE DE REMÈDE. Une distribution dont le magasin de + confiance n'a pas de forme par fichier ne peut pas recevoir + l'autorité, et poser celle-ci par déclaration arriverait trop tard : + sur un système déclaratif, la première reconstruction EST le premier + téléchargement. Le gestionnaire de paquets ne lit alors plus son cache + binaire, se rabat sur la construction depuis les sources — des + centaines de dérivations — et ces sources échouent pour la même + raison. L'installation part pour une heure avant de rendre 1. + + CE QU'ELLE COÛTE. L'exception vaut pour TOUT ce que l'hôte relaie, y + compris ses propres téléchargements : il cesse de profiter du cache. + C'est le prix, et il est dit plutôt que subi. + """ + try: + mod = self._qemu_import_module() + except Exception: # pragma: no cover - dépend du module + mod = None + if not mod: + # Un module qui ne se charge pas ne doit pas emporter la suite de + # la création : sans lui on ne sait pas si l'invité a un magasin + # de confiance, et l'autorité reste la voie par défaut. + return False + distro = vm.get("distro") or "" + if not mod.cache_sans_autorite(distro): + return False + nom = (host.get("target") or "").split("@")[-1] + if not nom or nom not in set(self._qemu_list_domains()): + # Un hôte Proxmox qui ne vit pas ici ne traverse pas ce pont. + return False + mac = self._qemu_domain_mac(nom) + if not mac: + self._pve_note( + vm, f" ⚠ {t('download cache: host MAC not found')} : {nom}" + ) + return False + geste = ( + f"{shlex.quote(mod.CACHE_BIN)} --bypass-add {shlex.quote(mac)}" + f" --bypass-name {shlex.quote(nom)}" + ) + # Le binaire écrit le fichier d'exceptions et rend sur sa sortie le + # geste à chaud ; sans nft, seul le redémarrage du service repose la + # chaîne entière. + if shutil.which("nft"): + cmd = ["sudo", "sh", "-c", f"{geste} | nft -f -"] + else: + cmd = ["sudo", "sh", "-c", geste] + try: + fini = subprocess.run(cmd, capture_output=True, text=True) + except (OSError, subprocess.SubprocessError) as souci: + print(f" ⚠ {t('download cache bypass not set')} ({souci})") + return False + if fini.returncode: + print( + f" ⚠ {t('download cache bypass not set')}" + f" ({fini.returncode})" + ) + return False + self._pve_note( + vm, f" ✓ {t('host taken out of the download cache')} : {nom}" + ) + self._pve_note( + vm, f" {t('its own downloads stop being cached too.')}" + ) + return True + + def _qemu_domain_mac(self, nom): + """Première MAC du domaine libvirt `nom`, ou ''.""" + try: + res = subprocess.run( + virsh_argv("domiflist", nom), + capture_output=True, + text=True, + timeout=15, + ) + except (OSError, subprocess.SubprocessError): + return "" + trouve = re.findall(r"[0-9a-f]{2}(?::[0-9a-f]{2}){5}", res.stdout) + return trouve[0].lower() if trouve else "" + def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False): """Pose l'autorité du cache DANS la VM, par ssh. @@ -1775,7 +1883,7 @@ class ProxmoxMenuMixin: "no trust store for this distribution, its downloads " "will fail" ) - print(f" ⚠ {t(cle)} : {vm.get('distro') or '?'}") + self._pve_note(vm, f" ⚠ {t(cle)} : {vm.get('distro') or '?'}") return False morceaux = [] for chemin, mode, contenu, _proprio in fichiers: @@ -1793,7 +1901,7 @@ class ProxmoxMenuMixin: f" ⚠ {t('download cache authority not installed')} ({code})" ) return False - print(f" ✓ {t('download cache authority installed')}") + self._pve_note(vm, f" ✓ {t('download cache authority installed')}") return True def _pve_attendre_ssh(self, cible, delai=300, pas=10): @@ -2183,9 +2291,17 @@ class ProxmoxMenuMixin: # range ses index sous l'hôte demandé, et une VM qui en # réclame un autre ne retrouve rien de ce qui est gardé. self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu) - self._pve_set_cache_ca( - vm["alias"], vm, ca_cache, hors_ligne=bool(coupee) - ) + # Un invité dont le magasin de confiance n'a pas de forme + # par fichier ne peut RIEN recevoir : on soustrait son + # hôte au cache à la place. L'autorité n'est posée que + # lorsqu'il y a quelqu'un pour la recevoir. + if not self._pve_cache_bypass_hote(host, vm): + self._pve_set_cache_ca( + vm["alias"], + vm, + ca_cache, + hors_ligne=bool(coupee), + ) # Après la création, qui a posé l'écran accéléré : l'accès au # nœud de rendu est une affaire de COMPTE, et il se donne # dans l'invité. @@ -2285,6 +2401,14 @@ class ProxmoxMenuMixin: ) for vm in joignables }, + # Ce que l'hôte a DÉCIDÉ pour chaque VM avant de lancer + # l'installation. Sans cela, le journal ne porte que le + # symptôme, et la cause reste sur une console qui défile. + notes={ + vm["name"]: vm.get("notes") or [] + for vm in joignables + if vm.get("notes") + }, ) return resultat # Sans suivi mais avec quelque chose à installer : en série, sortie à diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 59ff909..4e3c0ec 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -366,6 +366,7 @@ class QemuDeployMixin: vm_tools=(), pve=None, meta=None, + notes=None, ai_agent="", guet_hors_ligne=False, deploy_started=None, @@ -386,6 +387,8 @@ class QemuDeployMixin: `vm_tools` : outils cochés pour tout le parc, filtrés machine par machine (Android Studio n'existe qu'en x86_64, les extensions GNOME n'ont pas de sens sous Cinnamon). + `notes` : {nom: [lignes]} — ce que l'hôte a décidé pour cette VM + avant l'installation, recopié en tête de son journal. `meta` : {nom: (distro, version, arch)} quand l'appelant SAIT ce que sont ces VM. Sans elle, on le demande à virsh — juste ici, donc faux pour une VM qui vit sur un Proxmox distant.""" @@ -449,6 +452,11 @@ class QemuDeployMixin: # connaît pas. if (pve or {}).get(name): entry["pve"] = pve[name] + # Ce que l'hôte a décidé AVANT de lancer l'installation : + # écrit en tête du journal, là où on le cherche quand ça a + # échoué. La console qui l'a dit a défilé depuis. + if (notes or {}).get(name): + entry["notes"] = list(notes[name]) # Les outils imposent une commande PAR VM même quand tout le # reste est commun : ils dépendent de l'architecture de la # machine et de sa saveur de bureau, que seule cette boucle diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index ff0cecd..270b1b8 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -352,6 +352,16 @@ def _log_header(vm: dict, branch: str, when: str) -> str: bar = "=" * 64 titre = t("installation") if branch else t("VM start-up") ligne_branche = f" Branche : {branch}\n" if branch else "" + # Ce que l'hôte a décidé AVANT de lancer : l'autorité du cache posée ou + # non, l'exception, le miroir. Ces lignes sont dites à la console au + # moment où elles se prennent, et la console a défilé depuis. Le journal + # est ce qu'on rouvre quand l'installation a échoué — sans elles il ne + # porte que le symptôme. + notes = vm.get("notes") or [] + bloc = "" + if notes: + corps = "\n".join(f" {ligne}" for ligne in notes) + bloc = f" {t('Prepared by the host:')}\n{corps}\n{bar}\n" return ( f"{bar}\n" f" ERPLibre — {titre}\n" @@ -361,7 +371,9 @@ def _log_header(vm: dict, branch: str, when: str) -> str: f" Architecture : {arch}\n" f"{ligne_branche}" f" IP : {vm['ip']}\n" - f"{bar}\n\n" + f"{bar}\n" + f"{bloc}" + f"\n" ) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 6eb4ef9..a86b9e6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -7268,6 +7268,26 @@ TRANSLATIONS = { "fr": "autorité du cache de téléchargement non posée", "en": "download cache authority not installed", }, + "Prepared by the host:": { + "fr": "Préparé par l'hôte :", + "en": "Prepared by the host:", + }, + "host taken out of the download cache": { + "fr": "hôte soustrait au cache de téléchargement", + "en": "host taken out of the download cache", + }, + "its own downloads stop being cached too.": { + "fr": "ses propres téléchargements cessent aussi d'être gardés.", + "en": "its own downloads stop being cached too.", + }, + "download cache bypass not set": { + "fr": "exception au cache de téléchargement non posée", + "en": "download cache bypass not set", + }, + "download cache: host MAC not found": { + "fr": "cache de téléchargement : MAC de l'hôte introuvable", + "en": "download cache: host MAC not found", + }, "no trust store for this distribution, its downloads " "will fail": { "fr": ( diff --git a/test/test_database_tools.py b/test/test_database_tools.py index 9797e01..424e5df 100644 --- a/test/test_database_tools.py +++ b/test/test_database_tools.py @@ -146,5 +146,74 @@ class TestCompareDatabaseApplicationLogic(unittest.TestCase): self.assertEqual(len(s2.difference(s1)), 0) +class UneDestructionNAnnonceQueCeQuElleAFait(unittest.TestCase): + """« make db_drop_all » composait une commande « parallel », jetait son + code de retour et imprimait la liste des bases comme détruites. + + Le cas s'atteint dès que « parallel » manque du PATH : le shell rend 127, + pas une base n'est touchée, et l'opérateur passe à la suite en croyant + ses bases parties. Une destruction qui annonce un succès qu'elle n'a pas + obtenu est pire que celle qui échoue. + """ + + def _module(self): + import importlib.util + + chemin = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "script/database/db_drop_all.py", + ) + spec = importlib.util.spec_from_file_location("db_drop_all", chemin) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + def _courir(self, code_destruction): + import contextlib + + mod = self._module() + + def faux_shell(cmd): + if "--list" in cmd: + return 0, "test_alpha\ntest_beta" + return code_destruction, ( + "" if not code_destruction else "parallel: command not found" + ) + + mod.execute_shell = faux_shell + + class Config: + database = "" + test_only = True + + mod.get_config = lambda: Config() + sortie, erreur = io.StringIO(), io.StringIO() + with contextlib.redirect_stdout(sortie): + with contextlib.redirect_stderr(erreur): + code = mod.main() + return code, sortie.getvalue(), erreur.getvalue() + + def test_a_failed_drop_is_not_announced_as_done(self): + code, sortie, erreur = self._courir(127) + self.assertEqual(127, code) + self.assertNotIn("Database deleted", sortie) + self.assertNotIn("test_alpha", sortie) + + def test_the_cause_reaches_the_operator(self): + """Le code de retour seul laisserait chercher : la sortie du shell + nomme ce qui manque.""" + _code, _sortie, erreur = self._courir(127) + self.assertIn("NOT deleted", erreur) + self.assertIn("parallel", erreur) + + def test_a_real_drop_is_still_announced(self): + """Le cas ordinaire ne change pas : les bases détruites se disent.""" + code, sortie, _erreur = self._courir(0) + self.assertEqual(0, code) + self.assertIn("Database deleted", sortie) + self.assertIn("test_alpha", sortie) + self.assertIn("test_beta", sortie) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index cadc2cd..e10dc5a 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -2621,5 +2621,139 @@ class TestUnInviteQueLAutoriteNAtteintPas(unittest.TestCase): self.assertNotIn("⚠", vu["ecrit"]) +class UnInviteImbriqueSortMasqueDerriereSonHote(unittest.TestCase): + """Une distribution sans magasin de confiance par fichier ne peut RIEN + recevoir, et le poser par déclaration arriverait trop tard : sur un + système déclaratif, la première reconstruction EST le premier + téléchargement. + + Le remède est de ne pas intercepter. Mais l'invité imbriqué sort MASQUÉ + derrière son hôte : sur le pont d'ici, le cache ne voit que la MAC de + l'hôte Proxmox, et c'est donc elle qu'il faut excepter. + """ + + def _todo(self): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO.__new__(TODO) + + class _Mod: + CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" + + @staticmethod + def cache_sans_autorite(distro): + return distro == "nixos" + + def _poser(self, distro, domaines=("pve-local",), mac="52:54:00:ab:cd:ef"): + import contextlib + import io + + todo = self._todo() + vu = {"cmd": None} + todo._qemu_import_module = lambda: self._Mod + todo._qemu_list_domains = lambda: list(domaines) + todo._qemu_domain_mac = lambda nom: mac + + class Fini: + returncode = 0 + stdout = "" + stderr = "" + + def faux_run(cmd, **kw): + vu["cmd"] = cmd + return Fini() + + import subprocess as sp + + vrai = sp.run + self.addCleanup(setattr, sp, "run", vrai) + sp.run = faux_run + with contextlib.redirect_stdout(io.StringIO()) as sortie: + vu["rendu"] = todo._pve_cache_bypass_hote( + {"target": "pve-local"}, {"distro": distro} + ) + vu["ecrit"] = sortie.getvalue() + return vu + + def test_a_guest_with_a_trust_store_is_left_alone(self): + """L'autorité suffit pour lui, et excepter son hôte lui retirerait le + cache sans rien lui rendre.""" + vu = self._poser("debian") + self.assertFalse(vu["rendu"]) + self.assertIsNone(vu["cmd"], "une commande lancée pour rien") + + def test_a_module_that_will_not_load_does_not_stop_the_creation(self): + """Sans le module on ne sait pas si l'invité a un magasin de + confiance : l'autorité reste la voie par défaut, et la suite de la + création continue.""" + import contextlib + import io + + todo = self._todo() + todo._qemu_import_module = lambda: None + with contextlib.redirect_stdout(io.StringIO()): + self.assertFalse( + todo._pve_cache_bypass_hote( + {"target": "pve-local"}, {"distro": "nixos"} + ) + ) + + def test_a_remote_host_never_crosses_this_bridge(self): + """Un hôte Proxmox qui ne vit pas ici n'est pas intercepté : rien à + excepter, et sa MAC ne nous appartient pas.""" + vu = self._poser("nixos", domaines=()) + self.assertFalse(vu["rendu"]) + self.assertIsNone(vu["cmd"]) + + def test_the_host_mac_is_the_one_excepted(self): + """Celle de l'invité n'apparaît jamais sur ce pont : il est masqué.""" + vu = self._poser("nixos") + self.assertTrue(vu["rendu"]) + joint = " ".join(vu["cmd"]) + self.assertIn("--bypass-add 52:54:00:ab:cd:ef", joint) + self.assertIn("--bypass-name pve-local", joint) + + def test_the_decision_is_kept_for_the_log(self): + """Dit à l'écran ET gardé : la console défile, le journal reste.""" + import contextlib + import io + + todo = self._todo() + vm = {"distro": "x"} + with contextlib.redirect_stdout(io.StringIO()) as sortie: + todo._pve_note(vm, " ✓ une décision") + self.assertIn("une décision", sortie.getvalue()) + self.assertEqual(["✓ une décision"], vm["notes"]) + + def test_the_menu_hands_them_to_the_installer(self): + """Gardées et non transmises, elles ne serviraient à personne.""" + from pathlib import Path + + racine = Path(__file__).resolve().parent.parent + src = (racine / "script/todo/proxmox_menu.py").read_text( + encoding="utf-8" + ) + self.assertIn("notes={", src) + dep = (racine / "script/todo/qemu_deploy.py").read_text( + encoding="utf-8" + ) + self.assertIn('entry["notes"] = list(notes[name])', dep) + + def test_the_price_is_said(self): + """L'exception vaut pour TOUT ce que l'hôte relaie, ses propres + téléchargements compris. Le taire ferait chercher plus tard pourquoi + le cache ne sert plus cet hôte.""" + vu = self._poser("nixos") + self.assertIn("pve-local", vu["ecrit"]) + self.assertIn("✓", vu["ecrit"]) + self.assertTrue( + len(vu["ecrit"].strip().splitlines()) >= 2, + f"le prix n'est pas dit : {vu['ecrit']!r}", + ) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_qemu_deploy_monitor.py b/test/test_qemu_deploy_monitor.py index 1ae2c66..ea70e33 100644 --- a/test/test_qemu_deploy_monitor.py +++ b/test/test_qemu_deploy_monitor.py @@ -215,6 +215,28 @@ class TestLeJournal(unittest.TestCase): self.assertIn(mon.t("VM start-up"), head) self.assertNotIn("Branche", head) + def test_what_the_host_decided_reaches_the_log(self): + """La console qui a dit la décision a défilé ; le journal est ce + qu'on rouvre quand l'installation a échoué. Sans ces lignes il ne + porte que le symptôme — des centaines de lignes de construction et + un certificat refusé, sans un mot sur ce qui l'a voulu.""" + vm = dict( + self._vm(), + notes=[ + "⚠ pas de magasin de confiance : nixos", + "✓ hôte soustrait au cache : pve-local", + ], + ) + head = mon._log_header(vm, "develop", "2026-01-01 00:00:00") + self.assertIn(mon.t("Prepared by the host:"), head) + self.assertIn("pas de magasin de confiance", head) + self.assertIn("hôte soustrait au cache", head) + + def test_a_log_without_notes_is_unchanged(self): + """Le cas ordinaire ne gagne pas une section vide.""" + head = mon._log_header(self._vm(), "develop", "2026-01-01 00:00:00") + self.assertNotIn(mon.t("Prepared by the host:"), head) + def test_the_prologue_says_what_actually_follows(self): """« installation ERPLibre en cours » alors que rien ne s'installe.""" import tempfile