From 8be55031ab8250c82084019a1d66031816a9c155 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 23:17:37 -0400 Subject: [PATCH 01/12] =?UTF-8?q?[FIX]=20suivi=20:=20effacer=20depuis=20un?= =?UTF-8?q?=20suivi=20rouvert=20v=C3=A9rifie=20d'abord=20l'identit=C3=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le tableau de bord se rouvre sur un manifeste passé — c'est fait pour, les installations partent détachées. Mais un nom de domaine se réemploie et un VMID libéré est RÉATTRIBUÉ : effacer « le 101 » d'un run de mars, c'est effacer ce qui porte le 101 aujourd'hui, et « erplibre-ubuntu-2604 » de mars n'est pas celui d'aujourd'hui. Même famille que tout le reste — on jugeait sur le nom, avec ici la pire conséquence. La commande porte donc son garde, et non l'écran : elle protège ainsi tous ses appelants, et la vérification se fait SUR la machine, à l'instant d'effacer. Sur Proxmox, le VMID doit encore porter ce nom. En local, l'UUID du domaine — relevé au lancement, seul instant où l'on sait que ce nom désigne bien cette machine-là. Un manifeste écrit avant ce correctif n'en a pas : il retombe sur la protection d'avant plutôt que de bloquer. Le garde du VMID est une fonction à part, exécutable telle quelle. Il traverse deux « shlex.quote » avant d'atteindre un dash, et un garde qu'on ne sait pas éprouver s'OUVRE le jour où il casse. Vérifié sur erplibre-proxmox-9 sans rien détruire : le VMID 100 refusé sous un nom périmé, accepté sous le sien. --- EN --- The dashboard reopens on a past manifest — by design, since installs run detached. But a domain name gets reused and a freed VMID is REASSIGNED: deleting "the 101" from a March run deletes whatever holds 101 today, and March's "erplibre-ubuntu-2604" is not today's. Same family as the rest — we judged by name, here with the worst consequence. The command carries its guard, not the screen: that protects every caller, and the check happens ON the machine, at the moment of deletion. On Proxmox the VMID must still bear that name. Locally, the domain's UUID — recorded at launch, the only moment we know that name means that machine. A manifest written before this fix has none: it falls back to the previous protection rather than blocking. The VMID guard is its own function, runnable as is. It crosses two "shlex.quote" layers before reaching a dash, and a guard you cannot exercise OPENS the day it breaks. Verified on erplibre-proxmox-9 without destroying anything: VMID 100 refused under a stale name, accepted under its own. Assisted-by: Claude Opus 5 --- script/todo/qemu_install_monitor.py | 87 ++++++++++++++++++++++++++--- test/test_qemu_monitor_pve.py | 63 +++++++++++++++++++++ 2 files changed, 141 insertions(+), 9 deletions(-) diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 5e085a1..c2a9849 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -313,6 +313,12 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # Une VM posée sur un hôte Proxmox : c'est LUI qui connaît son état. if vm.get("pve"): entree["pve"] = vm["pve"] + else: + # L'UUID du domaine, relevé MAINTENANT : c'est le seul instant où + # l'on sait que ce nom désigne bien cette machine. Rouvert des + # semaines plus tard, le suivi ne peut plus le savoir — et c'est + # lui qui arme le garde de la suppression. + entree["uuid"] = local_uuid(vm["name"]) entries.append(entree) manifest = { "branch": branch, @@ -324,6 +330,26 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: return manifest_path +def local_uuid(name: str) -> str: + """UUID du domaine libvirt local, ou "" s'il est illisible. + + Sans sudo d'abord : l'appartenance au groupe libvirt suffit souvent. Une + chaîne vide DÉSARME le garde plutôt que de bloquer — mieux vaut la + protection d'avant que refuser toute suppression sur un poste où virsh + demande un mot de passe.""" + base = ["virsh", "--connect", "qemu:///system", "domuuid", name] + for argv in (base, ["sudo", "-n"] + base): + try: + res = subprocess.run( + argv, capture_output=True, text=True, timeout=15 + ) + except (OSError, subprocess.SubprocessError): + continue + if res.returncode == 0 and res.stdout.strip(): + return res.stdout.strip() + return "" + + def finished_at(log_path: str, fallback: float) -> float: """Instant où l'installation s'est RÉELLEMENT arrêtée. @@ -1743,15 +1769,41 @@ def restart_odoo_cmd() -> str: ) -def delete_vm_cmd_pve(info, purge: bool = True) -> str: +def pve_identity_guard(vmid: int, name: str) -> str: + """Shell qui S'ARRÊTE si le VMID ne porte plus ce nom. + + Un VMID libéré est RÉATTRIBUÉ, et le suivi se rouvre sur un manifeste qui + peut avoir des semaines : effacer « le 101 » d'un run de mars, c'est + effacer ce qui porte le 101 aujourd'hui. + + Une fonction à part, et exécutable telle quelle : c'est ce qui la rend + vérifiable. Enfouie dans la commande, elle ne se testait qu'à travers deux + « shlex.quote » — et un garde qu'on ne sait pas éprouver s'OUVRE le jour + où il casse, au lieu de se fermer.""" + q = shlex.quote(name) + return ( + f"vu=$(qm config {int(vmid)} 2>/dev/null" + " | sed -n 's/^name: //p' | head -1); " + f'if [ "$vu" != {q} ]; then ' + f'echo "REFUS : le VMID {int(vmid)} porte maintenant $vu,"' + f' "et non {name}. Rien n\'a ete efface."; exit 1; fi; ' + ) + + +def delete_vm_cmd_pve(info, purge: bool = True, name: str = "") -> str: """Efface une VM sur son hôte PROXMOX, par son VMID. « virsh undefine » y aurait effacé le domaine LOCAL homonyme — le - même piège que partout ailleurs, avec la pire conséquence.""" + même piège que partout ailleurs, avec la pire conséquence. + + `name` arme le garde d'identité (voir `pve_identity_guard`) : sans lui, la + commande efface le VMID quoi qu'il porte aujourd'hui.""" vmid = int((info or {}).get("vmid") or 0) - suite = ( + suite = pve_identity_guard(vmid, name) if name else "" + suite += ( f"qm stop {vmid} --skiplock 1 || true; " - f"qm destroy {vmid}{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}" + f"qm destroy {vmid}" + f"{' --purge 1 --destroy-unreferenced-disks 1' if purge else ''}" ) return pve_host_cmd(info, suite) @@ -1789,12 +1841,26 @@ def delete_lines(vm) -> list: ] -def delete_vm_cmd(name: str, with_disks: bool) -> str: +def delete_vm_cmd(name: str, with_disks: bool, uuid: str = "") -> str: """Efface la VM sur l'HÔTE. Même séquence que « TODO._qemu_delete_vm » : arrêt, retrait de la définition (nvram si UEFI, repli sinon), puis les - disques à la demande.""" + disques à la demande. + + `uuid` arme un GARDE. Le suivi se rouvre sur un manifeste passé, et un nom + de domaine se réemploie : « erplibre-ubuntu-2604 » d'un run de mars n'est + pas forcément celui d'aujourd'hui. L'UUID, lui, naît avec le domaine et + meurt avec lui — c'est la seule chose qui distingue deux machines du même + nom.""" q = shlex.quote(name) - cmd = ( + cmd = "" + if uuid: + cmd = ( + f"vu=$(sudo virsh domuuid {q} 2>/dev/null | tr -d '[:space:]'); " + f'if [ "$vu" != {shlex.quote(uuid)} ]; then ' + f'echo "REFUS : {name} n\'est plus le même domaine"' + f' "($vu). Rien n\'a été effacé."; exit 1; fi; ' + ) + cmd += ( f"sudo virsh destroy {q} 2>/dev/null; " f"sudo virsh undefine {q} --nvram 2>/dev/null " f"|| sudo virsh undefine {q}" @@ -3081,10 +3147,13 @@ def run_monitor(manifest_path: str, run_app: bool = True): if not yes: return info = vm.get("pve") + # Le garde d'identité voyage avec la VM : c'est ce qui + # rend une suppression sûre depuis un suivi ROUVERT, dont le + # manifeste peut avoir des semaines. cmd = ( - delete_vm_cmd_pve(info) + delete_vm_cmd_pve(info, name=vm["name"]) if info - else delete_vm_cmd(vm["name"], True) + else delete_vm_cmd(vm["name"], True, vm.get("uuid") or "") ) with self.suspend(): print(f"\n=== Suppression — {vm['name']} ===") diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index d69f5ef..ef94522 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -230,6 +230,69 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) +class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase): + """Le suivi se ROUVRE sur un manifeste passé — c'est fait pour. + + Mais un nom de domaine se réemploie, et un VMID libéré est RÉATTRIBUÉ. + Effacer « le 101 » d'un run de mars, c'est effacer ce qui porte le 101 + aujourd'hui, et « erplibre-ubuntu-2604 » de mars n'est pas celui + d'aujourd'hui. Même famille que tout le reste : on jugeait sur le nom. + + La commande porte donc son garde. Dans la commande et non dans l'écran : + elle protège ainsi tous ses appelants, et la vérification se fait SUR la + machine, à l'instant d'effacer.""" + + def test_a_proxmox_delete_checks_the_vmid_still_bears_the_name(self): + cmd = mon.delete_vm_cmd_pve( + {"target": "pve9", "vmid": 101}, name="vm-a" + ) + self.assertIn("qm config 101", cmd) + self.assertIn("exit 1", cmd) + self.assertIn("qm destroy 101", cmd) + # Le garde vient AVANT la destruction, sinon il ne garde rien. + self.assertLess(cmd.index("qm config 101"), cmd.index("qm destroy")) + + def test_a_local_delete_checks_the_uuid(self): + cmd = mon.delete_vm_cmd("vm-a", True, "5d55d05a-1e77") + self.assertIn("virsh domuuid vm-a", cmd) + self.assertIn("5d55d05a-1e77", cmd) + self.assertLess(cmd.index("domuuid"), cmd.index("virsh destroy vm-a")) + + def test_an_old_manifest_without_identity_still_deletes(self): + # Un manifeste écrit avant ce correctif n'a pas d'UUID. Refuser toute + # suppression y serait une régression : on retombe sur la protection + # d'avant, la confirmation à deux mains. + cmd = mon.delete_vm_cmd("vm-a", True) + self.assertNotIn("domuuid", cmd) + self.assertIn("virsh undefine vm-a", cmd) + sans_nom = mon.delete_vm_cmd_pve({"target": "pve9", "vmid": 101}) + self.assertNotIn("qm config", sans_nom) + self.assertIn("qm destroy 101", sans_nom) + + def test_the_guard_is_shell_correct(self): + """Le garde est EXÉCUTÉ, « qm » bouchonné par une fonction shell. + + Il traverse ensuite deux « shlex.quote » avant d'atteindre un dash : + chaque niveau est une occasion de le casser, et un garde cassé + s'OUVRE au lieu de se fermer. Éprouvé aussi sur le vrai hôte, où il a + refusé un nom périmé et laissé passer le bon.""" + import subprocess + + garde = mon.pve_identity_guard(101, "vm-a") + for vu, attendu in (("vm-a", 0), ("autre-vm", 1)): + res = subprocess.run( + [ + "sh", + "-c", + f"qm() {{ echo 'name: {vu}'; }}; {garde} exit 0", + ], + capture_output=True, + text=True, + ) + self.assertEqual(res.returncode, attendu, f"{vu} : {res.stdout}") + self.assertIn("vm-a", garde) + + class TestCeQueLaConfirmationPromet(unittest.TestCase): """La confirmation de suppression annonçait un fichier qcow2 local à TOUTE VM, Proxmox comprise. From 2462798f4e3269b3fa0d4304a818bff93ccae26f Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 23:28:23 -0400 Subject: [PATCH 02/12] [FIX] analyse: retirer un constat qui faisait peur pour rien MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « ir_model_relation nomme une table absente » : 0 avant la migration, 68 après. Le profil idéal — et aucune conséquence. Son unique consommateur, _module_data_uninstall dans base/models/ir_model.py, teste sql.table_exists() AVANT de supprimer : la ligne périmée est ignorée, puis effacée. J'écrivais « la prochaine mise à jour de module tente de la modifier et échoue » : c'est faux. Les 68 appartiennent de plus à des modules INSTALLÉS, et database_cleanup ne touche que les désinstallés — la réparation désignée n'en aurait réparé aucune. Un constat sans conséquence et sans geste possible est du bruit, quelle que soit la netteté du signal. Restent trois constats, tous réels. --- EN --- « ir_model_relation names a missing table »: 0 before the migration, 68 after. The ideal profile — and no consequence whatsoever. Its only consumer, _module_data_uninstall in base/models/ir_model.py, tests sql.table_exists() BEFORE dropping: the stale row is skipped, then unlinked. I wrote "the next module update tries to alter it and fails": that is false. The 68 also belong to INSTALLED modules, and database_cleanup only touches uninstalled ones — the repair I named would have fixed none. A finding with no consequence and no possible action is noise, however clean the signal. Three findings remain, all real. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_residue.py | 25 +++++++++++++---------- script/todo/todo_i18n.py | 8 -------- 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index eaef2d6..b171851 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -35,9 +35,22 @@ parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures : liste de prix par défaut absente 0 → 1 `product` installé, son xmlid pas là -Zéro avant, non nul après : aucun de ces quatre ne peut s'expliquer +Zéro avant, non nul après : aucun de ces trois ne peut s'expliquer autrement que par la migration. +Un quatrième a été RETIRÉ après vérification +-------------------------------------------- +« ir_model_relation nomme une table absente » : 0 avant, 68 après, le +profil idéal. Et sans la moindre conséquence. Son unique consommateur, +`_module_data_uninstall` dans `base/models/ir_model.py`, teste +`sql.table_exists(...)` AVANT de supprimer : la ligne périmée est +ignorée, puis effacée. Les 68 appartiennent en outre à des modules +INSTALLÉS, que `database_cleanup` ne touche pas — la réparation que ce +fichier désignait n'en aurait réparé aucune. + +Un constat sans conséquence et sans geste possible est du bruit, quelle +que soit la netteté du signal. Il est parti. + Chaque constat nomme l'outil qui le répare. Un rapport qui montre un dégât sans dire quoi lancer oblige à chercher, et on ne cherche pas. @@ -101,16 +114,6 @@ CONTROLES = ( "gravity": "broken", "repair": None, }, - { - "key": "orphan_relation", - "title": "Many-to-many tables named but missing", - "why": "ir_model_relation still names a table PostgreSQL does not" - " have; the next module update tries to alter it and fails.", - "sql": "SELECT count(*) FROM ir_model_relation r WHERE NOT EXISTS" - " (SELECT 1 FROM pg_class c WHERE c.relname = r.name)", - "gravity": "broken", - "repair": "script/analyse/database_cleanup.py", - }, { "key": "duplicate_index", "title": "Indexes duplicated by the Odoo 17 renaming", diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 40f038e..d47cfc9 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -9564,14 +9564,6 @@ TRANSLATIONS = { "fr": "Un booléen qui n'est ni vrai ni faux : la langue n'est listée nulle part et ne peut plus être réactivée depuis l'interface.", "en": "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.", }, - "Many-to-many tables named but missing": { - "fr": "Tables plusieurs-à-plusieurs nommées mais absentes", - "en": "Many-to-many tables named but missing", - }, - "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.": { - "fr": "ir_model_relation nomme encore une table que PostgreSQL n'a pas ; la prochaine mise à jour de module tente de la modifier et échoue.", - "en": "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.", - }, "Indexes duplicated by the Odoo 17 renaming": { "fr": "Index doublés par le renommage d'Odoo 17", "en": "Indexes duplicated by the Odoo 17 renaming", From 0551d807aca9c2cbbe566e22e3741ca6dc03d4ba Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 23:31:13 -0400 Subject: [PATCH 03/12] =?UTF-8?q?[REF]=20d=C3=A9ploiement=20:=20la=20branc?= =?UTF-8?q?he,=20le=20profil=20et=20le=20type=20se=20choisissent=20par=20V?= =?UTF-8?q?M?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur Proxmox on déploie le plus souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM ERPLibre. C'est exactement le cas où un réglage par machine sert, et c'est le seul écran qui ne l'offrait pas : ses rangées n'avaient ni branche, ni profil, ni type. Les trois choix et leur gestionnaire — quatre-vingts lignes — rejoignent le socle. Les dupliquer aurait remis en place le mécanisme de dérive qu'on vient d'enlever. L'écran QEMU/KVM perd encore 130 lignes sans qu'un widget, un modèle ou une spec ne bouge : ancien et nouveau montés dans le même processus, mêmes rangées, mêmes valeurs après avoir changé une branche, un type et un profil. Le déploiement suit : il lisait la seule valeur commune alors que le plan portait déjà le choix par rangée. Une seule VM qui s'écarte suffit à rendre la carte nécessaire — « len(set) > 1 » ne l'aurait pas vu. Un défaut trouvé par un test, pas à l'usage : l'écho du montage se reconnaissait à sa commande, or quand la commande imposée par le système n'est pas dans la liste proposée, la liste retombe au rang 0 — et l'écho de ce rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait ERPLibre et Odoo 18. L'écho se reconnaît maintenant au RANG affiché. --- EN --- On Proxmox you usually deploy a MIXED fleet — a nested hypervisor next to ERPLibre VMs. That is exactly where a per-machine setting earns its keep, and it was the only screen without one: its rows had no branch, no profile, no type. The three choices and their handler — eighty lines — move into the shared foundation. Duplicating them would have restored the very drift mechanism we just removed. The QEMU/KVM screen loses another 130 lines with no widget, model or spec moving: old and new mounted in one process, same rows, same values after changing a branch, a type and a profile. The deployment follows: it read the single common value while the plan already carried the per-row choice. One VM that differs is enough to require the map — "len(set) > 1" would not have seen it. One defect found by a test, not by use: the mount echo was recognised by its command, yet when the command imposed by the guest OS is absent from the offered list, the list falls back to index 0 — and that index-0 echo erased the imposition. A nested Proxmox took ERPLibre and Odoo 18 back. The echo is now recognised by the DISPLAYED index. Assisted-by: Claude Opus 5 --- script/todo/deploy_form_extras.py | 176 ++++++++++++++++++++++++++- script/todo/proxmox_deploy_form.py | 31 ++++- script/todo/proxmox_menu.py | 24 +++- script/todo/qemu_deploy_form.py | 142 ++------------------- script/todo/todo_i18n.py | 4 + test/test_proxmox_form.py | 88 ++++++++++++++ test/test_todo_deploy_form_parity.py | 12 ++ 7 files changed, 336 insertions(+), 141 deletions(-) diff --git a/script/todo/deploy_form_extras.py b/script/todo/deploy_form_extras.py index c611a6b..dc75a4f 100644 --- a/script/todo/deploy_form_extras.py +++ b/script/todo/deploy_form_extras.py @@ -26,6 +26,10 @@ tous les fragments, et lire un widget absent ne doit pas casser l'écran. from script.todo.deploy_form_lib import FREE, t +# « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le +# rendrait indistinguable de la sentinelle « rien de sélectionné ». +SERVER = "__server__" + # Ce que l'écran lit du contexte. Une seule liste, parce que les deux # formulaires doivent en recevoir autant : c'est en fournissant un # sous-ensemble que l'écran Proxmox avait perdu la moitié des réglages. @@ -122,9 +126,18 @@ class ExtrasMixin: `render_extras()` après chaque recalcul et `extras_values()` au moment de bâtir sa spec.""" - def extras_init(self, ctx) -> None: - """À appeler dans `__init__`, avant tout `compose`.""" + def extras_init(self, ctx, branches=(), profiles=()) -> None: + """À appeler dans `__init__`, avant tout `compose`. + + `branches` et `profiles` viennent du formulaire et non du contexte : + chacun les ordonne à sa façon (branch_order) et l'écran les propose + dans cet ordre-là. Ce sont les mêmes listes que portent les rangées.""" self._extras = extras_tables(ctx) + self._extras["branches"] = list(branches) + self._extras["profiles"] = list(profiles) + self._extras["distro_profiles"] = dict( + ctx.get("distro_profiles") or {} + ) # ------------------------------------------------------------------ # # Les widgets @@ -310,6 +323,165 @@ class ExtrasMixin: def _extras_disk_gb(self, vm, tools) -> int: return extras_disk_gb(vm, tools, self._extras) + # ------------------------------------------------------------------ # + # Une rangée du plan : branche, profil, type — par VM + # ------------------------------------------------------------------ # + def _branch(self) -> str: + """Branche du formulaire : le défaut de chaque VM.""" + widget = self._widget("#f_branch") + valeur = widget.value if widget is not None else None + branches = self._extras["branches"] + return valeur if isinstance(valeur, str) else (branches or [""])[0] + + def _profile_cmd(self) -> str: + """Commande du profil choisi en haut : le défaut de chaque VM.""" + profiles = self._extras["profiles"] + widget = self._widget("#f_profile_install") + if not profiles or widget is None: + return "" + index = widget.value + return profiles[index if isinstance(index, int) else 0][1] + + def _row_default_cmd(self, i) -> str: + """Commande qu'une rangée prend d'elle-même : celle que son système + impose, sinon le choix commun d'en haut. + + C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM + Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est + aussi la valeur commune.""" + if i < len(self.rows): + impose = self._extras["distro_profiles"].get( + self.rows[i]["vm"]["distro"] + ) + if impose: + return impose[1] + return self._profile_cmd() + + def _row_profile_index(self, i) -> int: + """Rang du profil que la rangée doit AFFICHER.""" + cmd = "" + if i < len(self.rows): + cmd = self.rows[i]["vm"].get("install_cmd") or "" + cmd = cmd or self._row_default_cmd(i) + for k, (_lbl, c) in enumerate(self._extras["profiles"]): + if c == cmd: + return k + return 0 + + def _type_options(self): + return [(t("Server"), SERVER)] + [ + (label, key) for key, label in self._extras["desktops"] + ] + + def install_row_widgets(self, i, null=None): + """Les trois choix qu'une VM peut prendre à elle seule. + + Ici et non dans un formulaire : ce sont les mêmes trois des deux + côtés, et ils manquaient à l'écran Proxmox — où l'on déploie pourtant + le plus souvent un parc MIXTE, un hyperviseur imbriqué à côté de VM + ERPLibre.""" + from textual.widgets import Select, Static + + tab = self._extras + if null is None: + null = getattr(Select, "NULL", Select.BLANK) + vm = self.rows[i]["vm"] if i < len(self.rows) else {} + widgets = [ + Select( + [(b, b) for b in tab["branches"]], + classes="vmbranch", + # Repli sur la branche du FORMULAIRE, jamais sur branches[0] : + # les rangées sont remontées dès que le jeu de VM change (une + # entrée cochée, une copie ajoutée, un renommage), et elles + # retombaient alors toutes sur « develop » quel que soit le + # choix commun. + value=vm.get("branch") or self._branch(), + allow_blank=False, + id=f"v{i}_branch", + ), + ( + Select( + [(lbl, k) for k, (lbl, _c) in enumerate(tab["profiles"])], + value=self._row_profile_index(i), + allow_blank=False, + classes="vmprof", + id=f"v{i}_prof", + ) + if tab["profiles"] + else Static("", classes="vmprof") + ), + ] + if tab["desktops"]: + widgets.append( + Select( + self._type_options(), + value=vm.get("desktop") or SERVER, + allow_blank=False, + id=f"v{i}_type", + ) + ) + return widgets + + def extras_on_row_select(self, event, index, field, null=None) -> bool: + """Traite branche / profil / type d'UNE rangée. True quand c'est fait. + + Ce que ces trois partagent, et qui n'est pas évident : poser + « value= » au montage fait émettre un Changed que Textual délivre + APRÈS coup, et un verrou temporel ne l'attrape pas — mesuré, les + champs de chaque VM se retrouvaient surchargés dès l'affichage et le + profil x1..x4 devenait inopérant. On compare donc à ce que le modèle + dit DÉJÀ : une valeur identique n'est pas une saisie, c'est l'écho. + + Cas limite assumé : choisir explicitement la valeur que le profil + donne déjà n'enregistre pas de surcharge. La VM suivra donc le profil + s'il change — ce qui est aussi le plus attendu quand on n'a rien + changé de visible.""" + if field not in ("branch", "prof", "type"): + return False + vm_now = self.rows[index]["vm"] + if field == "prof": + # L'écho se reconnaît à ceci : la valeur est CELLE QU'ON VIENT + # D'AFFICHER. Comparer les commandes ne suffisait pas — quand la + # commande imposée par le système n'est pas dans la liste + # proposée, la liste retombe sur le rang 0, et l'écho de ce + # rang 0 effaçait l'imposition. Un Proxmox imbriqué reprenait + # ainsi ERPLibre et Odoo 18, le défaut qu'on venait de corriger. + if event.value == self._row_profile_index(index): + return True + label, cmd = self._extras["profiles"][event.value] + defaut = self._row_default_cmd(index) + if cmd == (vm_now.get("install_cmd") or defaut): + return True + meme = cmd == defaut + self._set_override(index, "install_cmd", "" if meme else cmd) + self._set_override(index, "install_label", "" if meme else label) + self._recompute() + return True + if field == "branch": + # « la branche du formulaire » n'est pas une surcharge : la VM + # doit suivre si on la change en haut. + if event.value == (vm_now.get("branch") or self._branch()): + return True + self._set_override( + index, + "branch", + "" if event.value == self._branch() else event.value, + ) + self._recompute() + return True + nouveau = "" if event.value == SERVER else event.value + if nouveau == (vm_now.get("desktop") or ""): + return True + self._set_override(index, "desktop", nouveau) + # « Serveur » est un choix légitime, pas un retrait : on le note + # explicitement pour qu'il tienne face au défaut. + if event.value == SERVER: + cle = self._row_key(index) + if cle is not None: + self.overrides.setdefault(cle, {})["desktop"] = "" + self._recompute() + return True + # ------------------------------------------------------------------ # # Redessiner # ------------------------------------------------------------------ # diff --git a/script/todo/proxmox_deploy_form.py b/script/todo/proxmox_deploy_form.py index 80245a4..6efe4b9 100644 --- a/script/todo/proxmox_deploy_form.py +++ b/script/todo/proxmox_deploy_form.py @@ -187,7 +187,14 @@ def run_proxmox_form(ctx, run_app: bool = True): + """ SelectionList { height: 10; border: solid $panel; } RadioSet { height: auto; layout: horizontal; } + /* Ces deux règles portent « .vmrow Select » EN PLUS de leur + classe : « .vmrow Select » (une classe + un type) l'emporte sur + « .vmbranch » (une classe) par spécificité CSS. Écrites simplement, + elles étaient silencieusement écrasées. La branche porte des noms + longs (« 1.6.0 », « develop », « feature/xyz ») : trop étroite, la + liste les tronque et on ne sait plus ce qu'on a choisi. */ .vmrow Select.vmbranch { width: 34; } + .vmrow Select.vmprof { width: 40; } #hostline { height: 1; color: $accent; padding: 0 1; } """ ) @@ -210,7 +217,7 @@ def run_proxmox_form(ctx, run_app: bool = True): self._syncing = False # La liste des ponts GRANDIT : l'écran sait en créer un. self._ponts = list(ponts) - self.extras_init(ctx) + self.extras_init(ctx, branches, profiles) # ---------------------------------------------------------------- # # L'écran @@ -594,6 +601,11 @@ def run_proxmox_form(ctx, run_app: bool = True): if item.get("instance") else Static("", classes="vmcopy") ), + # Branche, profil, type — par VM. On déploie ici le plus + # souvent un parc MIXTE : un hyperviseur Proxmox imbriqué + # à côté de VM ERPLibre, et l'écran n'offrait qu'un choix + # commun pour les deux. + *self.install_row_widgets(i, SELECT_NULL), classes="vmrow", ) cartes.append( @@ -739,6 +751,14 @@ def run_proxmox_form(ctx, run_app: bool = True): if ident in ("f_storage", "f_bridge"): self._refresh_after() return + if ident in ("f_branch", "f_profile_install"): + # Un réglage commun reprend la main sur les VM non figées : + # c'est le sens même du mot « commun ». + self._clear_overrides( + ("branch",) if ident == "f_branch" else ("install_cmd",) + ) + self._refresh_after(remonter=True) + return # Réglage commun : « libre… » révèle la saisie, une valeur # s'applique à toutes les VM non figées. champ = SELECT_TO_FIELD.get(ident) @@ -755,9 +775,16 @@ def run_proxmox_form(ctx, run_app: bool = True): # Réglage d'UNE rangée. if ident.startswith("v") and "_" in ident: rang, champ = ident[1:].split("_", 1) - if not rang.isdigit() or champ not in RES_FIELDS: + if not rang.isdigit(): return index = int(rang) + if index >= len(self.rows): + return + if self.extras_on_row_select(event, index, champ): + self._refresh_after() + return + if champ not in RES_FIELDS: + return if event.value is FREE: self._row_free(index, champ, True) self._set_override( diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 79be598..d45274e 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1427,6 +1427,21 @@ class ProxmoxMenuMixin: } finale = cartes if self._qemu_per_vm(cartes, commun) else commun branche = (install or {}).get("branch") or "" + # Même règle pour la branche et pour le type de VM : depuis que le + # plan les porte PAR RANGÉE, lire la seule valeur commune revenait à + # jeter le choix. Un parc mixte — un hyperviseur imbriqué à côté de VM + # ERPLibre — est justement ce qu'on déploie ici le plus souvent. + branches_vm = { + vm["name"]: (vm.get("branch") or branche) for vm in joignables + } + if self._qemu_per_vm(branches_vm, branche): + branche = branches_vm + bureau = spec.get("desktop") or "" + bureaux = { + vm["name"]: (vm.get("desktop") or bureau) for vm in joignables + } + if self._qemu_per_vm(bureaux, bureau): + bureau = bureaux if suivi: # Rien à installer ? La commande distante regarde alors la VM # ARRIVER (cloud-init, puis relevé système) : c'est ce que le @@ -1458,7 +1473,7 @@ class ProxmoxMenuMixin: # Les réglages du système invité, qui n'atteignaient pas la # commande distante : la VM naissait serveur nu, sans outils. prod=bool(spec.get("prod")), - desktop=spec.get("desktop") or "", + desktop=bureau, python_provider=spec.get("python_provider") or "", app_store=spec.get("app_store") or "deb", vm_tools=spec.get("vm_tools") or (), @@ -1477,16 +1492,17 @@ class ProxmoxMenuMixin: return resultat # Sans suivi mais avec quelque chose à installer : en série, sortie à # l'écran. C'est le pendant exact de la voie QEMU/KVM. - print(f"\n{t('Installing ERPLibre on each VM')} ({branche})…") + etiquette = branche if isinstance(branche, str) else t("per VM") + print(f"\n{t('Installing ERPLibre on each VM')} ({etiquette})…") for vm in joignables: self._qemu_install_erplibre_vm( vm["name"], cle_locale, - branche, + branches_vm.get(vm["name"], ""), alias.get(vm["name"], vm["name"]), vm.get("install_cmd") or commun, bool(spec.get("prod")), - desktop=spec.get("desktop") or "", + desktop=bureaux.get(vm["name"], ""), python_provider=spec.get("python_provider") or "", app_store=spec.get("app_store") or "deb", vm_tools=spec.get("vm_tools") or (), diff --git a/script/todo/qemu_deploy_form.py b/script/todo/qemu_deploy_form.py index f2f81d6..15fd899 100644 --- a/script/todo/qemu_deploy_form.py +++ b/script/todo/qemu_deploy_form.py @@ -70,7 +70,7 @@ from script.todo.deploy_form_lib import ( # noqa: F401 # Le socle commun aux deux formulaires (QEMU/KVM et Proxmox VE). Réexporté # tel quel : les appelants historiques importent encore ces noms ICI. -from script.todo.deploy_form_extras import ExtrasMixin +from script.todo.deploy_form_extras import SERVER, ExtrasMixin from script.todo.deploy_form_plan import ( # noqa: F401 PlanMixin, preview_screen, @@ -128,8 +128,6 @@ def run_deploy_form(ctx, run_app: bool = True): total_disk = ctx.get("total_disk") or 0 base_vcpus = ctx.get("base_vcpus") or 2 extra_disk = ctx.get("extra_disk_gb") or 0 - # [(clé, libellé)] — la liste vient de todo.py, source unique. - desktops = list(ctx.get("desktops") or []) # {clé de saveur: suffixe de nom}, fourni par todo.py qui décrit les # saveurs — on ne le redéfinit pas ici. desktop_suffixes = dict(ctx.get("desktop_suffixes") or {}) @@ -137,9 +135,6 @@ def run_deploy_form(ctx, run_app: bool = True): result = {"spec": None} AUTO = "__auto__" - # « Serveur » est un CHOIX, pas une absence de choix : lui donner « » le - # rendrait indistinguable de la sentinelle « rien de sélectionné ». - SERVER = "__server__" def entry_label(e): star = " *" if e.get("default") else "" @@ -194,7 +189,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._syncing = False # Jeu de VM actuellement monté dans le panneau droit. self._shown_ids = () - self.extras_init(ctx) + self.extras_init(ctx, branches, profiles) # Génération du jeu de rangées monté. Les identifiants de widgets # portent un RANG, et le rang se décale quand on coche ou décoche # une entrée : un événement émis par un widget déjà détruit @@ -518,48 +513,7 @@ def run_deploy_form(ctx, run_app: bool = True): self._render_plan() self.render_extras() - def _profile_cmd(self): - """Commande du profil choisi en haut : le défaut de chaque VM.""" - if not profiles: - return "" - index = self.query_one("#f_profile_install", Select).value - return profiles[index if isinstance(index, int) else 0][1] - - def _row_default_cmd(self, i): - """Commande qu'une rangée prend d'elle-même : celle que son - système impose, sinon le choix commun d'en haut. - - C'est le défaut CONTRE LEQUEL on compare une saisie : sur une VM - Proxmox, choisir Odoo 18 est une vraie surcharge même quand c'est - aussi la valeur commune.""" - if i < len(self.rows): - impose = distro_profiles.get(self.rows[i]["vm"]["distro"]) - if impose: - return impose[1] - return self._profile_cmd() - - def _row_profile_index(self, i): - """Rang du profil que la rangée doit AFFICHER.""" - cmd = "" - if i < len(self.rows): - cmd = self.rows[i]["vm"].get("install_cmd") or "" - cmd = cmd or self._row_default_cmd(i) - for k, (_lbl, c) in enumerate(profiles): - if c == cmd: - return k - return 0 - - def _branch(self): - """Branche du formulaire : le défaut de chaque VM.""" - value = self.query_one("#f_branch", Select).value - return value if isinstance(value, str) else branches[0] - # -- panneau droit : une rangée de widgets par VM ---------------- # - def _type_options(self): - return [(t("Server"), SERVER)] + [ - (label, key) for key, label in desktops - ] - def _mount_rows(self) -> None: """(Re)construit le panneau droit. @@ -610,40 +564,7 @@ def run_deploy_form(ctx, run_app: bool = True): if item.get("instance") else Static("", classes="vmcopy") ), - Select( - [(b, b) for b in branches], - classes="vmbranch", - # Repli sur la branche du FORMULAIRE, jamais sur - # branches[0] : les rangées sont remontées dès que le - # jeu de VM change (une entrée cochée, une copie - # ajoutée, un renommage), et elles retombaient alors - # toutes sur « develop » quel que soit le choix commun. - value=( - self.rows[i]["vm"].get("branch") - if i < len(self.rows) - else "" - ) - or self._branch(), - allow_blank=False, - id=f"v{i}_branch", - ), - ( - Select( - [(lbl, i) for i, (lbl, _c) in enumerate(profiles)], - value=self._row_profile_index(i), - allow_blank=False, - classes="vmprof", - id=f"v{i}_prof", - ) - if profiles - else Static("", classes="vmprof") - ), - Select( - self._type_options(), - value=vm.get("desktop") or SERVER, - allow_blank=False, - id=f"v{i}_type", - ), + *self.install_row_widgets(i, SELECT_NULL), classes="vmrow", ) widgets.append( @@ -854,63 +775,18 @@ def run_deploy_form(ctx, run_app: bool = True): # profil donne déjà n'enregistre pas de surcharge. La VM # suivra donc le profil s'il change — ce qui est aussi le plus # attendu quand on n'a rien changé de visible. - vm_now = self.rows[index]["vm"] - if field == "prof": - label, cmd = profiles[event.value] - if cmd == ( - vm_now.get("install_cmd") - or self._row_default_cmd(index) - ): - return - same = cmd == self._row_default_cmd(index) - self._set_override( - index, "install_cmd", "" if same else cmd - ) - self._set_override( - index, "install_label", "" if same else label - ) - self._recompute() + if self.extras_on_row_select(event, index, field): return - if field == "branch": - # « la branche du formulaire » n'est pas une surcharge : - # la VM doit suivre si on la change en haut. - current = vm_now.get("branch") or self._branch() - if event.value == current: - return - self._set_override( - index, - "branch", - "" if event.value == self._branch() else event.value, - ) - self._recompute() - return - if field == "type": - new_desk = "" if event.value == SERVER else event.value - if new_desk == (vm_now.get("desktop") or ""): - return - elif ( - event.value is not FREE and event.value is not SELECT_NULL - ): - if self._row_echo(index, field, event.value): - return - if field == "type": - self._set_override( - index, - "desktop", - "" if event.value == SERVER else event.value, - ) - # « Serveur » est un choix légitime, pas un retrait : on le - # note explicitement pour qu'il tienne face au défaut. - if event.value == SERVER: - key = self._row_key(index) - if key is not None: - self.overrides.setdefault(key, {})["desktop"] = "" - elif event.value is FREE: + # Ne restent ici que les RESSOURCES : elles n'ont pas de + # défaut à comparer, mais une saisie libre à révéler. + if event.value is FREE: self._row_free(index, field, True) self._set_override( index, field, self._read_row_free(index, field) ) elif event.value is not SELECT_NULL: + if self._row_echo(index, field, event.value): + return self._row_free(index, field, False) self._set_override(index, field, event.value) self._recompute() diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d47cfc9..603064a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4351,6 +4351,10 @@ TRANSLATIONS = { "fr": "Clonage d'ERPLibre sur chaque VM", "en": "Cloning ERPLibre on each VM", }, + "per VM": { + "fr": "par VM", + "en": "per VM", + }, "Installing ERPLibre on each VM": { "fr": "Installation d'ERPLibre sur chaque VM", "en": "Installing ERPLibre on each VM", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index f972c72..aeb0358 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -598,6 +598,94 @@ class TestDeuxVmDuMemeNom(unittest.TestCase): ) +class TestUnParcMixte(unittest.TestCase): + """Le plan porte branche, profil et type PAR RANGÉE — le déploiement + lisait encore la seule valeur commune. + + C'est le cas qu'on déploie le plus souvent sur un Proxmox : un + hyperviseur imbriqué à côté de VM ERPLibre. Une seule VM qui porte sa + propre valeur suffit à rendre la carte nécessaire — « len(set) > 1 » ne + l'aurait pas vu, et tout le parc serait retombé sur le commun.""" + + def _capture(self, vms): + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + vu = {} + todo = TODO.__new__(TODO) + todo._write_ssh_config_entry = lambda *a, **k: None + todo._ssh_private_key = lambda k: None + todo._ssh_config_block = lambda nom: {} + todo._qemu_list_domains = lambda: [] + todo._pve_guest_ip = lambda vmid, attente=120: "" + todo._pve_write_guide = lambda *a, **k: True + todo._pve_set_timezone = lambda *a, **k: True + todo._qemu_import_module = lambda: None + + def prise(noms, branche, alias, finale, **kw): + vu.update(branche=branche, finale=finale, kw=kw) + + todo._qemu_install_erplibre_monitored = prise + spec = { + "host": {"target": "pve1"}, + "vms": vms, + "user": "erplibre", + "add_ssh_config": True, + "install": { + "branch": "develop", + "cmd": "make install_odoo_18", + "label": "X", + }, + "monitor": True, + "desktop": "", + } + with contextlib.redirect_stdout(io.StringIO()): + todo._pve_after_create( + spec["host"], spec, [v["name"] for v in vms], "" + ) + return vu + + def _vm(self, nom, **extra): + base = { + "name": nom, + "vmid": 100, + "ipconfig": "ip=10.10.10.150/24,gw=10.10.10.1", + "install_cmd": "", + } + base.update(extra) + return base + + def test_a_single_vm_with_its_own_branch_forces_the_map(self): + vu = self._capture( + [ + self._vm("vm-a", branch="master"), + self._vm("vm-b", vmid=101), + ] + ) + self.assertEqual(vu["branche"], {"vm-a": "master", "vm-b": "develop"}) + + def test_a_uniform_fleet_keeps_the_common_value(self): + vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)]) + self.assertEqual(vu["branche"], "develop") + + def test_a_per_vm_desktop_reaches_the_install(self): + vu = self._capture( + [ + self._vm("vm-a", desktop="gnome"), + self._vm("vm-b", vmid=101), + ] + ) + self.assertEqual(vu["kw"]["desktop"], {"vm-a": "gnome", "vm-b": ""}) + + def test_a_uniform_fleet_keeps_the_common_desktop(self): + vu = self._capture([self._vm("vm-a"), self._vm("vm-b", vmid=101)]) + self.assertEqual(vu["kw"]["desktop"], "") + + class TestLEcranDUneVmProxmox(unittest.TestCase): """« Console de l'hyperviseur » conseillait des commandes virsh sur une machine qui n'a pas libvirt. diff --git a/test/test_todo_deploy_form_parity.py b/test/test_todo_deploy_form_parity.py index 3402929..17a3b19 100644 --- a/test/test_todo_deploy_form_parity.py +++ b/test/test_todo_deploy_form_parity.py @@ -108,6 +108,7 @@ def releve(fabrique, ctx, gestes=None): vu["ids"] = {w.id for w in app.query("#fields *") if w.id} vu["spec"] = app._form_values() vu["noms"] = [r["vm"]["name"] for r in app.rows] + vu["rangee"] = [w.id for w in app.query("#plan *") if w.id] vu["disques"] = [r["disk_gb"] for r in app.rows] asyncio.run(scenario()) @@ -163,6 +164,17 @@ class TestLesDeuxEcrans(unittest.TestCase): vide.extras_init({}) return vide + def test_both_screens_offer_the_same_per_vm_choices(self): + """Branche, profil, type : ce qu'une VM peut prendre à elle seule. + + L'écran Proxmox n'en offrait aucun, alors qu'on y déploie le plus + souvent un parc MIXTE — un hyperviseur imbriqué à côté de VM + ERPLibre — c'est-à-dire le cas où ils servent le plus.""" + for ident in ("v0_branch", "v0_prof", "v0_type"): + with self.subTest(reglage=ident): + self.assertIn(ident, self.qemu["rangee"], "QEMU/KVM") + self.assertIn(ident, self.pve["rangee"], "Proxmox") + def test_both_screens_bind_the_same_catalog_shortcuts(self): # « Versions principales » (F7) manquait à l'écran Proxmox, qui # affiche pourtant le même catalogue, drapeau « default » compris. From f31e2370a7cb042d0fd4c8b345e4b88abcc68404 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 00:30:54 -0400 Subject: [PATCH 04/12] =?UTF-8?q?[FIX]=20suivi=20:=20un=20relev=C3=A9=20Pr?= =?UTF-8?q?oxmox=20jet=C3=A9,=20et=20deux=20lignes=20qui=20montraient=20un?= =?UTF-8?q?e=20autre=20machine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une AUTRE machine. Deux fautes, dont une était le miroir d'un correctif précédent. Le code de sortie de la suite distante est celui de son DERNIER maillon, la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire pendant TOUTE l'installation, précisément quand on regarde — la boucle finit en échec et le relevé, parfait, était jeté. On avait corrigé l'erreur inverse, un code 0 pris pour une réponse ; exiger 0 était la même faute retournée. Seule une liste de ressources analysable prouve une réponse. Pendant ce temps, « virsh domstats » indexe par NOM, et un nom se partage : les deux VM qui avaient un homonyme LOCAL affichaient ses chiffres. Mesuré — 1,5 Gio de RAM sur 12 et 58 Gio de disque sur 65, quand la vraie tournait avec 3 Gio et 25. Les relevés locaux d'une VM qui vit ailleurs sont donc retirés AVANT d'ajouter ceux de l'hôte : un hôte muet laisse la colonne VIDE, ce qui est vrai. Une colonne vide se remarque ; une colonne juste et fausse, non. L'alias enfin. Prendre le nom court quand il se trouvait libre donnait un parc incohérent : sur ce même déploiement, deux VM ont reçu « hôte+vm » — leurs noms étaient pris par des domaines locaux — et la troisième son nom court. Une convention qui dépend de ce qui traîne dans le fichier n'est pas une convention. Le nom chaîné est systématique. --- EN --- Of three VMs on one Proxmox, only one had empty columns — and the other two showed ANOTHER machine's figures. Two defects, one the mirror of an earlier fix. A remote pipeline's exit code is its LAST link's, the Odoo probe. While Odoo is not listening — that is, during the WHOLE install, exactly when you are watching — the loop ends in failure and the reading, perfectly good, was thrown away. We had fixed the opposite error, a 0 taken for an answer; demanding 0 was the same mistake reversed. Only a parsable resource list proves an answer. Meanwhile "virsh domstats" indexes by NAME, and a name is shared: the two VMs with a LOCAL namesake displayed its figures. Measured — 1.5 GiB of RAM out of 12 and 58 GiB of disk out of 65, while the real one ran on 3 GiB and 25. Local readings for a VM that lives elsewhere are therefore dropped BEFORE the host's are added: a silent host leaves the column EMPTY, which is true. An empty column gets noticed; a plausible wrong one does not. The alias, finally. Taking the short name while it happened to be free gave an inconsistent fleet: in that same deployment two VMs got "host+vm" — their names were held by local domains — and the third its short name. A convention that depends on what happens to sit in the file is not a convention. The chained name is now systematic. Assisted-by: Claude Opus 5 --- script/todo/proxmox_menu.py | 42 ++++------ script/todo/qemu_install_monitor.py | 42 ++++++++-- script/todo/todo.py | 34 -------- test/test_proxmox_form.py | 122 ++++++++++------------------ test/test_qemu_monitor_pve.py | 74 +++++++++++++++++ 5 files changed, 168 insertions(+), 146 deletions(-) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index d45274e..76754d6 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1121,35 +1121,27 @@ class ProxmoxMenuMixin: fh.write("\n".join(entete) + "\n") return chemin - def _pve_alias_names(self, nom, chaine, locaux, rebond=""): - """UN seul nom pour l'entrée ~/.ssh/config, et lequel. + def _pve_alias_names(self, nom, chaine, locaux=(), rebond=""): + """UN seul nom pour l'entrée ~/.ssh/config : « hôte+vm ». - Deux noms sur la même ligne « Host » — le court et le chaîné - « hôte+vm » — étaient un doublon dès que le court était libre : ssh - n'a besoin que d'un nom, et le second n'ajoutait qu'une façon de plus - d'écrire la même adresse. Rapporté. + Deux noms sur la même ligne « Host » — le chaîné et le court — + étaient un doublon : ssh n'a besoin que d'un nom, et le second + n'ajoutait qu'une façon de plus d'écrire la même adresse. Rapporté. - Le court quand il est LIBRE, c'est celui qu'on tape ; le chaîné - sinon, car un nom déjà pris désigne une AUTRE machine — une VM locale - du même nom, ou la VM d'un autre hôte Proxmox. Vécu : « ssh » partait - vers la machine locale qui partageait le nom. + Reste à choisir lequel, et c'est le chaîné. Prendre le nom court + quand il se trouvait libre donnait un parc INCOHÉRENT : sur un même + déploiement, deux VM recevaient « hôte+vm » — leurs noms étaient pris + par des domaines locaux — et la troisième son nom court. Rapporté + aussi. Une convention qui dépend de ce qui traîne dans le fichier + n'est pas une convention. - « Pris » se juge sur le ProxyJump du bloc et non sur sa seule - présence : notre propre entrée, réécrite à chaque déploiement, se - serait autrement prise pour une rivale — et le nom aurait basculé - d'une fois sur l'autre. + Le chaîné est donc systématique. Il dit où la machine vit, il ne peut + rien voler à un domaine local, et deux VM du même nom sur deux hôtes + Proxmox différents se distinguent d'elles-mêmes. - Rend (noms, volé) — `volé` nomme ce qui a forcé le nom chaîné, pour - que l'appelant le dise plutôt que de laisser la surprise.""" - if nom in locaux: - return [chaine], t("a local VM") - bloc = self._ssh_config_block(nom) - notre = ( - not bloc - or chaine in bloc.get("names", ()) - or (rebond and bloc.get("proxyjump") == rebond) - ) - return ([nom], "") if notre else ([chaine], "~/.ssh/config") + Rend (noms, volé) — la seconde valeur reste pour l'appelant, qui + signale au passage un nom qu'une VM locale porte aussi.""" + return [chaine], (t("a local VM") if nom in locaux else "") def _pve_set_timezone(self, cible, spec): """Pose le fuseau DANS la VM, par ssh. diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index c2a9849..8e7e9ae 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -1500,6 +1500,25 @@ def read_pvestats(vms, now=None) -> dict: return _read_pvestats(vms, now)[0] +def drop_local_twins(stats, vms) -> dict: + """Retire des relevés LOCAUX ceux d'une VM qui vit ailleurs. + + « virsh domstats » indexe par NOM, et un nom se partage : une VM posée + sur un Proxmox distant héritait des chiffres du domaine local homonyme. + Vécu sur trois VM — « erplibre-ubuntu-2604 » affichait 1,5 Gio de RAM sur + 12 et 58 Gio de disque sur 65, tout cela appartenant à la machine locale + du même nom, pendant que la vraie tournait avec 3 Gio et 25. + + Retirés AVANT d'ajouter ceux de l'hôte : ainsi un hôte muet laisse la + colonne VIDE — ce qui est vrai — au lieu de la remplir avec la mauvaise + machine. Une colonne vide se remarque ; une colonne juste et fausse, non. + """ + for vm in vms or (): + if vm.get("pve"): + stats.pop(vm.get("name"), None) + return stats + + def _read_pvestats(vms, now=None): """({nom: relevé}, succès). Un appel par hôte, mis en cache PVE_STATS_INTERVAL secondes. @@ -1547,17 +1566,25 @@ def _read_pvestats(vms, now=None): == target ) ] - code, sortie = pve.run( + _code, sortie = pve.run( {"target": target, "sudo": sudo, "jump": info.get("jump", "")}, pve_stats_cmd(siennes), 40, ) - # « code == 0 » ne suffit PAS : la commande est une SUITE - # (pvesh ; echo ; du ; echo ; boucle), et son code est celui du DERNIER - # maillon. Un pvesh en panne rendait donc « l'hôte a répondu, la VM - # n'y est plus » — et trois tours plus tard, la poubelle. Ce qui prouve - # une réponse, c'est une LISTE de ressources analysable. - if code == 0 and _resources_parsable(sortie): + # Le code de sortie ne prouve RIEN, dans AUCUN sens. La commande + # est une SUITE (pvesh ; echo ; du ; echo ; boucle) et son code est + # celui du DERNIER maillon — la sonde Odoo. Un pvesh en panne rendait + # donc 0, « l'hôte a répondu, la VM n'y est plus », et trois tours + # plus tard la poubelle ; c'est ce qu'on avait corrigé. Mais + # l'exiger à 0 était l'erreur SYMÉTRIQUE : tant qu'Odoo n'écoute pas + # — c'est-à-dire pendant TOUTE l'installation, précisément quand on + # regarde — la boucle finit en échec et le relevé, parfait, était + # jeté. Mesuré sur trois VM : colonnes vides côté Proxmox, et les + # lignes qui avaient un homonyme LOCAL affichaient ses chiffres. + # + # Ce qui prouve une réponse, c'est une LISTE de ressources + # analysable. Rien d'autre, et surtout pas le code. + if _resources_parsable(sortie): ok = True releves = parse_pvestats(sortie) ouverts = parse_odoo_probe(sortie) @@ -2367,6 +2394,7 @@ def run_monitor(manifest_path: str, run_app: bool = True): # calcule sur les relevés successifs, donc il faut échantillonner # à chaque tour (2 s) et non au rythme lent des états. stats = parse_domstats(read_domstats()) + drop_local_twins(stats, vms) # Les VM d'un hôte Proxmox distant : virsh ne les voit pas, leurs # colonnes restaient vides. Même forme de relevé, donc la suite ne # change pas d'un iota. diff --git a/script/todo/todo.py b/script/todo/todo.py index fef5f1c..0249191 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1705,40 +1705,6 @@ class TODO( pass return names - @classmethod - def _ssh_config_block(cls, name): - """Le bloc « Host … » qui déclare `name`, ou {}. - - Rend ses noms ET ses directives : savoir qu'un nom est pris ne suffit - pas, il faut savoir PAR QUI. Un nom court déjà déclaré peut être notre - propre entrée qu'on réécrit — auquel cas il n'y a rien de volé — ou - celle d'une autre machine, et c'est le ProxyJump qui les distingue. - - {"names": [...], "proxyjump": "...", "hostname": "..."}.""" - path = os.path.expanduser("~/.ssh/config") - try: - with open(path, encoding="utf-8") as fh: - contenu = fh.read() - except OSError: - return {} - bloc = None - for line in contenu.splitlines(): - if re.match(r"^[ \t]*Host[ \t]+", line): - if bloc is not None: - return bloc - noms = line.split()[1:] - bloc = {"names": noms} if name in noms else None - continue - if bloc is None: - continue - # Une ligne non indentée et non vide clôt le bloc. - if line.strip() and not line[:1].isspace(): - return bloc - mots = line.split() - if len(mots) >= 2 and mots[0].lower() in ("proxyjump", "hostname"): - bloc[mots[0].lower()] = mots[1] - return bloc or {} - @staticmethod def _ssh_config_user(host): """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index aeb0358..6e5c986 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -15,7 +15,6 @@ aucun hôte Proxmox n'est joint. import asyncio import sys -import os import unittest sys.argv = ["todo.py"] @@ -735,92 +734,56 @@ class TestLEcranDUneVmProxmox(unittest.TestCase): class TestUnSeulNomDansSshConfig(unittest.TestCase): - """L'entrée portait DEUX noms sur sa ligne « Host » : le nom chaîné - « hôte+vm » et le nom court. + """L'entrée portait DEUX noms sur sa ligne « Host », puis le mauvais. - Rapporté : « Host erplibre-proxmox-9+erplibre-arch-latest - erplibre-arch-latest ». Le second est un doublon dès que le premier - suffit — ssh n'a besoin que d'un nom, et le doubler n'ajoute qu'une façon - de plus d'écrire la même adresse. + D'abord le doublon : « Host erplibre-proxmox-9+erplibre-arch-latest + erplibre-arch-latest ». ssh n'a besoin que d'un nom, et le second + n'ajoutait qu'une façon de plus d'écrire la même adresse. - Un seul, donc, et le bon : le court quand il est LIBRE, le chaîné quand - il désignerait une autre machine. « Pris » se juge sur le ProxyJump du - bloc, pas sur sa seule présence — sinon notre propre entrée, réécrite à - chaque déploiement, se prendrait pour une rivale et le nom basculerait - d'une fois sur l'autre.""" + Puis le choix. Prendre le nom COURT quand il se trouvait libre donnait un + parc incohérent : sur un même déploiement de trois VM, deux recevaient + « hôte+vm » — leurs noms étaient pris par des domaines locaux — et la + troisième son nom court. Une convention qui dépend de ce qui traîne dans + le fichier n'est pas une convention. Le chaîné est systématique.""" - def setUp(self): + def _choisit(self, nom, locaux=()): import sys - import tempfile sys.argv = ["todo.py"] from script.todo.todo import TODO - self.maison = tempfile.mkdtemp() - os.makedirs(os.path.join(self.maison, ".ssh")) - self._vrai_home = os.environ.get("HOME") - os.environ["HOME"] = self.maison - self.todo = TODO.__new__(TODO) + todo = TODO.__new__(TODO) + return todo._pve_alias_names(nom, f"pve9+{nom}", set(locaux), "pve9") - def tearDown(self): - import shutil - - if self._vrai_home is not None: - os.environ["HOME"] = self._vrai_home - shutil.rmtree(self.maison, ignore_errors=True) - - def _ecrit(self, noms, rebond=""): - self.todo._write_ssh_config_entry( - noms, "erplibre", "10.10.10.150", proxy_jump=rebond or None - ) - - def _lignes_host(self): - with open( - os.path.join(self.maison, ".ssh/config"), encoding="utf-8" - ) as fh: - return [ - ligne.rstrip() for ligne in fh if ligne.startswith("Host ") - ] - - def _choisit(self, nom, locaux=(), rebond="pve9"): - return self.todo._pve_alias_names( - nom, f"pve9+{nom}", set(locaux), rebond - ) - - def test_a_free_name_is_written_alone(self): + def test_one_name_and_it_is_the_chained_one(self): noms, vole = self._choisit("erplibre-arch-latest") - self.assertEqual(noms, ["erplibre-arch-latest"]) - self.assertFalse(vole) - self._ecrit(noms, "pve9") - self.assertEqual(self._lignes_host(), ["Host erplibre-arch-latest"]) - - def test_redeploying_the_same_vm_keeps_the_same_name(self): - # Le piège du correctif : notre propre bloc déclare déjà le nom. - self._ecrit(["erplibre-arch-latest"], "pve9") - noms, vole = self._choisit("erplibre-arch-latest") - self.assertEqual(noms, ["erplibre-arch-latest"], "le nom a basculé") + self.assertEqual(noms, ["pve9+erplibre-arch-latest"]) self.assertFalse(vole) - def test_a_local_vm_keeps_its_name(self): - # Vécu : « ssh » partait vers la machine locale du même nom. - noms, vole = self._choisit( + def test_a_fleet_gets_one_single_convention(self): + # Le défaut rapporté : trois VM du même déploiement, deux nommées + # d'une façon et la troisième d'une autre. + noms = [ + self._choisit(n, locaux=("erplibre-ubuntu-2604",))[0][0] + for n in ( + "erplibre-ubuntu-2604", + "erplibre-arch-latest", + "erplibre-proxmox-9", + ) + ] + self.assertTrue( + all(n.startswith("pve9+") for n in noms), + f"un parc, une convention : {noms}", + ) + + def test_a_local_namesake_is_still_named(self): + # Le nom chaîné ne lui vole rien, mais on le DIT : c'est ce qui + # explique pourquoi « ssh » va ailleurs. + _noms, vole = self._choisit( "erplibre-arch-latest", locaux=("erplibre-arch-latest",) ) - self.assertEqual(noms, ["pve9+erplibre-arch-latest"]) self.assertTrue(vole) - def test_another_proxmox_host_keeps_its_name(self): - self._ecrit(["erplibre-ubuntu-2604"], "pve7") - noms, vole = self._choisit("erplibre-ubuntu-2604") - self.assertEqual(noms, ["pve9+erplibre-ubuntu-2604"]) - self.assertEqual(vole, "~/.ssh/config") - self._ecrit(noms, "pve9") - # Les deux machines cohabitent, chacune sous son nom. - self.assertEqual( - self._lignes_host(), - ["Host erplibre-ubuntu-2604", "Host pve9+erplibre-ubuntu-2604"], - ) - def test_no_deploy_path_writes_two_names_anymore(self): import re from pathlib import Path as P @@ -1008,10 +971,9 @@ class TestLeSuivi(unittest.TestCase): nom ) todo._ssh_private_key = lambda k: None - # Hermétique : le choix du nom lit ~/.ssh/config et la liste des - # domaines locaux. Sans ces deux bouchons, le test dépendrait de - # la machine qui le lance. - todo._ssh_config_block = lambda nom: {} + # Hermétique : le choix du nom lit la liste des domaines + # locaux. Sans ce bouchon, le test dépendrait de la machine qui + # le lance. todo._qemu_list_domains = lambda: [] todo._pve_guest_ip = lambda vmid, attente=120: "" todo._qemu_install_erplibre_monitored = lambda *a, **k: None @@ -1036,16 +998,16 @@ class TestLeSuivi(unittest.TestCase): return ecrites cmd = {"branch": "develop", "cmd": "make x", "label": "X"} - # UN nom : le court, puisque rien ne le porte déjà. Le chaîné - # « hôte+vm » ne sort que lorsqu'il faut départager (voir + # UN nom, et le chaîné : « hôte+vm » dit où la machine vit et ne + # dépend pas de ce qui traîne dans ~/.ssh/config (voir # TestUnSeulNomDansSshConfig). - self.assertEqual(essai(False, cmd, False), [["vm-a"]]) + self.assertEqual(essai(False, cmd, False), [["pve1+vm-a"]]) # Décoché, suivi demandé : le suivi entre aussi par le rebond. - self.assertEqual(essai(False, None, True), [["vm-a"]]) + self.assertEqual(essai(False, None, True), [["pve1+vm-a"]]) # Décoché et rien à faire dans la VM : le choix est respecté. self.assertEqual(essai(False, None, False), []) # Coché : écrite, évidemment. - self.assertEqual(essai(True, None, False), [["vm-a"]]) + self.assertEqual(essai(True, None, False), [["pve1+vm-a"]]) def test_a_local_vm_of_the_same_name_keeps_its_alias(self): """Le piège qui a fait installer ERPLibre sur la MAUVAISE machine. diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index ef94522..d22f84e 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -230,6 +230,80 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) +class TestTroisVmSurUnProxmox(unittest.TestCase): + """Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait + ses colonnes vides — et les deux autres montraient les chiffres d'une + AUTRE machine. + + Deux fautes, dont une était le miroir d'un correctif précédent.""" + + def test_a_reading_stands_even_when_odoo_is_not_up_yet(self): + # Le code de sortie de la suite distante est celui de son DERNIER + # maillon : la sonde Odoo. Tant qu'Odoo n'écoute pas — c'est-à-dire + # pendant TOUTE l'installation, précisément quand on regarde — la + # boucle finit en échec et le relevé, parfait, était jeté. + # + # On avait corrigé l'erreur inverse (code 0 pris pour une réponse) ; + # exiger 0 était la même faute, retournée. + sortie = ( + '[{"vmid":101,"name":"vm-a","status":"running","maxmem":1024,' + '"mem":512,"maxdisk":2048,"diskwrite":10}]\n' + "---ERPLIBRE-DU---\n---ERPLIBRE-ODOO---\n" + ) + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}} + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(1, sortie) + ): + stats, ok = mon.read_pvestats_detail([vm], now=10.0) + self.assertTrue( + ok, "un code non nul ne réfute pas une réponse lisible" + ) + self.assertIn("vm-a", stats) + + def test_a_broken_pvesh_is_still_refuted(self): + # L'autre sens tient toujours : sans liste analysable, pas de réponse. + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vm = {"name": "vm-a", "pve": {"target": "h", "sudo": "", "vmid": 101}} + with mock.patch( + "script.proxmox.proxmox_deploy.run", + return_value=(0, "permission denied\n---ERPLIBRE-DU---\n"), + ): + _stats, ok = mon.read_pvestats_detail([vm], now=20.0) + self.assertFalse(ok) + + def test_a_remote_vm_never_borrows_a_local_namesake(self): + # « virsh domstats » indexe par NOM, et un nom se partage. Mesuré : + # « erplibre-ubuntu-2604 » sur Proxmox affichait 1,5 Gio sur 12 et + # 58 Gio de disque — ceux de la machine locale du même nom — quand la + # vraie tournait avec 3 Gio et 25. + locaux = { + "erplibre-ubuntu-2604": { + "ram_used": 1 << 30, + "ram_total": 12 << 30, + }, + "erplibre-arch-latest": {"ram_used": 5, "ram_total": 9}, + "vm-locale": {"ram_used": 7, "ram_total": 8}, + } + vms = [ + {"name": "erplibre-ubuntu-2604", "pve": {"vmid": 100}}, + {"name": "vm-locale"}, + ] + reste = mon.drop_local_twins(dict(locaux), vms) + self.assertNotIn("erplibre-ubuntu-2604", reste) + # Une VM locale garde les siens, et une VM étrangère au manifeste + # n'est pas touchée. + self.assertIn("vm-locale", reste) + self.assertIn("erplibre-arch-latest", reste) + + def test_a_silent_host_leaves_the_column_empty(self): + # Vide, c'est vrai. Une colonne vide se remarque ; une colonne juste + # et fausse, non — c'est ce qui a fait remonter le défaut. + stats = {"vm-a": {"ram_used": 1, "ram_total": 2}} + mon.drop_local_twins(stats, [{"name": "vm-a", "pve": {"vmid": 1}}]) + self.assertEqual(stats, {}) + + class TestEffacerDepuisUnSuiviRouvert(unittest.TestCase): """Le suivi se ROUVRE sur un manifeste passé — c'est fait pour. From db9472ef69ba22c81eb9b37b4f7e3e6f4738d199 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 00:43:10 -0400 Subject: [PATCH 05/12] =?UTF-8?q?[FIX]=20proxmox=20:=20l'ancienne=20entr?= =?UTF-8?q?=C3=A9e=20ssh=20s'en=20va=20avec=20la=20convention?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le nom chaîné devient systématique, mais les entrées écrites AVANT portent le nom court — et rien ne les retirerait : elles ne déclarent pas le nom qu'on écrit maintenant. Deux blocs mèneraient à la même machine, exactement ce qu'on venait d'enlever. Le ProxyJump tranche : un bloc qui rebondit par CET hôte est le nôtre, on le retire. Celui d'une VM locale homonyme n'en a pas, et on n'y touche jamais ; celui d'un autre hôte Proxmox non plus. Le drapeau Odoo gagne son test au passage. Il tombait pour la même raison que les colonnes vides — la sonde est le dernier maillon de la suite distante, et un parc où une seule VM n'a pas d'Odoo, un hyperviseur imbriqué par exemple, finit en échec. Vérifié sur les trois VM : l'hôte rend bien « ODOO » pour les deux qui écoutent, et le navigateur répondait 303 pendant que la colonne disait « — ». --- EN --- The chained name becomes systematic, but entries written BEFORE carry the short one — and nothing would retire them: they do not declare the name we now write. Two blocks would lead to the same machine, exactly what we had just removed. The ProxyJump decides: a block hopping through THIS host is ours, so it goes. A local namesake's has none, and is never touched; another Proxmox host's neither. The Odoo flag gains its test along the way. It failed for the same reason as the empty columns — the probe is the remote pipeline's last link, and a fleet where a single VM has no Odoo, a nested hypervisor for instance, ends in failure. Verified on all three VMs: the host does return "ODOO" for the two that listen, and the browser answered 303 while the column said "—". Assisted-by: Claude Opus 5 --- script/todo/proxmox_menu.py | 18 +++++ script/todo/todo.py | 127 ++++++++++++++++++++++++++++++++-- test/test_proxmox_form.py | 75 ++++++++++++++++++++ test/test_qemu_monitor_pve.py | 47 +++++++++++++ 4 files changed, 263 insertions(+), 4 deletions(-) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 76754d6..150955f 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1143,6 +1143,20 @@ class ProxmoxMenuMixin: signale au passage un nom qu'une VM locale porte aussi.""" return [chaine], (t("a local VM") if nom in locaux else "") + def _pve_alias_perime(self, nom, rebond): + """Le nom court à RETIRER, s'il désigne encore cette VM-ci. + + La convention a changé — le nom court d'abord, puis « hôte+vm » — et + rien ne retirerait l'ancien bloc : il ne porte pas le nom qu'on + écrit. Deux entrées mèneraient alors à la même machine, ce qu'on + venait justement d'enlever. + + Le ProxyJump tranche : un bloc qui rebondit par CET hôte est le nôtre. + Celui d'une VM locale homonyme n'en a pas, et on n'y touche donc + jamais.""" + bloc = self._ssh_config_block(nom) + return [nom] if bloc and bloc.get("proxyjump") == rebond else [] + def _pve_set_timezone(self, cible, spec): """Pose le fuseau DANS la VM, par ssh. @@ -1384,6 +1398,9 @@ class ProxmoxMenuMixin: ip, identity_file=self._ssh_private_key(cle_locale), proxy_jump=host["target"], + also_drop=self._pve_alias_perime( + vm["name"], host["target"] + ), ) alias[vm["name"]] = noms_alias[0] print(f" ✓ ~/.ssh/config : ssh {noms_alias[0]}") @@ -1713,6 +1730,7 @@ class ProxmoxMenuMixin: ip, identity_file=cle, proxy_jump=host["target"], + also_drop=self._pve_alias_perime(vm["name"], host["target"]), ) print( f" ✓ ssh {noms[0]} ({ip} {t('through')} {host['target']})" diff --git a/script/todo/todo.py b/script/todo/todo.py index 0249191..4ac7732 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1430,13 +1430,24 @@ class TODO( return "".join(out) def _write_ssh_config_entry( - self, host, user, ip, proxy_jump=None, identity_file=None + self, + host, + user, + ip, + proxy_jump=None, + identity_file=None, + also_drop=(), ): """Écrit/remplace un bloc « Host » dans ~/.ssh/config. `host` peut être une liste de noms : ils partagent alors un seul bloc. - Sert aux VM imbriquées, joignables par leur nom court ET par leur nom - chaîné « parent+enfant », qui montre où elles vivent. + + `also_drop` : noms dont le bloc doit DISPARAÎTRE sans être réécrit. + Sert quand une convention de nommage change : l'ancienne entrée ne + désigne pas le nom qu'on écrit, donc rien ne la retirerait, et deux + blocs finiraient par mener à la même machine — ce qu'on venait + justement d'enlever. L'appelant vérifie que l'ancien bloc est BIEN le + sien avant de le nommer ici. `proxy_jump` : alias du rebond pour une VM imbriquée, dont l'IP n'est joignable que depuis son hôte. OpenSSH enchaîne les ProxyJump tout @@ -1452,7 +1463,9 @@ class TODO( if os.path.exists(cfg): with open(cfg, encoding="utf-8") as fh: existing = fh.read() - existing = self._ssh_config_drop_hosts(existing, names).rstrip("\n") + existing = self._ssh_config_drop_hosts( + existing, names + [n for n in also_drop if n not in names] + ).rstrip("\n") block = ( f"Host {' '.join(names)}\n" f" HostName {ip}\n" @@ -1705,6 +1718,40 @@ class TODO( pass return names + @classmethod + def _ssh_config_block(cls, name): + """Le bloc « Host … » qui déclare `name`, ou {}. + + Rend ses noms ET ses directives : savoir qu'un nom est pris ne suffit + pas, il faut savoir PAR QUI. Le ProxyJump distingue notre propre + entrée — celle d'une VM derrière tel hôte — de celle d'une machine + qui se trouve porter le même nom. + + {"names": [...], "proxyjump": "...", "hostname": "..."}.""" + path = os.path.expanduser("~/.ssh/config") + try: + with open(path, encoding="utf-8") as fh: + contenu = fh.read() + except OSError: + return {} + bloc = None + for line in contenu.splitlines(): + if re.match(r"^[ \t]*Host[ \t]+", line): + if bloc is not None: + return bloc + noms = line.split()[1:] + bloc = {"names": noms} if name in noms else None + continue + if bloc is None: + continue + # Une ligne non indentée et non vide clôt le bloc. + if line.strip() and not line[:1].isspace(): + return bloc + mots = line.split() + if len(mots) >= 2 and mots[0].lower() in ("proxyjump", "hostname"): + bloc[mots[0].lower()] = mots[1] + return bloc or {} + @staticmethod def _ssh_config_user(host): """`User` déclaré pour cet hôte dans ~/.ssh/config, ou "". @@ -3556,8 +3603,80 @@ class TODO( extra = None if analyse.get("asks_expect"): extra = ["--expect", self._monitoring_expect(kind)] + if analyse.get("writes"): + self._monitoring_write_flow(analyse, target) + return monitoring.run_analysis(analyse, target, extra=extra) + def _monitoring_write_flow(self, analyse, database): + """La seule analyse qui écrit : montrer, puis demander. + + On lance TOUJOURS la marche à blanc d'abord, et l'on demande + ensuite. Une question posée avant de savoir ce qui sera touché + n'est pas un consentement : c'est un pari. Le rapport dit combien + de modèles et de colonnes, et lesquels sont traduits ou uniques. + + La confirmation redemande le NOM de la base. Une frappe sur « o » + se donne par réflexe ; recopier « chezlepro_neutralize_upgrade_18 » + oblige à regarder ce qu'on détruit. + """ + from script.analyse import monitoring + + choix = self._monitoring_anonymize_options() + if choix is None: + return + print() + if monitoring.run_analysis(analyse, database, extra=choix) == 2: + return + print() + print( + f"⚠️ {t('This DESTROYS the data of')} '{database}'" + f" — {t('there is no undo.')}" + ) + tape = input( + f"💬 {t('Type the database name to confirm (empty to cancel): ')}" + ).strip() + if tape != database: + print(f"↩️ {t('Cancelled: nothing was written.')}") + return + monitoring.run_analysis( + analyse, database, extra=choix + ["--apply", "--confirm", database] + ) + + def _monitoring_anonymize_options(self): + """Le mode et ses listes, ou None si l'on renonce.""" + print() + print(f"[1] {t('Hybrid: the default personal-data models, adjusted')}") + print(f"[2] {t('Whitelist: only the models I name')}") + print(f"[3] {t('Blacklist: every model except those I name')}") + print(f"[0] {t('Back')}") + answer = click.prompt(t("Command:")) + print() + mode = {"1": "hybrid", "2": "whitelist", "3": "blacklist"}.get(answer) + if not mode: + return None + extra = ["--mode", mode] + invite = ( + t("Models to ADD, comma separated (empty for none): ") + if mode != "blacklist" + else t("Models to EXCLUDE, comma separated: ") + ) + noms = input(f"💬 {invite}").strip() + if noms: + extra += ["--exclude" if mode == "blacklist" else "--models", noms] + elif mode == "whitelist": + print(f"❌ {t('A whitelist with no model would do nothing.')}") + return None + mots = input( + f"💬 {t('Python file declaring MOTS (empty for the built-in): ')}" + ).strip() + if mots: + if not os.path.isfile(os.path.expanduser(mots)): + print(f"❌ {t('No such file: ')}{mots}") + return None + extra += ["--words", os.path.expanduser(mots)] + return extra + def _monitoring_expect(self, kind): """Copie de développement, ou instance en service ? diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 6e5c986..9413265 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -795,6 +795,81 @@ class TestUnSeulNomDansSshConfig(unittest.TestCase): ) +class TestLAncienNomSEnVa(unittest.TestCase): + """La convention a changé : les entrées écrites AVANT portent le nom + court, et rien ne les retirerait — elles ne portent pas le nom qu'on + écrit maintenant. Deux blocs mèneraient à la même machine, ce qu'on + venait justement d'enlever.""" + + def setUp(self): + import os + import sys + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + self.maison = tempfile.mkdtemp() + os.makedirs(os.path.join(self.maison, ".ssh")) + self._vrai = os.environ.get("HOME") + os.environ["HOME"] = self.maison + self.todo = TODO.__new__(TODO) + + def tearDown(self): + import os + import shutil + + if self._vrai is not None: + os.environ["HOME"] = self._vrai + shutil.rmtree(self.maison, ignore_errors=True) + + def _hosts(self): + import os + + with open( + os.path.join(self.maison, ".ssh/config"), encoding="utf-8" + ) as fh: + return [ + ligne.rstrip() for ligne in fh if ligne.startswith("Host ") + ] + + def test_the_old_short_entry_is_retired(self): + # L'état d'avant : une entrée écrite sous l'ancienne convention. + self.todo._write_ssh_config_entry( + ["vm-a"], "erplibre", "10.10.10.151", proxy_jump="pve9" + ) + perime = self.todo._pve_alias_perime("vm-a", "pve9") + self.assertEqual(perime, ["vm-a"]) + self.todo._write_ssh_config_entry( + ["pve9+vm-a"], + "erplibre", + "10.10.10.151", + proxy_jump="pve9", + also_drop=perime, + ) + self.assertEqual(self._hosts(), ["Host pve9+vm-a"]) + + def test_a_local_vm_of_the_same_name_is_left_alone(self): + # Sans ProxyJump vers cet hôte, le bloc n'est pas le nôtre : on n'y + # touche pas, même s'il porte exactement ce nom. + self.todo._write_ssh_config_entry(["vm-a"], "erplibre", "192.168.1.9") + self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), []) + self.todo._write_ssh_config_entry( + ["pve9+vm-a"], + "erplibre", + "10.10.10.151", + proxy_jump="pve9", + also_drop=self.todo._pve_alias_perime("vm-a", "pve9"), + ) + self.assertEqual(self._hosts(), ["Host vm-a", "Host pve9+vm-a"]) + + def test_another_hosts_vm_is_left_alone(self): + self.todo._write_ssh_config_entry( + ["vm-a"], "erplibre", "10.0.0.9", proxy_jump="pve7" + ) + self.assertEqual(self.todo._pve_alias_perime("vm-a", "pve9"), []) + + class TestLeGuideDeConnexion(unittest.TestCase): """Une VM Proxmox n'avait AUCUN guide, quelle que soit sa distribution. diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index d22f84e..1010820 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -261,6 +261,53 @@ class TestTroisVmSurUnProxmox(unittest.TestCase): ) self.assertIn("vm-a", stats) + def test_the_odoo_flag_survives_a_partly_closed_fleet(self): + """Le cas rapporté, et il est le cas NORMAL. + + La sonde est le dernier maillon : elle boucle sur toutes les adresses + et son code est celui de la DERNIÈRE. Un parc où une seule VM n'a pas + d'Odoo — un hyperviseur imbriqué, par exemple — finit donc en échec, + et le relevé entier partait, drapeaux Odoo compris. Le navigateur, lui, + répondait 303.""" + sortie = ( + '[{"vmid":100,"name":"vm-a","status":"running","maxmem":1024,' + '"mem":512,"maxdisk":2048,"diskwrite":10},' + '{"vmid":102,"name":"pve-imbrique","status":"running",' + '"maxmem":1024,"mem":512,"maxdisk":2048,"diskwrite":10}]\n' + "---ERPLIBRE-DU---\n" + "---ERPLIBRE-ODOO---\nODOO 10.10.10.150\n" + ) + mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) + vms = [ + { + "name": "vm-a", + "pve": { + "target": "h", + "sudo": "", + "vmid": 100, + "addr": "10.10.10.150", + }, + }, + { + "name": "pve-imbrique", + "pve": { + "target": "h", + "sudo": "", + "vmid": 102, + "addr": "10.10.10.152", + }, + }, + ] + with mock.patch( + "script.proxmox.proxmox_deploy.run", return_value=(1, sortie) + ): + stats, ok = mon.read_pvestats_detail(vms, now=30.0) + self.assertTrue(ok) + self.assertTrue(stats["vm-a"]["odoo"], "la VM qui répond doit être 🟢") + self.assertFalse( + stats["pve-imbrique"]["odoo"], "un hyperviseur n'a pas d'Odoo" + ) + def test_a_broken_pvesh_is_still_refuted(self): # L'autre sens tient toujours : sans liste analysable, pas de réponse. mon._PVE_CACHE.update({"at": 0.0, "stats": {}, "ok": False}) From 67a59d05228a07b35819afa84205b9ab535d0982 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 00:43:50 -0400 Subject: [PATCH 06/12] [ADD] analyse: anonymiser une copie, sans IA et sans rien casser MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Des mots pris dans une liste, des nombres tirés entre 0 et 1000, écrits en SQL. Aucun modèle, aucun réseau — un test le vérifie sur les imports. Le difficile n'est pas de remplacer, c'est de savoir ce qu'on n'a PAS le droit de toucher. Mesuré sur une base 18 réelle : 505 champs `selection` sont stockés en varchar, 2693 many2one sont des entiers, 194 textes sont des jsonb par langue, 301 contraintes d'unicité attendent une collision. « Tous les champs string » n'existe pas ; on croise ir_model_fields, pg_attribute et pg_constraint, et aucune des trois ne suffit seule. Trois pièges ont été trouvés en LANÇANT l'outil, pas en le relisant : PostgreSQL refuse d'indexer un ARRAY[...] sans parenthèses, res_partner.credit_limit est un jsonb qu'Odoo appelle float, et crm_lead.probability porte un CHECK qui interdit 1000. Chaque fois l'écriture a échoué et la base est restée intacte : une seule transaction, tout ou rien. Preuve sur copie jetable : empreinte du schéma identique, 848 tables, 6495 contraintes, arch_db et xmlid intacts, lang et many2one inchangés — seules les colonnes visées ont changé. --- EN --- Words from a list, numbers drawn between 0 and 1000, written in SQL. No model, no network — a test checks that on the imports. The hard part is not replacing, it is knowing what must NOT be touched. Measured on a real 18 database: 505 `selection` fields are stored as varchar, 2693 many2one are integers, 194 texts are per-language jsonb, 301 unique constraints await a collision. "All string fields" does not exist; we cross ir_model_fields, pg_attribute and pg_constraint, and none of the three is enough alone. Three traps were found by RUNNING it, not by rereading it: PostgreSQL refuses to subscript a bare ARRAY[...], res_partner.credit_limit is a jsonb Odoo calls float, and crm_lead.probability has a CHECK forbidding 1000. Each time the write failed and the database stayed intact: one transaction, all or nothing. Proof on a throwaway copy: identical schema fingerprint, 848 tables, 6495 constraints, arch_db and xmlids intact, lang and many2one unchanged — only the targeted columns changed. Assisted-by: Claude Opus 5 --- script/analyse/anonymize.py | 550 +++++++++++++++++++++++++++++++ script/analyse/monitoring.py | 23 +- script/analyse/monitoring_tui.py | 4 +- script/todo/todo_i18n.py | 124 +++++++ test/test_anonymize.py | 368 +++++++++++++++++++++ test/test_monitoring.py | 2 +- 6 files changed, 1064 insertions(+), 7 deletions(-) create mode 100755 script/analyse/anonymize.py create mode 100644 test/test_anonymize.py diff --git a/script/analyse/anonymize.py b/script/analyse/anonymize.py new file mode 100755 index 0000000..63be592 --- /dev/null +++ b/script/analyse/anonymize.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Remplacer les données d'une copie par des données sans intérêt. + +Aucune IA, aucun appel réseau : des mots pris dans une liste et des +nombres tirés au hasard, écrits par des UPDATE SQL. Ce qui rend la chose +délicate n'est pas le remplacement, c'est de savoir CE QU'ON N'A PAS LE +DROIT DE TOUCHER. + +Quatre pièges, tous mesurés sur une base réelle +----------------------------------------------- +1. « Tous les champs string » n'existe pas. 505 champs `selection` sont + stockés en varchar : `res.partner.lang`, `sale.order.invoice_status`. + Y écrire un mot au hasard casse l'ORM, pas la confidentialité. On ne + se fie donc jamais au type PostgreSQL seul, mais au `ttype` que + `ir_model_fields` déclare. +2. 2693 champs `many2one` sont des ENTIERS. Les tirer au hasard + mélangerait toutes les relations de la base. Les nombres qu'on touche + sont ceux qu'Odoo appelle integer, float ou monetary — jamais une + relation. +3. 194 champs texte sont en `jsonb` depuis Odoo 17, un objet par langue. + Écrire une chaîne par-dessus détruit la colonne ; on reconstruit + l'objet, clé par clé. C'est le piège qui a déjà coûté un /contact + réparé en anglais et resté cassé en français. +4. 301 contraintes d'unicité. Deux lignes qui reçoivent le même mot font + échouer tout l'UPDATE. Sur une colonne unique, l'identifiant est + collé au mot. + +Ce qu'on ne touche jamais +------------------------- +Les modèles `ir.*` — vues, champs, xmlid : la base ne s'ouvrirait plus. +Les langues, devises et pays : ce ne sont pas des données personnelles, +et les casser casse les adresses et les montants. Cette liste est un +PLANCHER : aucune liste blanche ne la lève. + +`res.users.login` et le mot de passe restent en place par défaut. On +anonymise pour POUVOIR partager une copie utilisable ; personne ne +pourrait plus s'y connecter. `--include-logins` pour l'autre choix. + +Rien n'est écrit sans `--apply` ET `--confirm `. +""" + +from __future__ import annotations + +import os +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +from script.analyse import lib_analyse # noqa: E402 + +TYPES_TEXTE = ("char", "text", "html") +TYPES_NOMBRE = ("integer", "float", "monetary") + +# Le plancher : aucune liste blanche ne le lève. +PREFIXES_INTERDITS = ("ir.",) +MODELES_INTERDITS = frozenset( + { + "res.groups", + "res.lang", + "res.currency", + "res.currency.rate", + "res.country", + "res.country.state", + "res.country.group", + "res.config.settings", + "base.language.install", + "decimal.precision", + "uom.uom", + "uom.category", + } +) +CHAMPS_INTERDITS = frozenset( + { + "id", + "create_uid", + "write_uid", + "create_date", + "write_date", + "sequence", + "active", + "display_name", + "__last_update", + "arch_db", + "arch_fs", + "key", + "model", + "res_model", + "res_field", + "state", + "color", + "company_id", + } +) +CHAMPS_CONNEXION = frozenset({"login", "password"}) + +# Le point de départ du mode hybride : ce qui porte des données +# personnelles dans une base Odoo ordinaire. +MODELES_PAR_DEFAUT = ( + "res.partner", + "res.users", + "res.company", + "res.bank", + "res.partner.bank", + "crm.lead", + "hr.employee", + "mail.message", + "mail.tracking.value", + "account.move", + "sale.order", + "purchase.order", + "project.task", + "calendar.event", + "survey.user_input", +) + +MOTS_PAR_DEFAUT = ( + "alouette", + "bruyere", + "cascade", + "dolmen", + "erable", + "fougere", + "givre", + "hameau", + "iris", + "jonquille", + "lichen", + "marais", + "nenuphar", + "orme", + "pinson", + "roseau", + "sureau", + "tourbe", + "varech", + "zephyr", +) + +MODES = ("whitelist", "blacklist", "hybrid") + + +def modele_interdit(modele): + """Le plancher, en une question.""" + if any(modele.startswith(p) for p in PREFIXES_INTERDITS): + return True + return modele in MODELES_INTERDITS + + +def choisir_modeles(tous, mode, whitelist=(), blacklist=(), defauts=None): + """Quels modèles anonymiser, selon le mode. + + whitelist : ceux-là et rien d'autre. + blacklist : tous SAUF ceux-là. + hybrid : la liste par défaut, plus la blanche, moins la noire — + c'est le mode utile en pratique : on part de ce qui porte + des données personnelles et on ajuste aux marges. + + Le plancher s'applique aux trois : un modèle `ir.*` ne passe par + aucun chemin, même nommé explicitement. + """ + tous = set(tous) + blanche, noire = set(whitelist), set(blacklist) + if mode == "whitelist": + choisis = blanche & tous + elif mode == "blacklist": + choisis = tous - noire + elif mode == "hybrid": + base = set(MODELES_PAR_DEFAUT if defauts is None else defauts) + choisis = ((base | blanche) - noire) & tous + else: + raise ValueError(f"{t('Unknown mode:')} {mode}") + return sorted(m for m in choisis if not modele_interdit(m)) + + +def champ_retenu(champ, inclure_connexion=False): + """Ce champ-là se remplace-t-il ? + + `champ` : dict avec model, name, ttype, pg_type, unique. + """ + if champ["name"] in CHAMPS_INTERDITS: + return False + if champ["name"] in CHAMPS_CONNEXION and not inclure_connexion: + return False + if champ["name"].endswith("_id") or champ["name"].endswith("_ids"): + # Une relation qui aurait échappé au filtre de ttype. + return False + if champ.get("checked"): + # Une contrainte CHECK dit ce que la colonne a le droit de valoir. + # Mesuré : crm_lead.probability doit rester entre 0 et 100, et un + # tirage à 1000 fait échouer l'UPDATE — donc, transaction unique + # oblige, TOUTE l'anonymisation. Lire l'expression du CHECK pour + # tirer dedans serait deviner ; on s'abstient et on le dit. + return False + if champ["ttype"] in TYPES_NOMBRE and champ.get("pg_type") == "jsonb": + # Mesuré sur res_partner.credit_limit : un `float` d'Odoo peut + # vivre dans un jsonb par société. Y écrire un nombre nu ferait + # échouer l'UPDATE — et donc, transaction unique oblige, TOUTE + # l'anonymisation. On s'abstient plutôt que de deviner sa forme. + return False + return champ["ttype"] in TYPES_TEXTE + TYPES_NOMBRE + + +def mots_pour(nom_champ, mots): + """La liste de mots à utiliser pour ce champ. + + `mots` peut être une simple liste — la même partout — ou un + dictionnaire par nom de champ avec une entrée `*` en repli. Le + dictionnaire permet de garder des courriels qui ressemblent à des + courriels, ce qu'une liste unique ne sait pas faire. + """ + if isinstance(mots, dict): + choix = mots.get(nom_champ) or mots.get("*") or MOTS_PAR_DEFAUT + else: + choix = mots or MOTS_PAR_DEFAUT + return tuple(str(m) for m in choix) or MOTS_PAR_DEFAUT + + +def litteral(texte): + """Un littéral SQL. Le seul endroit où du texte entre dans la requête.""" + return "'" + str(texte).replace("'", "''") + "'" + + +def expression_texte(champ, mots): + """Le SQL qui remplace un champ texte, en préservant les NULL. + + Un NULL qui deviendrait un mot créerait de la donnée là où il n'y en + avait pas : la copie mentirait dans l'autre sens. + """ + nom = champ["name"] + liste = mots_pour(nom, mots) + # Les parenthèses ne sont pas décoratives : PostgreSQL refuse + # d'indexer un constructeur ARRAY[...] directement. + tableau = "(ARRAY[" + ",".join(litteral(m) for m in liste) + "])" + tirage = f"{tableau}[(id % {len(liste)}) + 1]" + if champ.get("unique"): + # Deux lignes qui reçoivent le même mot feraient échouer TOUT + # l'UPDATE sur une colonne unique. + tirage = f"{tirage} || '-' || id::text" + if champ.get("pg_type") == "jsonb": + # Un objet par langue depuis Odoo 17 : on le reconstruit clé à + # clé. Écrire une chaîne par-dessus détruirait la colonne. + return ( + f"CASE WHEN {nom} IS NULL THEN NULL ELSE" + f" (SELECT jsonb_object_agg(kv.key, {tirage})" + f" FROM jsonb_each_text({nom}) AS kv) END" + ) + return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" + + +def expression_nombre(champ): + """Le SQL qui remplace un nombre : au hasard, entre 0 et 1000.""" + nom = champ["name"] + if champ["ttype"] == "integer": + tirage = "floor(random() * 1001)::integer" + else: + tirage = "round((random() * 1000)::numeric, 2)" + return f"CASE WHEN {nom} IS NULL THEN NULL ELSE {tirage} END" + + +def sql_pour_table(table, champs, mots): + """Un seul UPDATE par table : toutes ses colonnes d'un coup.""" + morceaux = [] + for champ in champs: + if champ["ttype"] in TYPES_TEXTE: + morceaux.append( + f"{champ['name']} = {expression_texte(champ, mots)}" + ) + else: + morceaux.append(f"{champ['name']} = {expression_nombre(champ)}") + if not morceaux: + return None + return f"UPDATE {table} SET " + ", ".join(morceaux) + ";" + + +def table_de(modele): + """Le nom de table qu'Odoo donne à ce modèle.""" + return modele.replace(".", "_") + + +SEP = "\x1f" + +# On croise TROIS sources, et c'est la raison d'être de cette requête : +# `ir_model_fields` dit ce qu'Odoo croit (le ttype, seul capable de +# distinguer un `selection` d'un vrai texte), `pg_attribute` dit ce que +# PostgreSQL a vraiment (jsonb ou varchar), et `pg_constraint` dit ce qui +# doit rester unique. Aucune des trois ne suffit seule. +REQUETE_CHAMPS = """ +SELECT f.model || '\x1f' || f.name || '\x1f' || f.ttype || '\x1f' + || a.atttypid::regtype::text || '\x1f' + || CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint k + WHERE k.conrelid = c.oid + AND k.contype IN ('u', 'p') + AND a.attnum = ANY(k.conkey) + ) THEN '1' ELSE '0' END || '\x1f' + || CASE WHEN EXISTS ( + SELECT 1 FROM pg_constraint k + WHERE k.conrelid = c.oid + AND k.contype = 'c' + AND a.attnum = ANY(k.conkey) + ) THEN '1' ELSE '0' END + FROM ir_model_fields f + JOIN pg_class c ON c.relname = replace(f.model, '.', '_') + AND c.relkind = 'r' + JOIN pg_attribute a ON a.attrelid = c.oid + AND a.attname = f.name + AND a.attnum > 0 + AND NOT a.attisdropped + WHERE f.store + AND f.ttype IN ('char','text','html','integer','float','monetary') + ORDER BY f.model, f.name +""" + + +def inspect(database, config_path=None): + """Tous les champs remplaçables de la base, avec leurs trois vérités.""" + brut = lib_analyse.run_psql( + database, REQUETE_CHAMPS, config_path=config_path + ) + champs = [] + for ligne in brut.splitlines(): + parts = ligne.split(SEP) + if len(parts) != 6: + continue + champs.append( + { + "model": parts[0], + "name": parts[1], + "ttype": parts[2], + "pg_type": parts[3], + "unique": parts[4] == "1", + "checked": parts[5] == "1", + } + ) + return champs + + +def plan( + champs, + mode, + whitelist=(), + blacklist=(), + inclure_connexion=False, + mots=None, +): + """Ce qui sera écrit, table par table — avant d'écrire quoi que ce soit. + + Rendu séparément de l'exécution pour que le mode « à blanc » montre + EXACTEMENT ce que `--apply` ferait, et non une approximation. + """ + modeles = choisir_modeles( + {c["model"] for c in champs}, mode, whitelist, blacklist + ) + retenus = set(modeles) + par_modele = {} + for champ in champs: + if champ["model"] not in retenus: + continue + if not champ_retenu(champ, inclure_connexion): + continue + par_modele.setdefault(champ["model"], []).append(champ) + etapes = [] + for modele in modeles: + liste = par_modele.get(modele) + if not liste: + continue + sql = sql_pour_table(table_de(modele), liste, mots) + if sql: + etapes.append({"model": modele, "fields": liste, "sql": sql}) + return etapes + + +def render(etapes, applique=False, verbeux=False): + """Le rapport. Il dit ce qui est ÉCARTÉ autant que ce qui est pris.""" + if not etapes: + return f"✅ {t('Nothing to anonymise with these lists.')}" + total = sum(len(e["fields"]) for e in etapes) + tete = ( + f"🎭 {len(etapes)} {t('model(s)')}, {total} {t('column(s)')}" + f" — {t('written') if applique else t('dry run, nothing written')}" + ) + lignes = [tete, ""] + for etape in etapes: + textes = [f for f in etape["fields"] if f["ttype"] in TYPES_TEXTE] + nombres = [f for f in etape["fields"] if f["ttype"] in TYPES_NOMBRE] + traduits = [f for f in textes if f["pg_type"] == "jsonb"] + uniques = [f for f in etape["fields"] if f["unique"]] + detail = f"{len(textes)} {t('text')}, {len(nombres)} {t('numeric')}" + if traduits: + detail += f", {len(traduits)} {t('translated (jsonb)')}" + if uniques: + detail += f", {len(uniques)} {t('unique')}" + lignes.append(f" {etape['model']:<34} {detail}") + if verbeux: + for champ in etape["fields"]: + lignes.append( + f" {champ['name']:<30} {champ['ttype']}" + f" / {champ['pg_type']}" + ) + if not applique: + lignes.append("") + lignes.append(f" {t('Use --apply --confirm to write.')}") + return "\n".join(lignes) + + +def charger_mots(chemin): + """Lire un fichier Python qui déclare MOTS. + + Une liste — les mêmes mots partout — ou un dictionnaire par nom de + champ avec un repli `*`. Aucun réseau, aucun modèle : des mots. + """ + if not chemin: + return None + espace = {} + with open(chemin, "r", encoding="utf-8") as handle: + exec(compile(handle.read(), chemin, "exec"), espace) # noqa: S102 + mots = espace.get("MOTS") + if not mots: + raise ValueError(f"{t('This file declares no MOTS:')} {chemin}") + return mots + + +def ecrire(database, etapes, config_path=None, timeout=900): + """Écrire les UPDATE — TOUS, ou AUCUN. None si tout a réussi. + + Une seule transaction (`-1`) et `ON_ERROR_STOP=1`. Sans cela, une + collision d'unicité au dixième modèle laisserait une base à moitié + anonymisée — c'est-à-dire une base dont plus personne ne peut dire ce + qui est vrai, et que rien ne rattrape sinon une restauration. + + On reprend `pg_env` pour la connexion — hôte, port, mot de passe lus + dans config.conf — et l'on ne lève QUE la lecture seule. La + redéclarer ici, ce serait accepter qu'elle diverge un jour. + """ + import subprocess + + env = lib_analyse.pg_env(config_path, timeout=timeout) + env["PGOPTIONS"] = f"-c statement_timeout={timeout}s" + sql = "\n".join(etape["sql"] for etape in etapes) + done = subprocess.run( + [ + "psql", + "-X", + "-w", + "-1", + "-v", + "ON_ERROR_STOP=1", + "-d", + database, + "-tA", + "-c", + sql, + ], + capture_output=True, + text=True, + env=env, + timeout=timeout + 60, + ) + if done.returncode: + detail = (done.stderr or "").strip().splitlines() + return detail[0][:200] if detail else "psql" + return None + + +def main(argv=None): + import argparse + + parser = argparse.ArgumentParser( + description=t("Replace the data of a COPY with meaningless data."), + ) + parser.add_argument("-d", "--database", required=True) + parser.add_argument("-c", "--config", help="odoo config file") + parser.add_argument("--mode", choices=MODES, default="hybrid") + parser.add_argument( + "--models", default="", help=t("comma separated, adds to the mode") + ) + parser.add_argument( + "--exclude", default="", help=t("comma separated, removed from it") + ) + parser.add_argument("--words", help=t("python file declaring MOTS")) + parser.add_argument("--include-logins", action="store_true") + parser.add_argument("--apply", action="store_true") + parser.add_argument( + "--confirm", + default="", + help=t("repeat the database name; --apply refuses without it"), + ) + parser.add_argument("--verbose", action="store_true") + args = parser.parse_args(argv) + + # Le garde-fou, AVANT toute lecture : personne ne doit découvrir en + # route qu'il a lancé cela sur la mauvaise base. + if args.apply and args.confirm != args.database: + print( + f"❌ {t('Refusing to write: --confirm must repeat')}" + f" '{args.database}'.", + file=sys.stderr, + ) + return 2 + + try: + lib_analyse.require_odoo_database( + args.database, config_path=args.config + ) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + try: + mots = charger_mots(args.words) + champs = inspect(args.database, args.config) + except Exception as exc: # noqa: BLE001 + print(f"❌ {exc}", file=sys.stderr) + return 2 + + etapes = plan( + champs, + args.mode, + [m.strip() for m in args.models.split(",") if m.strip()], + [m.strip() for m in args.exclude.split(",") if m.strip()], + args.include_logins, + mots, + ) + if not args.apply: + print(render(etapes, applique=False, verbeux=args.verbose)) + return 1 if etapes else 0 + + erreur = ecrire(args.database, etapes, args.config) + if erreur: + print(f"❌ {t('Nothing was written:')} {erreur}", file=sys.stderr) + return 2 + print(render(etapes, applique=True, verbeux=args.verbose)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/analyse/monitoring.py b/script/analyse/monitoring.py index cba79aa..fc3cbc3 100644 --- a/script/analyse/monitoring.py +++ b/script/analyse/monitoring.py @@ -68,7 +68,7 @@ ANALYSES = ( " step-by-step databases.", "script": "script/analyse/check_migration_residue.py", "kinds": (KIND_DATABASE,), - "needs_sql": "It reads pg_catalog — indexes and real tables — which" + "why_not": "It reads pg_catalog — indexes and real tables — which" " no RPC session exposes.", }, { @@ -78,13 +78,28 @@ ANALYSES = ( " administrator — read for the use you intend.", "script": "script/analyse/check_instance_state.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Several checks read tables no RPC session exposes," + "why_not": "Several checks read tables no RPC session exposes," " and the lateness of a job is computed in SQL.", # Le même chiffre veut dire deux choses opposées selon qu'on # ausculte une copie ou une production : l'attente ne se devine # pas, elle se demande. "asks_expect": True, }, + { + "key": "anonymize", + "title": "Anonymise a copy", + "why": "Replace names, texts and numbers with meaningless ones so" + " the copy can be shared. No AI: words from a list, numbers drawn" + " at random, written by SQL.", + "script": "script/analyse/anonymize.py", + "kinds": (KIND_DATABASE,), + # La seule entrée qui ÉCRIT. Le refus d'une instance vivante n'a + # rien à voir avec le SQL : on ne détruit pas les données d'une + # production, même à la demande. + "why_not": "This one WRITES. It is offered only for a database" + " you restored here — never for an instance in service.", + "writes": True, + }, { "key": "cow_views", "title": "Customised views, website copies included", @@ -92,7 +107,7 @@ ANALYSES = ( " shadows a module view.", "script": "script/analyse/analyse_view_custom.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Comparing a copy with the module view it hides is a" + "why_not": "Comparing a copy with the module view it hides is a" " join on arch_db, done in SQL.", }, { @@ -102,7 +117,7 @@ ANALYSES = ( " them have no column behind them.", "script": "script/analyse/analyse_custom_field.py", "kinds": (KIND_DATABASE,), - "needs_sql": "Telling a declared field from a real column means" + "why_not": "Telling a declared field from a real column means" " reading pg_attribute.", }, ) diff --git a/script/analyse/monitoring_tui.py b/script/analyse/monitoring_tui.py index 2802d43..56d3946 100644 --- a/script/analyse/monitoring_tui.py +++ b/script/analyse/monitoring_tui.py @@ -63,7 +63,7 @@ def rows(kind): analyse["key"], t(analyse["title"]), utilisable, - "" if utilisable else t(analyse["needs_sql"]), + "" if utilisable else t(analyse["why_not"]), ) ) return lignes @@ -79,7 +79,7 @@ def detail(key, kind): morceaux.append(f"▶ {t('Enter to run it.')}") else: morceaux.append(f"✖ {t('Not available for this source.')}") - morceaux.append(f" {t(analyse['needs_sql'])}") + morceaux.append(f" {t(analyse['why_not'])}") morceaux.append("") morceaux.append(f" {analyse['script']}") return "\n".join(morceaux) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 603064a..dea52b2 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -9640,6 +9640,130 @@ TRANSLATIONS = { "fr": "Une instance en service", "en": "An instance in service", }, + "Anonymise a copy": { + "fr": "🎭 Anonymiser une copie", + "en": "🎭 Anonymise a copy", + }, + "Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.": { + "fr": "Remplacer noms, textes et nombres par des valeurs sans intérêt, pour pouvoir partager la copie. Sans IA : des mots pris dans une liste, des nombres tirés au hasard, écrits en SQL.", + "en": "Replace names, texts and numbers with meaningless ones so the copy can be shared. No AI: words from a list, numbers drawn at random, written by SQL.", + }, + "This one WRITES. It is offered only for a database you restored here — never for an instance in service.": { + "fr": "Celle-ci ÉCRIT. Elle n'est offerte que pour une base restaurée ici — jamais pour une instance en service.", + "en": "This one WRITES. It is offered only for a database you restored here — never for an instance in service.", + }, + "This DESTROYS the data of": { + "fr": "Ceci DÉTRUIT les données de", + "en": "This DESTROYS the data of", + }, + "there is no undo.": { + "fr": "il n'y a pas de retour en arrière.", + "en": "there is no undo.", + }, + "Type the database name to confirm (empty to cancel): ": { + "fr": "Retapez le nom de la base pour confirmer (vide pour annuler) : ", + "en": "Type the database name to confirm (empty to cancel): ", + }, + "Cancelled: nothing was written.": { + "fr": "Annulé : rien n'a été écrit.", + "en": "Cancelled: nothing was written.", + }, + "Hybrid: the default personal-data models, adjusted": { + "fr": "Hybride : les modèles à données personnelles par défaut, ajustés", + "en": "Hybrid: the default personal-data models, adjusted", + }, + "Whitelist: only the models I name": { + "fr": "Liste blanche : seulement les modèles que je nomme", + "en": "Whitelist: only the models I name", + }, + "Blacklist: every model except those I name": { + "fr": "Liste noire : tous les modèles sauf ceux que je nomme", + "en": "Blacklist: every model except those I name", + }, + "Models to ADD, comma separated (empty for none): ": { + "fr": "Modèles à AJOUTER, séparés par des virgules (vide pour aucun) : ", + "en": "Models to ADD, comma separated (empty for none): ", + }, + "Models to EXCLUDE, comma separated: ": { + "fr": "Modèles à EXCLURE, séparés par des virgules : ", + "en": "Models to EXCLUDE, comma separated: ", + }, + "A whitelist with no model would do nothing.": { + "fr": "Une liste blanche sans modèle ne ferait rien.", + "en": "A whitelist with no model would do nothing.", + }, + "Python file declaring MOTS (empty for the built-in): ": { + "fr": "Fichier Python déclarant MOTS (vide pour la liste intégrée) : ", + "en": "Python file declaring MOTS (empty for the built-in): ", + }, + "Nothing to anonymise with these lists.": { + "fr": "Rien à anonymiser avec ces listes.", + "en": "Nothing to anonymise with these lists.", + }, + "column(s)": { + "fr": "colonne(s)", + "en": "column(s)", + }, + "dry run, nothing written": { + "fr": "marche à blanc, rien n'est écrit", + "en": "dry run, nothing written", + }, + "text": { + "fr": "texte", + "en": "text", + }, + "numeric": { + "fr": "nombre", + "en": "numeric", + }, + "translated (jsonb)": { + "fr": "traduit (jsonb)", + "en": "translated (jsonb)", + }, + "unique": { + "fr": "unique", + "en": "unique", + }, + "Use --apply --confirm to write.": { + "fr": "Utiliser --apply --confirm pour écrire.", + "en": "Use --apply --confirm to write.", + }, + "Replace the data of a COPY with meaningless data.": { + "fr": "Remplacer les données d'une COPIE par des données sans intérêt.", + "en": "Replace the data of a COPY with meaningless data.", + }, + "comma separated, adds to the mode": { + "fr": "séparés par des virgules, s'ajoutent au mode", + "en": "comma separated, adds to the mode", + }, + "comma separated, removed from it": { + "fr": "séparés par des virgules, retirés du mode", + "en": "comma separated, removed from it", + }, + "python file declaring MOTS": { + "fr": "fichier python déclarant MOTS", + "en": "python file declaring MOTS", + }, + "repeat the database name; --apply refuses without it": { + "fr": "répéter le nom de la base ; --apply refuse sans lui", + "en": "repeat the database name; --apply refuses without it", + }, + "Refusing to write: --confirm must repeat": { + "fr": "Écriture refusée : --confirm doit répéter", + "en": "Refusing to write: --confirm must repeat", + }, + "Nothing was written:": { + "fr": "Rien n'a été écrit :", + "en": "Nothing was written:", + }, + "This file declares no MOTS:": { + "fr": "Ce fichier ne déclare aucun MOTS :", + "en": "This file declares no MOTS:", + }, + "Unknown mode:": { + "fr": "Mode inconnu :", + "en": "Unknown mode:", + }, } diff --git a/test/test_anonymize.py b/test/test_anonymize.py new file mode 100644 index 0000000..c7eb172 --- /dev/null +++ b/test/test_anonymize.py @@ -0,0 +1,368 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Anonymiser : ce qui compte, c'est ce qu'on REFUSE de toucher. + +Remplacer des mots est facile. Ce qui casse une base, c'est de croire que +« tous les champs string » veut dire quelque chose. Mesuré sur une base +réelle en 18 : 505 champs `selection` sont stockés en varchar +(`res.partner.lang`, `sale.order.invoice_status`), 2693 many2one sont des +entiers, 194 textes sont des `jsonb` par langue, et 301 contraintes +d'unicité attendent une collision. + +Trois de ces pièges ont été trouvés en LANÇANT l'outil sur une copie +jetable, pas en le relisant : PostgreSQL refuse d'indexer un +`ARRAY[...]` sans parenthèses, `res_partner.credit_limit` est un jsonb +alors qu'Odoo l'appelle `float`, et `crm_lead.probability` porte un CHECK +qui interdit 1000. Chacun aurait fait échouer l'écriture — et l'écriture +étant transactionnelle, chaque fois la base est restée intacte. Ce +fichier fige ces trois-là pour qu'ils ne reviennent pas. + +Le plancher est l'objet du premier bloc : aucun mode, aucune liste +blanche, aucune insistance ne doit permettre d'écrire dans `ir.*`. +""" + +import ast +import unittest +from pathlib import Path + +from script.analyse import anonymize as anon + +MOTEUR = ( + Path(__file__).resolve().parent.parent + / "script" + / "analyse" + / "anonymize.py" +) + + +def champ( + nom, + ttype="char", + pg="character varying", + unique=False, + checked=False, + modele="res.partner", +): + return { + "model": modele, + "name": nom, + "ttype": ttype, + "pg_type": pg, + "unique": unique, + "checked": checked, + } + + +class TestTheFloorNobodyCanLift(unittest.TestCase): + """`ir.*` reste intouchable, quel que soit le chemin.""" + + def test_ir_models_are_refused_even_when_whitelisted(self): + tous = {"ir.ui.view", "ir.model.fields", "res.partner"} + for mode in anon.MODES: + choisis = anon.choisir_modeles( + tous, mode, whitelist=list(tous), blacklist=[] + ) + self.assertNotIn("ir.ui.view", choisis, mode) + self.assertNotIn("ir.model.fields", choisis, mode) + + def test_languages_and_currencies_are_refused_too(self): + """Ce ne sont pas des données personnelles, et les casser casse + les adresses et les montants.""" + tous = {"res.lang", "res.currency", "res.country", "res.partner"} + choisis = anon.choisir_modeles(tous, "whitelist", whitelist=list(tous)) + self.assertEqual(choisis, ["res.partner"]) + + def test_a_blacklist_of_nothing_still_respects_the_floor(self): + tous = {"ir.ui.view", "res.partner", "res.lang"} + self.assertEqual( + anon.choisir_modeles(tous, "blacklist", blacklist=[]), + ["res.partner"], + ) + + +class TestWhatIsNeverReplaced(unittest.TestCase): + """Le cœur : distinguer un vrai texte d'un varchar qui n'en est pas un.""" + + def test_a_selection_stored_as_varchar_is_left_alone(self): + """505 dans la base mesurée. Y écrire un mot casse l'ORM.""" + self.assertFalse(anon.champ_retenu(champ("lang", "selection"))) + self.assertFalse( + anon.champ_retenu(champ("invoice_status", "selection")) + ) + + def test_a_many2one_is_left_alone(self): + """2693 entiers qui sont des relations.""" + self.assertFalse( + anon.champ_retenu(champ("parent_id", "many2one", "integer")) + ) + + def test_a_many2one_is_refused_by_its_type_not_only_its_name(self): + """Sur la base mesurée, tous les many2one finissent par `_id` — mais + un module maison peut en nommer un `owner`, et la convention ne + peut pas être la seule barrière. C'est le TYPE qui décide.""" + self.assertFalse( + anon.champ_retenu(champ("owner", "many2one", "integer")) + ) + self.assertFalse( + anon.champ_retenu(champ("responsable", "many2one", "integer")) + ) + + def test_anything_named_like_a_relation_is_left_alone(self): + for nom in ("company_id", "tag_ids", "partner_id"): + self.assertFalse(anon.champ_retenu(champ(nom, "integer")), nom) + + def test_a_number_living_in_a_jsonb_is_left_alone(self): + """Mesuré : res_partner.credit_limit est un float DANS un jsonb.""" + self.assertFalse( + anon.champ_retenu(champ("credit_limit", "float", "jsonb")) + ) + + def test_a_column_under_a_check_constraint_is_left_alone(self): + """Mesuré : crm_lead.probability doit rester entre 0 et 100.""" + self.assertFalse( + anon.champ_retenu( + champ("probability", "float", "numeric", checked=True) + ) + ) + + def test_technical_columns_are_left_alone(self): + for nom in ( + "id", + "create_uid", + "write_date", + "state", + "sequence", + "arch_db", + "active", + ): + self.assertFalse(anon.champ_retenu(champ(nom, "char")), nom) + + def test_logins_stay_unless_asked(self): + """Sinon personne ne peut plus ouvrir la copie qu'on anonymise.""" + self.assertFalse(anon.champ_retenu(champ("login"))) + self.assertTrue( + anon.champ_retenu(champ("login"), inclure_connexion=True) + ) + + def test_a_real_text_is_taken(self): + for ttype in ("char", "text", "html"): + self.assertTrue(anon.champ_retenu(champ("name", ttype)), ttype) + + def test_a_real_number_is_taken(self): + for ttype in ("integer", "float", "monetary"): + self.assertTrue( + anon.champ_retenu(champ("amount", ttype, "numeric")), ttype + ) + + +class TestTheSqlItWrites(unittest.TestCase): + def test_a_null_stays_null(self): + """Un NULL devenu mot créerait de la donnée là où il n'y en avait + pas : la copie mentirait dans l'autre sens.""" + sql = anon.expression_texte(champ("name"), ["a"]) + self.assertIn("IS NULL THEN NULL", sql) + self.assertIn( + "IS NULL THEN NULL", + anon.expression_nombre(champ("x", "integer", "integer")), + ) + + def test_the_array_is_parenthesised(self): + """PostgreSQL refuse d'indexer un ARRAY[...] nu — mesuré.""" + sql = anon.expression_texte(champ("name"), ["a", "b"]) + self.assertIn("(ARRAY[", sql) + self.assertNotIn("] ARRAY[", sql) + self.assertRegex(sql, r"\(ARRAY\[[^\]]*\]\)\[") + + def test_a_unique_column_gets_the_id_appended(self): + """Deux lignes au même mot feraient échouer TOUT l'UPDATE.""" + sql = anon.expression_texte(champ("ref", unique=True), ["a"]) + self.assertIn("id::text", sql) + self.assertNotIn( + "id::text", anon.expression_texte(champ("ref"), ["a"]) + ) + + def test_a_translated_column_is_rebuilt_key_by_key(self): + """Écrire une chaîne dans un jsonb détruirait la colonne.""" + sql = anon.expression_texte(champ("comment", "html", "jsonb"), ["a"]) + self.assertIn("jsonb_object_agg", sql) + self.assertIn("jsonb_each_text", sql) + + def test_a_plain_text_column_is_not_treated_as_json(self): + sql = anon.expression_texte(champ("comment", "text", "text"), ["a"]) + self.assertNotIn("jsonb", sql) + + def test_numbers_land_between_zero_and_a_thousand(self): + entier = anon.expression_nombre(champ("n", "integer", "integer")) + self.assertIn("1001", entier) + decimal = anon.expression_nombre(champ("x", "float", "numeric")) + self.assertIn("1000", decimal) + + def test_a_word_with_a_quote_cannot_break_out(self): + """Une liste de mots vient d'un fichier : elle n'est pas de confiance.""" + sql = anon.expression_texte(champ("name"), ["l'ete"]) + self.assertIn("'l''ete'", sql) + + def test_one_update_per_table_not_per_column(self): + sql = anon.sql_pour_table( + "res_partner", [champ("name"), champ("ref")], None + ) + self.assertEqual(sql.count("UPDATE"), 1) + self.assertTrue(sql.endswith(";")) + + def test_no_column_means_no_statement(self): + self.assertIsNone(anon.sql_pour_table("res_partner", [], None)) + + +class TestTheModes(unittest.TestCase): + TOUS = {"res.partner", "crm.lead", "sale.order", "ir.ui.view"} + + def test_whitelist_takes_only_what_it_names(self): + self.assertEqual( + anon.choisir_modeles(self.TOUS, "whitelist", ["crm.lead"]), + ["crm.lead"], + ) + + def test_blacklist_takes_everything_else(self): + choisis = anon.choisir_modeles( + self.TOUS, "blacklist", blacklist=["crm.lead"] + ) + self.assertNotIn("crm.lead", choisis) + self.assertIn("res.partner", choisis) + + def test_hybrid_starts_from_the_defaults_and_adjusts(self): + choisis = anon.choisir_modeles( + self.TOUS, + "hybrid", + whitelist=["sale.order"], + blacklist=["res.partner"], + ) + self.assertIn("sale.order", choisis) + self.assertIn("crm.lead", choisis) # dans les défauts + self.assertNotIn("res.partner", choisis) # retiré + + def test_an_unknown_mode_raises_rather_than_guessing(self): + with self.assertRaises(ValueError): + anon.choisir_modeles(self.TOUS, "peut-etre") + + +class TestTheWordList(unittest.TestCase): + def test_a_flat_list_is_used_everywhere(self): + self.assertEqual(anon.mots_pour("name", ["a", "b"]), ("a", "b")) + + def test_a_dictionary_can_answer_per_field(self): + mots = {"email": ["a@b.c"], "*": ["mot"]} + self.assertEqual(anon.mots_pour("email", mots), ("a@b.c",)) + self.assertEqual(anon.mots_pour("name", mots), ("mot",)) + + def test_an_empty_list_falls_back_to_the_built_in(self): + self.assertEqual(anon.mots_pour("name", []), anon.MOTS_PAR_DEFAUT) + + +class TestThereIsNoModelInTheLoop(unittest.TestCase): + """« sans passer par un GPT » : vérifié sur le code, pas sur parole.""" + + def test_the_engine_imports_nothing_that_could_call_out(self): + arbre = ast.parse(MOTEUR.read_text(encoding="utf-8")) + interdits = { + "requests", + "urllib", + "urllib3", + "http", + "httpx", + "socket", + "openai", + "anthropic", + "xmlrpc", + "json", + } + for noeud in ast.walk(arbre): + noms = [] + if isinstance(noeud, ast.Import): + noms = [a.name.split(".")[0] for a in noeud.names] + elif isinstance(noeud, ast.ImportFrom) and noeud.module: + noms = [noeud.module.split(".")[0]] + for nom in noms: + self.assertNotIn(nom, interdits, nom) + + def test_the_write_is_one_transaction(self): + """Une collision au dixième modèle laisserait une base à moitié + anonymisée, que rien ne rattrape sinon une restauration.""" + arbre = ast.parse(MOTEUR.read_text(encoding="utf-8")) + fonction = [ + n + for n in ast.walk(arbre) + if isinstance(n, ast.FunctionDef) and n.name == "ecrire" + ] + self.assertEqual(len(fonction), 1) + # Le corps SANS la docstring : le texte la mentionne, l'argument + # doit être réellement passé. + corps = [ + n + for n in fonction[0].body + if not ( + isinstance(n, ast.Expr) + and isinstance(n.value, ast.Constant) + and isinstance(n.value.value, str) + ) + ] + litteraux = { + n.value + for bloc in corps + for n in ast.walk(bloc) + if isinstance(n, ast.Constant) and isinstance(n.value, str) + } + self.assertIn("-1", litteraux) + self.assertIn("ON_ERROR_STOP=1", litteraux) + + +class TestTheRefusalToWrite(unittest.TestCase): + """Le refus doit précéder la connexion. + + Sinon ces deux tests passent au vert parce que la base n'existe pas, + et ne prouvent rien du garde. On lit donc le message rendu, et l'on + vérifie qu'aucun psql n'a été appelé. + """ + + def _refus(self, argv): + import contextlib + import io as flux + + appels = [] + vrai = anon.lib_analyse.require_odoo_database + + def espion(*a, **k): + appels.append(a) + return vrai(*a, **k) + + anon.lib_analyse.require_odoo_database = espion + sortie = flux.StringIO() + try: + with contextlib.redirect_stderr(sortie): + code = anon.main(argv) + finally: + anon.lib_analyse.require_odoo_database = vrai + return code, sortie.getvalue(), appels + + def test_apply_without_a_matching_confirm_is_refused(self): + code, message, appels = self._refus( + ["-d", "une_base", "--apply", "--confirm", "une_autre"] + ) + self.assertEqual(code, 2) + self.assertIn( + anon.t("Refusing to write: --confirm must repeat"), message + ) + self.assertEqual(appels, [], "la base a été contactée pour refuser") + + def test_apply_with_no_confirm_at_all_is_refused(self): + code, message, appels = self._refus(["-d", "une_base", "--apply"]) + self.assertEqual(code, 2) + self.assertIn( + anon.t("Refusing to write: --confirm must repeat"), message + ) + self.assertEqual(appels, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_monitoring.py b/test/test_monitoring.py index f40ef8c..eb6e9a7 100644 --- a/test/test_monitoring.py +++ b/test/test_monitoring.py @@ -63,7 +63,7 @@ class TestWhatASourceAdmits(unittest.TestCase): def test_every_analysis_says_why_it_cannot_do_live(self): for analyse in monitoring.ANALYSES: - self.assertTrue(analyse["needs_sql"].strip(), analyse["key"]) + self.assertTrue(analyse["why_not"].strip(), analyse["key"]) def test_available_and_unavailable_cover_every_analysis(self): for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE): From c96f679858bf0784f2c16ec592647fc2dc8414db Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 00:58:14 -0400 Subject: [PATCH 07/12] [FIX] analyse: chercher la liste de prix, pas son identifiant externe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deuxième constat faux du même outil, et la même cause : un indicateur jamais confronté à la vraie condition. Le contrôle cherchait l'xmlid `product.list0`. Or la réparation laisse Odoo créer « Par défaut » SANS le poser. Mesuré sur la migration qui vient de tourner : une liste de prix bien présente, quatre modèles de rapprochement recréés, et mon rapport annonçait toujours « absente ». Il aurait signalé de même la base d'un client ayant créé la sienne à la main. On cherche donc une LIGNE dans product_pricelist, protégée par to_regclass pour le cas où le module n'est pas installé. --- EN --- Second false finding from the same tool, and the same cause: a proxy never checked against the real condition. It looked for the xmlid `product.list0`. But the repair lets Odoo create « Par défaut » WITHOUT setting it. Measured on the migration that just ran: a pricelist plainly there, four reconciliation models recreated — and my report still said "missing". It would have flagged a customer database whose pricelist was made by hand just the same. So we look for a ROW in product_pricelist, guarded by to_regclass in case the module is not installed. Assisted-by: Claude Opus 5 --- script/analyse/check_migration_residue.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py index b171851..f2ef0e3 100755 --- a/script/analyse/check_migration_residue.py +++ b/script/analyse/check_migration_residue.py @@ -134,10 +134,15 @@ CONTROLES = ( "title": "Default pricelist missing while product is installed", "why": "product.list0 was declared up to Odoo 16 only; nothing" " recreates it, and a quotation has no price list to pick.", + # On cherche une LISTE, pas son xmlid. Mesuré : la réparation + # laisse Odoo créer « Par défaut » sans poser `product.list0` — + # chercher l'xmlid signalait donc une base parfaitement saine, et + # aurait signalé de même celle d'un client qui a créé la sienne à + # la main. "sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module" " WHERE name='product' AND state='installed')" - " AND NOT EXISTS (SELECT 1 FROM ir_model_data" - " WHERE module='product' AND name='list0')" + " AND to_regclass('public.product_pricelist') IS NOT NULL" + " AND NOT EXISTS (SELECT 1 FROM product_pricelist)" " THEN 1 ELSE 0 END", "gravity": "broken", "repair": "script/odoo/migration/restore_config_defaults.py --apply", From 5e6c976ecb7bf7140f736e0e20057cf4e52aae13 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 01:00:46 -0400 Subject: [PATCH 08/12] [FIX] nettoyage : les enfants s'en vont avec leur rebond MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La VM Proxmox locale effacée, le nettoyage a retiré son entrée ssh — c'était juste — et GARDÉ les trois entrées qui rebondissaient par elle, en les annonçant « mènent encore quelque part ». Trois culs-de-sac, désignés comme vivants. Deux fautes. Un ProxyJump valait preuve de vie À LUI SEUL, au motif qu'il désigne une VM imbriquée que virsh ne connaîtra jamais : le raisonnement oubliait que le rebond, lui, peut avoir disparu. Et chaque entrée était jugée ISOLÉMENT, alors que retirer le parent orpheline ses enfants — qui orphelinent les leurs. D'où un point fixe, et non une passe. Un rebond qu'on ne gère pas — hôte personnel, adresse, nom DNS — reste supposé vivant : on n'efface pas sur une supposition. Mais un nom de NOTRE nommage sans entrée et sans domaine ne mène nulle part, et c'est exactement l'état qu'un nettoyage précédent laisse derrière lui. La liste des orphelines dit maintenant POURQUOI. « Son rebond n'existe plus : erplibre-proxmox-9 » est la seule chose qui permet de répondre non en connaissance de cause. --- EN --- With the local Proxmox VM deleted, the cleanup removed its ssh entry — rightly — and KEPT the three entries hopping through it, announcing them as "still lead somewhere". Three dead ends, labelled alive. Two defects. A ProxyJump counted as proof of life ON ITS OWN, on the grounds that it names a nested VM virsh will never know: the reasoning forgot the jump itself can be gone. And each entry was judged IN ISOLATION, while removing a parent orphans its children — which orphan theirs. Hence a fixed point, not a single pass. A jump we do not manage — personal host, address, DNS name — stays presumed alive: we do not delete on a guess. But a name of OUR OWN convention with no entry and no domain leads nowhere, and that is exactly the state a previous cleanup leaves behind. The orphan list now says WHY. "Its jump host is gone: erplibre-proxmox-9" is the only thing that lets you answer no knowingly. Assisted-by: Claude Opus 5 --- script/todo/qemu_manage.py | 122 +++++++++++++++++++++++++++++----- script/todo/todo_i18n.py | 4 ++ test/test_qemu_cleanup.py | 130 +++++++++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 19 deletions(-) diff --git a/script/todo/qemu_manage.py b/script/todo/qemu_manage.py index 95f6ea7..50f32b0 100644 --- a/script/todo/qemu_manage.py +++ b/script/todo/qemu_manage.py @@ -15,6 +15,94 @@ import time from script.todo.todo_i18n import t +def parse_ssh_blocks(content) -> dict: + """{nom: {"hostname": …, "proxyjump": …}} pour CHAQUE nom déclaré. + + Une ligne « Host » peut en porter plusieurs : ils partagent alors le même + corps, donc la même entrée. Les motifs (« * », « ? ») sont écartés — ce + sont des règles, pas des machines.""" + blocs, courant = {}, [] + for ligne in (content or "").splitlines(): + if re.match(r"^[ \t]*Host[ \t]+", ligne): + corps = {} + courant = [ + n for n in ligne.split()[1:] if "*" not in n and "?" not in n + ] + for nom in courant: + blocs[nom] = corps + continue + if not courant: + continue + if ligne.strip() and not ligne[:1].isspace(): + courant = [] + continue + mots = ligne.split() + if len(mots) >= 2 and mots[0].lower() in ("hostname", "proxyjump"): + blocs[courant[0]][mots[0].lower()] = mots[1] + return blocs + + +def ssh_orphans(blocs, juge, prefixe="erplibre-"): + """(gardées, orphelines) — chacune [(nom, raison)]. + + Un ProxyJump valait preuve de vie À LUI SEUL : « écrite pour une VM + imbriquée, que virsh ne connaîtra jamais ». Le raisonnement oubliait que + le rebond, lui, peut avoir disparu. Vécu : la VM Proxmox locale effacée, + le nettoyage a retiré son entrée — correctement — et GARDÉ les trois + entrées qui rebondissaient par elle. Trois culs-de-sac, présentés comme + « mènent encore quelque part ». + + D'où le point fixe : retirer un parent peut orpheliner ses enfants, et + ceux-ci peuvent en orpheliner d'autres. On tourne jusqu'à ce que plus + rien ne bouge. + + `juge(nom)` ne rend que les preuves DIRECTES — un domaine vivant, une + adresse qui mène à l'un d'eux, une VM de l'hôte Proxmox. Si le rebond + comptait comme preuve directe, une chaîne de rebonds morts se soutiendrait + toute seule.""" + noms = [n for n in blocs if n.startswith(prefixe)] + raisons = {n: juge(n) for n in noms} + + def rebond_vivant(saut): + if saut in blocs: + # Une entrée qu'on ne gère PAS — hôte personnel — n'est jamais + # notre affaire : on la suppose vivante plutôt que d'effacer sur + # une supposition. + return ( + bool(raisons.get(saut)) if saut.startswith(prefixe) else True + ) + # Plus AUCUNE entrée de ce nom. Deux lectures, et il faut les + # séparer : une adresse ou un nom DNS, ssh saura le joindre et ce + # n'est pas notre affaire ; un nom de NOTRE nommage, en revanche, + # n'existe que par son entrée — celle-ci partie, le rebond ne mène + # nulle part. C'est l'état exact laissé par un nettoyage précédent, + # qui avait retiré le parent et gardé les enfants. + if not saut.startswith(prefixe): + return True + return bool(juge(saut)) + + bouge = True + while bouge: + bouge = False + for nom in noms: + if raisons[nom]: + continue + saut = blocs[nom].get("proxyjump") + if saut and rebond_vivant(saut): + raisons[nom] = t("reached through a jump host") + bouge = True + gardes, orphelines = [], [] + for nom in noms: + if raisons[nom]: + gardes.append((nom, raisons[nom])) + else: + saut = blocs[nom].get("proxyjump") + orphelines.append( + (nom, f"{t('its jump host is gone:')} {saut}" if saut else "") + ) + return gardes, orphelines + + class QemuManageMixin: """Menu QEMU/KVM : g\u00e9rer les VM existantes.\n\nLe cycle de vie apr\u00e8s la cr\u00e9ation : lister, allumer et \u00e9teindre, r\u00e9gler le\nmat\u00e9riel, redimensionner (et r\u00e9tr\u00e9cir, ce qui demande de traverser le syst\u00e8me\nde fichiers invit\u00e9 par nbd), effacer, nettoyer les restes, retrouver une\nadresse IP, rouvrir le suivi d'une installation.\n\nC'est le fichier qui appelle \u00ab virsh \u00bb le plus souvent : les helpers qui le\nfont (domstate, dumpxml, c_env) vivent donc ici.""" @@ -1978,9 +2066,13 @@ class QemuManageMixin: preuves valent mieux : * son adresse est celle d'un domaine vivant ; - * elle porte un ProxyJump, donc elle a été écrite pour une VM - imbriquée ou distante, que virsh ne connaîtra jamais ; * son nom est celui d'une VM de l'hôte Proxmox retenu. + + Le ProxyJump n'en fait PAS partie, et c'est le second défaut de cette + fonction : il valait preuve à lui seul, sans qu'on regarde jamais si + le rebond existait encore. Cette question-là se traite dans + `ssh_orphans`, qui seule peut la poser — la réponse dépend des autres + entrées, et de celles qu'on s'apprête à retirer. """ if nom in domains: return nom @@ -1993,8 +2085,6 @@ class QemuManageMixin: ip = re.search(r"(?mi)^[ \t]*HostName[ \t]+(\S+)", corps) if ip and ip.group(1) in adresses: return adresses[ip.group(1)] - if re.search(r"(?mi)^[ \t]*ProxyJump[ \t]+\S+", corps): - return t("reached through a jump host") if nom in distantes: return t("a VM of the Proxmox host") return "" @@ -2023,23 +2113,25 @@ class QemuManageMixin: } except Exception: pass - hosts = re.findall(r"(?m)^[ \t]*Host[ \t]+(\S+)", content) - orphans, gardes = [], [] - for h in hosts: - if not h.startswith("erplibre-"): - continue - raison = self._ssh_entry_alive( + gardes, orphelines = ssh_orphans( + parse_ssh_blocks(content), + lambda h: self._ssh_entry_alive( content, h, domains, adresses, distantes - ) - (gardes if raison else orphans).append((h, raison)) + ), + ) if gardes: print(f"\n{t('Kept (still leads somewhere):')}") for h, raison in gardes: print(f" {h} ← {raison}") - orphans = [h for h, _r in orphans] - if not orphans: + if not orphelines: return - print(f"\n{t('Orphan ~/.ssh/config entries:')} {', '.join(orphans)}") + print(f"\n{t('Orphan ~/.ssh/config entries:')}") + # Avec la RAISON : « son rebond n'existe plus » explique pourquoi une + # entrée qu'on croyait bonne s'en va, et c'est la seule chose qui + # permet de répondre non en connaissance de cause. + for h, raison in orphelines: + print(f" {h}" + (f" ← {raison}" if raison else "")) + orphans = [h for h, _r in orphelines] if not self._is_yes( input(t("Remove these ~/.ssh/config entries? (y/N): ")) ): diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dea52b2..5e7bcb6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -4137,6 +4137,10 @@ TRANSLATIONS = { "fr": "Effacer ces images en double ? (o/N, défaut : non) : ", "en": "Delete these duplicate images? (y/N, default: no): ", }, + "its jump host is gone:": { + "fr": "son rebond n'existe plus :", + "en": "its jump host is gone:", + }, "Orphan ~/.ssh/config entries:": { "fr": "Entrées ~/.ssh/config orphelines :", "en": "Orphan ~/.ssh/config entries:", diff --git a/test/test_qemu_cleanup.py b/test/test_qemu_cleanup.py index eda85a7..20be8ff 100644 --- a/test/test_qemu_cleanup.py +++ b/test/test_qemu_cleanup.py @@ -223,10 +223,12 @@ class TestLesEntreesSsh(unittest.TestCase): def test_a_name_that_matches_a_domain_is_kept(self): self.assertTrue(self._juge("erplibre-ubuntu-2404-MIGRATION")) - def test_a_jump_entry_is_kept(self): - # Écrite pour une VM imbriquée ou distante : virsh ne la connaîtra - # jamais, et son adresse n'est pas routable d'ici. - self.assertTrue(self._juge("erplibre-imbriquee")) + def test_a_jump_is_not_a_direct_proof(self): + # Un ProxyJump valait preuve À LUI SEUL. Il n'en est plus une ICI : + # la réponse dépend du rebond, donc des AUTRES entrées, et elle se + # décide dans ssh_orphans. Sinon une chaîne de rebonds morts se + # soutiendrait toute seule. + self.assertEqual(self._juge("erplibre-imbriquee"), "") def test_a_vm_of_the_proxmox_host_is_kept(self): self.assertTrue( @@ -237,6 +239,126 @@ class TestLesEntreesSsh(unittest.TestCase): self.assertEqual(self._juge("erplibre-partie"), "") +class TestLeRebondQuiNExistePlus(unittest.TestCase): + """Le nettoyage a effacé la VM Proxmox locale, retiré son entrée — c'était + juste — et GARDÉ les trois entrées qui rebondissaient par elle. + + Trois culs-de-sac, présentés comme « mènent encore quelque part ». Deux + fautes : un ProxyJump valait preuve de vie sans qu'on regarde jamais si le + rebond existait, et chaque entrée était jugée ISOLÉMENT — retirer le + parent ne faisait pas réexaminer les enfants.""" + + CONFIG = """ +Host erplibre-vivante + HostName 192.168.123.170 + +Host erplibre-proxmox-9 + HostName 192.168.123.208 + +Host erplibre-proxmox-9+enfant + HostName 10.10.10.150 + ProxyJump erplibre-proxmox-9 + +Host erplibre-petit-enfant + HostName 10.10.20.1 + ProxyJump erplibre-proxmox-9+enfant + +Host erplibre-par-hote-personnel + HostName 10.10.30.1 + ProxyJump mon-serveur-perso + +Host mon-serveur-perso + HostName 203.0.113.9 +""" + + def _passe(self, vivants): + from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans + + return ssh_orphans( + parse_ssh_blocks(self.CONFIG), + lambda nom: nom if nom in vivants else "", + ) + + def test_the_children_go_with_their_jump(self): + _gardes, orphelines = self._passe({"erplibre-vivante"}) + noms = [n for n, _r in orphelines] + self.assertIn("erplibre-proxmox-9", noms) + self.assertIn("erplibre-proxmox-9+enfant", noms) + + def test_and_so_do_the_grandchildren(self): + # Le point fixe : retirer un parent orpheline ses enfants, qui + # orphelinent les leurs. Une seule passe n'aurait vu que le premier + # étage. + _gardes, orphelines = self._passe({"erplibre-vivante"}) + self.assertIn("erplibre-petit-enfant", [n for n, _r in orphelines]) + + def test_the_reason_names_the_missing_jump(self): + # Seule chose qui permet de répondre non en connaissance de cause. + _gardes, orphelines = self._passe({"erplibre-vivante"}) + raison = dict(orphelines)["erplibre-proxmox-9+enfant"] + self.assertIn("erplibre-proxmox-9", raison) + + def test_a_living_jump_keeps_its_children(self): + gardes, _orphelines = self._passe( + {"erplibre-vivante", "erplibre-proxmox-9"} + ) + noms = [n for n, _r in gardes] + self.assertIn("erplibre-proxmox-9+enfant", noms) + self.assertIn("erplibre-petit-enfant", noms) + + def test_a_jump_whose_entry_is_already_gone(self): + """L'état laissé par le nettoyage précédent : le parent RETIRÉ, les + enfants gardés. + + Le rebond ne désigne alors plus rien du tout — ni entrée, ni domaine. + Le prendre pour « un rebond qu'on ne gère pas » laissait les trois + culs-de-sac en place une seconde fois.""" + from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans + + sans_parent = """ +Host erplibre-vivante + HostName 192.168.123.170 + +Host erplibre-proxmox-9+enfant + HostName 10.10.10.150 + ProxyJump erplibre-proxmox-9 +""" + _gardes, orphelines = ssh_orphans( + parse_ssh_blocks(sans_parent), + lambda nom: nom if nom == "erplibre-vivante" else "", + ) + self.assertEqual( + [n for n, _r in orphelines], ["erplibre-proxmox-9+enfant"] + ) + + def test_a_jump_that_is_a_living_domain_without_an_entry(self): + # Le nom est du nôtre et n'a pas d'entrée, mais le domaine TOURNE : + # on ne coupe pas. + from script.todo.qemu_manage import parse_ssh_blocks, ssh_orphans + + cfg = """ +Host erplibre-enfant + HostName 10.10.10.150 + ProxyJump erplibre-hote +""" + gardes, _o = ssh_orphans( + parse_ssh_blocks(cfg), + lambda nom: nom if nom == "erplibre-hote" else "", + ) + self.assertEqual([n for n, _r in gardes], ["erplibre-enfant"]) + + def test_a_jump_we_do_not_manage_is_never_our_call(self): + # Hôte personnel, adresse, nom DNS : on le suppose vivant plutôt que + # d'effacer sur une supposition. + gardes, _orphelines = self._passe({"erplibre-vivante"}) + self.assertIn("erplibre-par-hote-personnel", [n for n, _r in gardes]) + + def test_entries_outside_the_prefix_are_never_judged(self): + gardes, orphelines = self._passe({"erplibre-vivante"}) + tous = [n for n, _r in gardes] + [n for n, _r in orphelines] + self.assertNotIn("mon-serveur-perso", tous) + + class TestLAdresseDUneVm(unittest.TestCase): """« --source arp » remonte les passerelles des ponts : la dernière candidate n'est pas la bonne. From 30ffb262b633302190a6504fd58c61bf3e4a4289 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 01:20:45 -0400 Subject: [PATCH 09/12] [ADD] script addons prod to dev support disable_payment_provider --- script/addons/update_prod_to_dev.sh | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/script/addons/update_prod_to_dev.sh b/script/addons/update_prod_to_dev.sh index 2c94bd4..0568d89 100755 --- a/script/addons/update_prod_to_dev.sh +++ b/script/addons/update_prod_to_dev.sh @@ -5,7 +5,7 @@ Color_Off='\033[0m' # Text Reset # This script will remove mail configuration, remove backup configuration, and force admin user to test/test echo "Update prod to dev on BD '$1'" -./script/addons/install_addons_dev.sh "$1" user_test,disable_mail_server,disable_auto_backup +./script/addons/install_addons_dev.sh "$1" user_test,disable_mail_server,disable_auto_backup,disable_payment_provider retVal=$? if [[ $retVal -ne 0 ]]; then @@ -15,7 +15,7 @@ fi echo "Update trace of prod to dev on BD '$1'" -./script/addons/uninstall_addons.sh "$1" user_test,disable_mail_server,disable_auto_backup +./script/addons/uninstall_addons.sh "$1" user_test,disable_mail_server,disable_auto_backup,disable_payment_provider retVal=$? if [[ $retVal -ne 0 ]]; then From eb5e607e1ef0bf109022bbe4e6e916678efe738a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 02:16:54 -0400 Subject: [PATCH 10/12] =?UTF-8?q?[FIX]=20proxmox=20:=20le=20pont=20NAT=20s?= =?UTF-8?q?'=C3=A9crivait=20avant=20de=20savoir=20si=20le=20NAT=20existe?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Table does not exist » : six lignes d'iptables et « code de retour 1 », après avoir déjà posé la strophe dans /etc/network/interfaces. Rien dans ce bruit ne dit qu'il faut redémarrer. L'hôte tournait le noyau cloud de Debian, qui est dépouillé de tout netfilter — aucun module NAT, ni legacy ni nft. Et le cas n'a rien d'exotique : c'est notre propre install_proxmox.sh qui le produit. Il pose le noyau Proxmox sans redémarrer, à raison — lancé par ssh, un reboot couperait la session et ferait passer l'installation pour un échec. Une Proxmox imbriquée fraîchement installée est donc TOUJOURS dans cet état. L'avertissement sur le noyau existait déjà, mais à la CONFIRMATION de l'hôte, et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un pont, et plus personne ne rappelle rien. Le garde va donc là où la conséquence tombe, et AVANT toute écriture. Il interroge la table NAT elle-même et non le NOM du noyau — « -pve » est un indice, pas une preuve — puis nomme le noyau en cours, celui qui est posé, et la commande qui règle l'affaire. Le sommaire de déploiement le dit désormais aussi, tant qu'on lit encore l'écran plutôt qu'au bout d'un journal d'une heure. --- EN --- "Table does not exist": six lines of iptables and "exit code 1", after the stanza had already been written into /etc/network/interfaces. Nothing in that noise says a reboot is needed. The host was running Debian's cloud kernel, stripped of all netfilter — no NAT module, legacy or nft. And the case is not exotic: our own install_proxmox.sh produces it. It installs the Proxmox kernel without rebooting, rightly — run over ssh, a reboot would cut the session and make the install look failed. A freshly installed nested Proxmox is therefore ALWAYS in this state. The kernel warning already existed, but at host CONFIRMATION, and the host is then remembered: you come back days later to create a bridge and nothing reminds you. So the guard moves to where the consequence lands, and BEFORE any write. It asks the NAT table itself rather than the kernel's NAME — "-pve" is a hint, not a proof — then names the running kernel, the installed one, and the command that settles it. The deployment summary now says it too, while the screen is still being read rather than at the end of an hour-long log. Assisted-by: Claude Opus 5 --- script/proxmox/proxmox_deploy.py | 32 +++++++++ script/todo/proxmox_menu.py | 85 +++++++++++++++++++++++ script/todo/todo_i18n.py | 32 +++++++++ test/test_proxmox_deploy.py | 54 +++++++++++++++ test/test_proxmox_form.py | 114 +++++++++++++++++++++++++++++++ 5 files changed, 317 insertions(+) diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index d16222c..12d1be2 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -218,6 +218,38 @@ def parse_kernel(text: str) -> str: return trouve.group(1) if trouve else "" +# Ce qu'il faut savoir AVANT d'écrire un pont NAT, en un aller-retour. +# +# Le noyau seul ne suffit pas à juger : « -pve » dans son nom est un indice, +# pas une preuve, et l'inverse non plus — c'est la table NAT elle-même qu'on +# interroge. « iptables -t nat -S » échoue avec « Table does not exist » quand +# aucun module netfilter n'est chargeable, et réussit sinon. +NAT_CHECK_CMD = ( + "uname -r; echo '---ERPLIBRE-NAT---'; " + "iptables -t nat -S >/dev/null 2>&1 && echo NAT-OK || echo NAT-KO; " + "echo '---ERPLIBRE-PVE-KERNEL---'; " + "ls -1 /lib/modules 2>/dev/null | grep -- -pve | sort -V | tail -1" +) + + +def parse_nat_check(text: str) -> dict: + """{"kernel": …, "nat": bool, "pve_kernel": …} depuis NAT_CHECK_CMD. + + `nat` à False sans `pve_kernel` veut dire que l'installation Proxmox n'est + pas allée au bout ; avec, qu'elle attend un redémarrage.""" + brut = strip_ssh_noise(text or "") + parts = brut.split("---ERPLIBRE-NAT---") + kernel = parts[0].strip().splitlines() + reste = parts[1] if len(parts) > 1 else "" + suite = reste.split("---ERPLIBRE-PVE-KERNEL---") + pve_kernel = suite[1].strip().splitlines() if len(suite) > 1 else [] + return { + "kernel": kernel[-1].strip() if kernel else "", + "nat": "NAT-OK" in (suite[0] if suite else ""), + "pve_kernel": pve_kernel[-1].strip() if pve_kernel else "", + } + + def parse_qm_list(text: str) -> list: """Sortie de « qm list » -> [{vmid, name, status, mem, disk}]. diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 150955f..439e837 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -648,6 +648,56 @@ class ProxmoxMenuMixin: parts = (sortie or "").split() return parts[parts.index("dev") + 1] if "dev" in parts else "" + def _pve_nat_ready(self, host): + """(prêt ?, lignes à dire). La table NAT existe-t-elle sur cet hôte ? + + Posée ICI, au moment d'écrire un pont NAT, et non à la connexion : le + noyau est vérifié quand on confirme l'hôte, mais l'hôte est ensuite + MÉMORISÉ — on revient des jours plus tard créer un pont, et plus + personne ne rappelle rien. Le garde doit être là où la conséquence + tombe. + + Sans cette question, ifupdown2 rendait six lignes d'iptables et « code + de retour 1 », après avoir déjà écrit la strophe dans + /etc/network/interfaces. Rien dans ce bruit ne dit qu'il faut + redémarrer. + + Le cas n'a rien d'exotique : notre propre install_proxmox.sh pose le + noyau Proxmox sans redémarrer — lancé par ssh, un reboot couperait la + session. Une Proxmox imbriquée fraîchement installée est donc TOUJOURS + dans cet état, sur le noyau cloud de Debian, qui est dépouillé de tout + netfilter.""" + from script.proxmox import proxmox_deploy as pve + + _c, out = pve.run(host, pve.NAT_CHECK_CMD, 40) + etat = pve.parse_nat_check(out) + if etat["nat"]: + return True, [] + lignes = [ + f"✗ {t('No NAT table on this host: the bridge would lead nowhere.')}", + f" {t('Running kernel:')} {etat['kernel'] or '?'}", + ] + if etat["pve_kernel"]: + lignes += [ + f" {t('The distribution kernel carries no netfilter module.')}", + f" {t('Proxmox kernel installed:')} {etat['pve_kernel']}" + f" — {t('taken at next boot')}", + f"→ ssh {host.get('target', '')} sudo reboot," + f" {t('then come back here.')}", + ] + else: + lignes.append( + f" {t('No Proxmox kernel installed: finish the install first.')}" + ) + return False, lignes + + def _pve_nat_reason(self, host): + """La même chose en UNE ligne, pour l'écran Textual.""" + ok, lignes = self._pve_nat_ready(host) + if ok: + return "" + return " ".join(ligne.strip("✗→ ") for ligne in lignes[:2]) + def _pve_make_internal_bridge(self): """Crée le pont INTERNE et le rend, ou ('', raison). SANS rien demander. @@ -662,6 +712,11 @@ class ProxmoxMenuMixin: host = self._pve_host(ask=False) if not host: return "", t("No Proxmox host.") + # AVANT d'écrire quoi que ce soit : une strophe posée puis un + # « ifup » qui échoue laisse le fichier modifié et le pont absent. + raison = self._pve_nat_reason(host) + if raison: + return "", raison uplink = self._pve_uplink() for cmd in pve.bridge_setup_cmds(uplink=uplink): code, sortie = pve.run(host, cmd, 180) @@ -704,6 +759,13 @@ class ProxmoxMenuMixin: f" ⚠ {t('This moves the host address: do it from a console.')}" ) return "" + host = self._pve_host(ask=False) + ok, lignes = self._pve_nat_ready(host) if host else (True, []) + if not ok: + print() + for ligne in lignes: + print(f" {ligne}") + return "" uplink = self._pve_uplink() print(f" {t('uplink for NAT')} : {uplink or t('none')}") for cmd in pve.bridge_setup_cmds(uplink=uplink): @@ -1292,6 +1354,19 @@ class ProxmoxMenuMixin: ) if vm.get("alias"): print(f" ssh {vm['alias']}") + # Une VM qui vient de recevoir Proxmox tourne encore le noyau de + # son image cloud : celui-ci n'a AUCUN module netfilter, donc ni + # pont NAT ni VM à l'intérieur. install_proxmox.sh ne redémarre + # pas de lui-même — lancé par ssh, un reboot couperait la session + # et ferait passer l'installation pour un échec. Le dire ICI, où + # on lit encore l'écran, plutôt qu'au bout d'un journal d'une + # heure : sans cela on le redécouvre en créant un pont, devant six + # lignes d'iptables qui ne parlent pas de redémarrage. + if self._pve_installs_proxmox(vm, spec): + print( + f" ⚠ {t('reboot it to boot the Proxmox kernel:')}" + f" ssh {vm.get('alias') or vm['name']} sudo reboot" + ) if spec.get("install"): print( f" {t('Install:')} {spec['install'].get('label') or ''}" @@ -1299,6 +1374,16 @@ class ProxmoxMenuMixin: ) print(f" {t('Log:')} {session}") + @staticmethod + def _pve_installs_proxmox(vm, spec) -> bool: + """Cette VM reçoit-elle l'hyperviseur Proxmox VE ? + + Jugé sur la commande EFFECTIVE de la VM — celle que son système lui + impose, sinon le choix commun — et non sur son nom ni sur sa + distribution : un parc mixte est le cas normal ici.""" + cmd = vm.get("install_cmd") or (spec.get("install") or {}).get("cmd") + return "install_proxmox.sh" in (cmd or "") + def _pve_confirm_spec(self, host, spec): """Récapitulatif puis confirmation, dans le TERMINAL. diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5e7bcb6..dce07a6 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3362,6 +3362,38 @@ TRANSLATIONS = { "fr": "Aucun stockage capable d'héberger un disque de VM.", "en": "No storage able to hold a VM disk.", }, + "No NAT table on this host: the bridge would lead nowhere.": { + "fr": "Pas de table NAT sur cet hôte : le pont ne mènerait nulle part.", + "en": "No NAT table on this host: the bridge would lead nowhere.", + }, + "reboot it to boot the Proxmox kernel:": { + "fr": "à redémarrer pour amorcer le noyau Proxmox :", + "en": "reboot it to boot the Proxmox kernel:", + }, + "Running kernel:": { + "fr": "Noyau en cours :", + "en": "Running kernel:", + }, + "The distribution kernel carries no netfilter module.": { + "fr": "Le noyau de la distribution ne porte aucun module netfilter.", + "en": "The distribution kernel carries no netfilter module.", + }, + "Proxmox kernel installed:": { + "fr": "Noyau Proxmox installé :", + "en": "Proxmox kernel installed:", + }, + "taken at next boot": { + "fr": "pris au prochain démarrage", + "en": "taken at next boot", + }, + "then come back here.": { + "fr": "puis revenir ici.", + "en": "then come back here.", + }, + "No Proxmox kernel installed: finish the install first.": { + "fr": "Aucun noyau Proxmox installé : terminer l'installation d'abord.", + "en": "No Proxmox kernel installed: finish the install first.", + }, "No network bridge on this host.": { "fr": "Aucun pont réseau sur cet hôte.", "en": "No network bridge on this host.", diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index f93ec5d..1b37447 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -619,5 +619,59 @@ class TestLeMenu(unittest.TestCase): self.assertEqual(0, res.returncode, res.stderr) +class TestLaTableNat(unittest.TestCase): + """« Table does not exist » : six lignes d'iptables et « code de retour 1 », + après avoir déjà écrit la strophe dans /etc/network/interfaces. + + Rien dans ce bruit ne dit qu'il faut redémarrer. Et le cas n'a rien + d'exotique : notre propre install_proxmox.sh pose le noyau Proxmox sans + redémarrer — lancé par ssh, un reboot couperait la session. Une Proxmox + imbriquée fraîchement installée est donc TOUJOURS sur le noyau cloud de + Debian, qui est dépouillé de tout netfilter. + + On demande donc à la table NAT elle-même, et non au NOM du noyau : « -pve » + est un indice, pas une preuve.""" + + def _sortie(self, kernel, nat, pve_kernel=""): + return ( + f"{kernel}\n---ERPLIBRE-NAT---\n" + f"{'NAT-OK' if nat else 'NAT-KO'}\n" + f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n" + ) + + def test_a_working_host(self): + lu = pve.parse_nat_check( + self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve") + ) + self.assertTrue(lu["nat"]) + self.assertEqual(lu["kernel"], "7.0.14-14-pve") + + def test_the_cloud_kernel_waiting_for_a_reboot(self): + # L'état exact rapporté : le noyau Proxmox est POSÉ, pas amorcé. + lu = pve.parse_nat_check( + self._sortie("6.12.101+deb13-cloud-amd64", False, "7.0.14-14-pve") + ) + self.assertFalse(lu["nat"]) + self.assertEqual(lu["pve_kernel"], "7.0.14-14-pve") + + def test_an_unfinished_install_has_no_pve_kernel(self): + lu = pve.parse_nat_check( + self._sortie("6.12.101+deb13-cloud-amd64", False) + ) + self.assertFalse(lu["nat"]) + self.assertEqual(lu["pve_kernel"], "") + + def test_ssh_noise_does_not_become_a_kernel(self): + brut = ( + "Warning: Permanently added 'x' (ED25519) to the list of known" + " hosts.\n" + self._sortie("7.0.14-14-pve", True, "7.0.14-14-pve") + ) + self.assertEqual(pve.parse_nat_check(brut)["kernel"], "7.0.14-14-pve") + + def test_the_probe_asks_the_table_not_the_name(self): + self.assertIn("iptables -t nat", pve.NAT_CHECK_CMD) + self.assertIn("uname -r", pve.NAT_CHECK_CMD) + + if __name__ == "__main__": unittest.main(verbosity=1) diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 9413265..781c411 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -16,6 +16,7 @@ aucun hôte Proxmox n'est joint. import asyncio import sys import unittest +from unittest import mock sys.argv = ["todo.py"] from script.todo.proxmox_deploy_form import ( # noqa: E402 @@ -685,6 +686,119 @@ class TestUnParcMixte(unittest.TestCase): self.assertEqual(vu["kw"]["desktop"], "") +class TestLePontQuiNeMeneraitNullePart(unittest.TestCase): + """Le pont NAT était écrit AVANT qu'on sache si le NAT existe. + + Résultat rapporté : la strophe posée dans /etc/network/interfaces, le + pont absent, et six lignes d'iptables qui ne parlent pas de redémarrage. + L'avertissement sur le noyau existait — mais à la CONFIRMATION de l'hôte, + et l'hôte est ensuite mémorisé : on revient des jours plus tard créer un + pont, et plus personne ne rappelle rien.""" + + def _todo(self, sortie): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vu = [] + todo._pve_host = lambda ask=True: {"target": "pve9", "sudo": ""} + todo._pve_uplink = lambda: "eth0" + + def faux_run(host, cmd, timeout=120): + vu.append(cmd) + from script.proxmox import proxmox_deploy as pve + + if cmd == pve.NAT_CHECK_CMD: + return 0, sortie + return 0, "" + + return todo, vu, faux_run + + def _sortie(self, nat, pve_kernel="7.0.14-14-pve"): + return ( + f"{'7.0.14-14-pve' if nat else '6.12.101+deb13-cloud-amd64'}\n" + f"---ERPLIBRE-NAT---\n{'NAT-OK' if nat else 'NAT-KO'}\n" + f"---ERPLIBRE-PVE-KERNEL---\n{pve_kernel}\n" + ) + + def test_nothing_is_written_when_there_is_no_nat(self): + todo, vu, faux = self._todo(self._sortie(nat=False)) + with mock.patch("script.proxmox.proxmox_deploy.run", faux): + nom, raison = todo._pve_make_internal_bridge() + self.assertEqual(nom, "") + self.assertTrue(raison) + # Une seule commande : la sonde. Rien n'a touché au fichier. + self.assertEqual(len(vu), 1, vu) + self.assertNotIn( + "interfaces", " ".join(vu), "la strophe ne doit pas être écrite" + ) + + def test_the_reason_names_the_kernel_to_boot(self): + todo, _vu, faux = self._todo(self._sortie(nat=False)) + with mock.patch("script.proxmox.proxmox_deploy.run", faux): + ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""}) + self.assertFalse(ok) + texte = " ".join(lignes) + self.assertIn("6.12.101+deb13-cloud-amd64", texte) + self.assertIn("7.0.14-14-pve", texte) + self.assertIn("reboot", texte) + + def test_an_unfinished_install_says_so_instead(self): + todo, _vu, faux = self._todo(self._sortie(nat=False, pve_kernel="")) + with mock.patch("script.proxmox.proxmox_deploy.run", faux): + _ok, lignes = todo._pve_nat_ready({"target": "pve9", "sudo": ""}) + texte = " ".join(lignes) + self.assertNotIn("reboot", texte, "rien à redémarrer, rien de posé") + + def test_a_working_host_goes_through(self): + todo, vu, faux = self._todo(self._sortie(nat=True)) + with mock.patch("script.proxmox.proxmox_deploy.run", faux): + todo._pve_make_internal_bridge() + self.assertGreater(len(vu), 1, "la création doit suivre la sonde") + + +class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase): + """Le sommaire ne disait pas qu'une VM qui vient de recevoir Proxmox + tourne encore le noyau de son image cloud. + + On le redécouvrait des jours plus tard, en créant un pont, devant six + lignes d'iptables.""" + + def _juge(self, vm, commun=""): + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + return TODO._pve_installs_proxmox( + vm, {"install": {"cmd": commun}} if commun else {} + ) + + def test_a_vm_that_gets_the_hypervisor(self): + self.assertTrue( + self._juge({"install_cmd": "./script/proxmox/install_proxmox.sh"}) + ) + + def test_through_the_common_choice_too(self): + self.assertTrue(self._juge({}, "./script/proxmox/install_proxmox.sh")) + + def test_an_erplibre_vm_is_left_alone(self): + self.assertFalse( + self._juge({}, "make install_os && make install_odoo_18") + ) + + def test_a_vm_of_its_own_overrides_the_common_choice(self): + # Parc mixte : la commande de la VM l'emporte sur celle du parc. + self.assertFalse( + self._juge( + {"install_cmd": "make install_odoo_18"}, + "./script/proxmox/install_proxmox.sh", + ) + ) + + class TestLEcranDUneVmProxmox(unittest.TestCase): """« Console de l'hyperviseur » conseillait des commandes virsh sur une machine qui n'a pas libvirt. From 7a91b010bd9b8e7fdfd29e9afa539ad61f5d73a9 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 02:49:02 -0400 Subject: [PATCH 11/12] =?UTF-8?q?[ADD]=20suivi=20:=20le=20red=C3=A9marrage?= =?UTF-8?q?=20fait=20partie=20de=20l'installation=20de=20Proxmox?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Proxmox VE n'existe qu'après un redémarrage : tant que la VM tourne le noyau de son image cloud, elle n'a aucun module netfilter — ni pont NAT, ni invité. install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. On le découvrait donc des jours plus tard, en créant un pont. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celui de la VM : installation, reboot, attente, puis vérification du noyau. Le ✅ ne s'écrit qu'après, et il veut donc dire « hyperviseur utilisable ». Trois choix méritent d'être dits. On ne redémarre qu'après un SUCCÈS — redémarrer après un échec effacerait la seule machine sur laquelle on pouvait chercher. On n'attend pas que ssh « revienne » mais que « uname -r » porte le motif attendu : sshd répond encore une seconde ou deux après l'ordre, et on lirait l'ancien noyau en croyant avoir la réponse. Et l'absence du noyau attendu est un vrai ÉCHEC, pas un avertissement. Le shell est exécuté par les tests, ssh bouchonné, dans les quatre cas — dont celui où les deux premières lectures rendent l'ancien noyau. Un garde qu'on ne sait pas éprouver s'ouvre le jour où il casse. La note du sommaire ne paraît plus que sans suivi, où rien ne redémarre : réclamer un redémarrage déjà fait est une consigne fausse. --- EN --- Proxmox VE only exists after a reboot: while the VM runs its cloud image's kernel it has no netfilter module — no NAT bridge, no guest. install_proxmox.sh installs the kernel then stops, rightly, since run over ssh a reboot would cut its own session and make the install look failed. So you found out days later, when creating a bridge. The reboot moves to the launch wrapper, which runs on OUR machine and survives the VM's: install, reboot, wait, then verify the kernel. The ✅ is written only after, and therefore means "usable hypervisor". Three choices worth stating. We reboot only after SUCCESS — rebooting after a failure would wipe the one machine you could investigate. We do not wait for ssh to "come back" but for "uname -r" to carry the expected pattern: sshd answers for another second or two after the order, and we would read the old kernel believing we had the answer. And a missing expected kernel is a real FAILURE, not a warning. The shell is executed by the tests, ssh stubbed, in all four cases — including the one where the first two reads return the old kernel. A guard you cannot exercise opens the day it breaks. The summary note now appears only without monitoring, where nothing reboots: asking for a reboot already done is a false instruction. Assisted-by: Claude Opus 5 --- script/todo/proxmox_menu.py | 17 +++-- script/todo/qemu_install_monitor.py | 98 ++++++++++++++++++++++++- script/todo/todo_i18n.py | 16 +++++ test/test_proxmox_form.py | 34 +++++++++ test/test_qemu_monitor_pve.py | 106 ++++++++++++++++++++++++++++ 5 files changed, 261 insertions(+), 10 deletions(-) diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index 439e837..41f62a3 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1356,13 +1356,16 @@ class ProxmoxMenuMixin: print(f" ssh {vm['alias']}") # Une VM qui vient de recevoir Proxmox tourne encore le noyau de # son image cloud : celui-ci n'a AUCUN module netfilter, donc ni - # pont NAT ni VM à l'intérieur. install_proxmox.sh ne redémarre - # pas de lui-même — lancé par ssh, un reboot couperait la session - # et ferait passer l'installation pour un échec. Le dire ICI, où - # on lit encore l'écran, plutôt qu'au bout d'un journal d'une - # heure : sans cela on le redécouvre en créant un pont, devant six - # lignes d'iptables qui ne parlent pas de redémarrage. - if self._pve_installs_proxmox(vm, spec): + # pont NAT ni VM à l'intérieur. + # + # Le suivi s'en charge : son enveloppe tourne sur NOTRE machine, + # donc elle survit au redémarrage de la VM, l'attend et vérifie le + # noyau avant de conclure. La note ne sert donc QUE sans suivi — + # la voie en série, elle, s'arrête à la fin du script. L'afficher + # dans les deux cas demanderait un redémarrage déjà fait. + if not spec.get("monitor", True) and self._pve_installs_proxmox( + vm, spec + ): print( f" ⚠ {t('reboot it to boot the Proxmox kernel:')}" f" ssh {vm.get('alias') or vm['name']} sudo reboot" diff --git a/script/todo/qemu_install_monitor.py b/script/todo/qemu_install_monitor.py index 8e7e9ae..57b52e4 100644 --- a/script/todo/qemu_install_monitor.py +++ b/script/todo/qemu_install_monitor.py @@ -33,6 +33,36 @@ except Exception: # pragma: no cover - repli si i18n indisponible EXIT_MARKER = "__ERPLIBRE_EXIT__" + +# Installations qui posent un NOYAU : elles ne valent rien avant un +# redémarrage, et le script ne peut pas survivre au sien. La table dit quoi +# attendre APRÈS — un motif à trouver dans « uname -r », donc une preuve et +# non une supposition. +# +# Proxmox VE en est le seul cas aujourd'hui, et il est systématique : notre +# install_proxmox.sh pose proxmox-default-kernel sans redémarrer — lancé par +# ssh, un reboot couperait la session et ferait passer l'installation pour un +# échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout +# netfilter : ni pont NAT, ni invité. On le découvrait des jours plus tard. +REBOOT_AFTER = (("install_proxmox.sh", "-pve"),) + +# Attente maximale du retour de la machine, en tours de cinq secondes. +# Généreuse : un hyperviseur imbriqué redémarre lentement, et échouer trop +# tôt marquerait rouge une installation qui a réussi. +REBOOT_TOURS = 180 + + +def reboot_expected(remote_cmd) -> str: + """Motif à trouver dans « uname -r » après redémarrage, ou "". + + Jugé sur la COMMANDE effective de la VM, pas sur sa distribution : un parc + mixte est le cas normal, et c'est ce qu'on installe qui décide.""" + for marque, motif in REBOOT_AFTER: + if marque in (remote_cmd or ""): + return motif + return "" + + SSH_OPTS = ( "-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null " "-o ConnectTimeout=8" @@ -79,6 +109,56 @@ def list_install_runs() -> list: return runs +def _reboot_steps(log_q: str, motif: str, tours: int = REBOOT_TOURS) -> str: + """Shell qui redémarre la VM, attend son retour, et vérifie son noyau. + + Trois choses valent d'être dites. + + Le redémarrage n'a lieu QUE si l'installation a réussi : redémarrer après + un échec effacerait la seule machine sur laquelle on pouvait chercher. + + On n'attend pas que ssh « revienne » — sshd répond encore une seconde ou + deux après l'ordre de redémarrage, et on lirait alors l'ANCIEN noyau en + croyant avoir la réponse. On attend que « uname -r » porte le motif ; tant + qu'il porte l'ancien, la machine n'est pas revenue. + + `tours` est un paramètre pour que ce shell soit ÉPROUVABLE : un garde + qu'on ne sait pas exécuter s'ouvre le jour où il casse. + + Et l'échec est un vrai échec : sans le noyau attendu, l'hyperviseur n'a ni + table NAT ni module bridge. Le dire ✅ serait le mensonge qui a coûté deux + jours à le comprendre.""" + msg_reboot = t("Rebooting to boot the new kernel") + msg_wait = t("waiting for the machine to come back") + msg_ok = t("kernel booted:") + msg_ko = t("the machine did not come back on the expected kernel:") + return ( + 'if [ "$rc" = 0 ]; then ' + f"echo {shlex.quote('== ' + msg_reboot + ' ==')} >> {log_q}; " + # « || true » : la session MEURT avec le redémarrage, et son code 255 + # ne dit rien de l'ordre lui-même. + f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" ' + # « sudo -n » : cette enveloppe tourne DÉTACHÉE, sans terminal. Un + # sudo qui demande son mot de passe échoue alors tout de suite au lieu + # d'attendre une frappe que personne ne fera. + "'sudo -n systemctl reboot' " + f">> {log_q} 2>&1 || true; " + f"echo {shlex.quote(' ' + msg_wait)} >> {log_q}; " + "krn=''; " + f"for i in $(seq 1 {tours}); do sleep ${{ERPLIBRE_REBOOT_SLEEP:-5}}; " + f'k=$(ssh {SSH_OPTS_BATCH} -o BatchMode=yes "erplibre@$ip" ' + "'uname -r' 2>/dev/null); " + f'case "$k" in *{motif}*) krn="$k"; break;; esac; ' + "if [ $((i % 6)) -eq 0 ]; then " + f'echo " ... $((i*5))s" >> {log_q}; fi; ' + "done; " + 'if [ -n "$krn" ]; then ' + f'echo " {msg_ok} $krn" >> {log_q}; ' + f'else echo " ⚠ {msg_ko} {motif}" >> {log_q}; rc=1; fi; ' + "fi; " + ) + + def _launch_one( ip: str, remote_cmd: str, @@ -86,10 +166,17 @@ def _launch_one( name: str = "", installs: bool = True, pve: bool = False, + reboot: str = "", ) -> None: """Lance une install SSH DÉTACHÉE : attend le sshd, exécute, journalise la sortie puis écrit le marqueur de fin avec le code de sortie. + `reboot` : motif attendu dans « uname -r » APRÈS un redémarrage. Non + vide, l'installation réussie est suivie d'un reboot, de l'attente du + retour, et d'une vérification du noyau — le succès n'est écrit qu'ensuite. + C'est ici et non dans la VM parce qu'un script ne survit pas à son propre + redémarrage : cette enveloppe, elle, tourne sur NOTRE machine. + `pve` : la VM vit sur un hôte Proxmox. On ne RÉ-RÉSOUT alors PAS son adresse par virsh — et c'est vital. Vécu le 24 août 2026 : une VM « erplibre-ubuntu-2604 » déployée sur Proxmox portait le nom d'un domaine @@ -225,8 +312,9 @@ def _launch_one( f"else echo {shlex.quote('== ' + msg_giveup + ' ==')} >> {log_q}; fi; " f'echo " → $ip" >> {log_q}; ' f'ssh {SSH_OPTS_BATCH} "erplibre@$ip" {shlex.quote(remote_cmd)} ' - f">> {log_q} 2>&1; " - f'echo "{EXIT_MARKER} $?" >> {log_q}' + f">> {log_q} 2>&1; rc=$?; " + + (_reboot_steps(log_q, reboot) if reboot else "") + + f'echo "{EXIT_MARKER} $rc" >> {log_q}' ) # setsid -f : le process survit à la fermeture du menu / du dashboard. # stdin sur /dev/null : SANS lui, le descripteur 0 du processus détaché @@ -293,13 +381,17 @@ def launch_installs(vms: list[dict], branch: str, remote_cmd: str) -> str: # ou bureau) se choisit machine par machine, le script distant n'est # plus le même pour toutes. `remote_cmd` reste le défaut, ce qui laisse # intacts les appelants qui n'en fournissent qu'une. + cmd_vm = vm.get("remote_cmd") or remote_cmd _launch_one( vm["ip"], - vm.get("remote_cmd") or remote_cmd, + cmd_vm, log_path, vm["name"], installs=bool(branch), pve=bool(vm.get("pve")), + # Une installation qui pose un NOYAU ne vaut rien avant le + # redémarrage : l'enveloppe s'en charge et ne conclut qu'après. + reboot=reboot_expected(cmd_vm), ) entree = { "name": vm["name"], diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index dce07a6..2eeee6a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -3370,6 +3370,22 @@ TRANSLATIONS = { "fr": "à redémarrer pour amorcer le noyau Proxmox :", "en": "reboot it to boot the Proxmox kernel:", }, + "Rebooting to boot the new kernel": { + "fr": "Redémarrage pour amorcer le nouveau noyau", + "en": "Rebooting to boot the new kernel", + }, + "waiting for the machine to come back": { + "fr": "attente du retour de la machine", + "en": "waiting for the machine to come back", + }, + "kernel booted:": { + "fr": "noyau amorcé :", + "en": "kernel booted:", + }, + "the machine did not come back on the expected kernel:": { + "fr": "la machine n'est pas revenue sur le noyau attendu :", + "en": "the machine did not come back on the expected kernel:", + }, "Running kernel:": { "fr": "Noyau en cours :", "en": "Running kernel:", diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index 781c411..bccd158 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -789,6 +789,40 @@ class TestUneProxmoxImbriqueeDoitRedemarrer(unittest.TestCase): self._juge({}, "make install_os && make install_odoo_18") ) + def test_the_note_only_shows_when_nothing_reboots_it(self): + """Avec suivi, l'enveloppe redémarre elle-même : réclamer un + redémarrage déjà fait est une consigne fausse. Sans suivi, la voie en + série s'arrête à la fin du script, et la note est la seule chose qui + dit que l'hyperviseur n'est pas encore utilisable.""" + import contextlib + import io + import sys + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + def sommaire(monitor): + todo = TODO.__new__(TODO) + spec = { + "vms": [{"name": "pve-imbrique"}], + "install": { + "cmd": "./script/proxmox/install_proxmox.sh", + "label": "Proxmox VE", + "branch": "develop", + }, + "monitor": monitor, + "storage": "local", + "bridge": "vmbr0", + } + vm = dict(spec["vms"][0], alias="pve9+pve-imbrique", vmid=102) + tampon = io.StringIO() + with contextlib.redirect_stdout(tampon): + todo._pve_print_summary(spec, [vm], "") + return tampon.getvalue() + + self.assertIn("reboot", sommaire(monitor=False)) + self.assertNotIn("reboot", sommaire(monitor=True)) + def test_a_vm_of_its_own_overrides_the_common_choice(self): # Parc mixte : la commande de la VM l'emporte sur celle du parc. self.assertFalse( diff --git a/test/test_qemu_monitor_pve.py b/test/test_qemu_monitor_pve.py index 1010820..3bffe23 100644 --- a/test/test_qemu_monitor_pve.py +++ b/test/test_qemu_monitor_pve.py @@ -230,6 +230,112 @@ class TestLesAutresCheminsVersLaPoubelle(unittest.TestCase): self.assertGreaterEqual(mon.PVE_ABSENCES_AVANT_EFFACEE, 2) +class TestLeRedemarrageQuiFaitPartieDeLInstallation(unittest.TestCase): + """Proxmox VE n'existe qu'après un redémarrage, et le script ne peut pas + survivre au sien. + + install_proxmox.sh pose le noyau puis s'arrête, à raison — lancé par ssh, + un reboot couperait la session et ferait passer l'installation pour un + échec. La VM restait donc sur le noyau cloud de Debian, dépouillé de tout + netfilter, et on le découvrait des jours plus tard en créant un pont. + + L'enveloppe, elle, tourne sur NOTRE machine : elle survit au redémarrage + de la VM. Elle redémarre, attend, vérifie le noyau, et ne conclut + qu'ensuite — le ✅ veut donc dire « hyperviseur utilisable ».""" + + def _joue(self, rc, faux_ssh, tours=4): + """Exécute le shell RÉEL, avec ssh bouchonné par une fonction.""" + import os + import subprocess + import tempfile + + log = tempfile.NamedTemporaryFile(suffix=".log", delete=False) + log.close() + cpt = tempfile.NamedTemporaryFile("w+", delete=False) + cpt.write("0") + cpt.close() + shell = ( + f"CPT={cpt.name}\n{faux_ssh}\n" + f"ip=10.0.0.1; rc={rc}; " + + mon._reboot_steps(log.name, "-pve", tours=tours) + + f'echo "{mon.EXIT_MARKER} $rc" >> {log.name}' + ) + subprocess.run( + ["bash", "-c", shell], + env=dict(os.environ, ERPLIBRE_REBOOT_SLEEP="0"), + timeout=60, + ) + with open(log.name, encoding="utf-8") as fh: + texte = fh.read() + etat = mon.read_status(log.name) + os.unlink(log.name) + os.unlink(cpt.name) + return etat, texte + + # Le compteur vit dans un FICHIER : « k=$(ssh …) » tourne en sous-shell. + REVIENT = """ssh() { + case "$*" in + *"uname -r"*) + n=$(cat "$CPT"); n=$((n+1)); echo "$n" > "$CPT" + if [ "$n" -ge 3 ]; then echo "7.0.14-14-pve"; + else echo "6.12.101+deb13-cloud-amd64"; fi;; + *reboot*) return 255;; + esac +}""" + RESTE = """ssh() { + case "$*" in + *"uname -r"*) echo "6.12.101+deb13-cloud-amd64";; + *reboot*) return 255;; + esac +}""" + + def test_only_the_pve_kernel_ends_the_wait(self): + # Le piège : sshd répond encore une seconde ou deux après l'ordre de + # redémarrage. Lire « uname -r » et s'arrêter là donnerait l'ANCIEN + # noyau en croyant avoir la réponse. Ici les deux premières lectures + # rendent le noyau cloud et doivent être REJETÉES. + (etat, code), texte = self._joue(0, self.REVIENT) + self.assertEqual((etat, code), ("done", 0)) + self.assertIn("7.0.14-14-pve", texte) + + def test_a_machine_that_stays_on_the_old_kernel_fails(self): + # Sans le noyau attendu, l'hyperviseur n'a ni table NAT ni module + # bridge. Le dire ✅ serait le mensonge qui a coûté deux jours. + (etat, code), texte = self._joue(0, self.RESTE) + self.assertEqual(etat, "failed") + self.assertEqual(code, 1) + self.assertIn("-pve", texte) + + def test_a_machine_that_never_answers_fails(self): + (etat, _c), _texte = self._joue(0, "ssh() { return 255; }") + self.assertEqual(etat, "failed") + + def test_a_failed_install_is_never_rebooted(self): + # Redémarrer après un échec effacerait la seule machine sur laquelle + # on pouvait chercher. + (etat, code), texte = self._joue( + 2, 'ssh() { echo "NE DEVRAIT PAS ETRE APPELE"; }' + ) + self.assertEqual((etat, code), ("failed", 2)) + self.assertNotIn("NE DEVRAIT PAS", texte) + self.assertNotIn("Redémarrage", texte) + + def test_which_installs_ask_for_it(self): + self.assertEqual( + mon.reboot_expected("./script/proxmox/install_proxmox.sh"), "-pve" + ) + self.assertEqual( + mon.reboot_expected("make install_os && make install_odoo_18"), "" + ) + self.assertEqual(mon.reboot_expected(""), "") + + def test_the_wrapper_only_reboots_when_asked(self): + import inspect + + src = inspect.getsource(mon._launch_one) + self.assertIn("_reboot_steps(log_q, reboot) if reboot else", src) + + class TestTroisVmSurUnProxmox(unittest.TestCase): """Rapporté à l'usage : sur trois VM d'un même Proxmox, une seule avait ses colonnes vides — et les deux autres montraient les chiffres d'une From b5cd6ca0d6723834372046df3b5c962d011fc422 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Tue, 25 Aug 2026 02:53:16 -0400 Subject: [PATCH 12/12] =?UTF-8?q?[UPD]=20changelog=20:=20les=2067=20commit?= =?UTF-8?q?s=20qui=20s=C3=A9parent=20develop=20de=20master?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le journal ne disait rien de Proxmox, ni de l'anonymisation, ni de l'éclatement de todo.py — soit l'essentiel de ce qui attend un merge dans master. Une relecture de 67 commits ne devrait pas commencer par lire 67 commits. Les entrées suivent les six segments du plan de merge : ce qui précède la factorisation, la factorisation elle-même, l'écran Proxmox, les réparations de migration, l'audit, et le choix par VM. Une section Modifié apparaît, qui manquait à Non publié : un déplacement de 9 500 lignes n'est ni un ajout ni un correctif, et le classer ailleurs aurait menti sur sa nature. --- EN --- The log said nothing of Proxmox, nor of the anonymisation, nor of todo.py being split — that is most of what is waiting to be merged into master. A review of 67 commits should not have to start by reading 67 commits. The entries follow the six segments of the merge plan: what precedes the split, the split itself, the Proxmox screen, the migration repairs, the audit, and the per-VM choice. A Changed section appears, which Unreleased lacked: moving 9 500 lines is neither an addition nor a fix, and filing it elsewhere would have misstated what it is. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 48 +++++++++++++++++++++++++++++++++++++++++++++++ CHANGELOG.fr.md | 22 ++++++++++++++++++++++ CHANGELOG.md | 22 ++++++++++++++++++++++ 3 files changed, 92 insertions(+) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index aad19f5..c1c52df 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -112,6 +112,13 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - virt-viewer opens a VM screen from the menu - The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent - A VM greets its SSH login with the distribution's own commands +- Proxmox VE as a deployment target: deploy a VM on a remote Proxmox host from a screen that recaps in the terminal before creating, creates the missing bridge, follows the VM and changes its state, shows its Odoo column and the web link, and deletes it from the host. Every remote VM now comes with its connection guide, which was missing from the start +- Repairs for what an Odoo bump leaves behind: website copies that no longer render — predicted BEFORE the bump rather than found as a 500 after it — the indexes Odoo 17 creates twice, and the settings no event restores +- Analyses that read a database rather than count it: which module depends on which, on screen; what is installed, in progress and applied; the state of an instance read for the use made of it; and the examination of a database that is not local +- Anonymising a copy without AI: a production copy exists to reproduce a defect, so identifiers stay consistent across tables once the names are replaced +- Per-VM statistics: writes, RAM and disk +- Choosing the database at startup without ever blocking on the choice +- The reboot is part of installing Proxmox VE: install_proxmox.sh lays down the kernel and stops, rightly so, because launched over ssh a reboot would cut its session and make the install look failed. The reboot now belongs to the launching wrapper, which runs on OUR machine and survives the VM's — install, reboot, wait for `uname -r` to carry the Proxmox kernel, then verify. The ✅ is written only after that, so it means "usable hypervisor" @@ -188,6 +195,29 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - virt-viewer ouvre l'écran d'une VM depuis le menu - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution +- Proxmox VE comme cible de déploiement : déployer une VM sur un hôte Proxmox distant depuis un écran qui récapitule dans le terminal avant de créer, crée le pont manquant, suit la VM et change son état, montre sa colonne Odoo et le lien web, et la supprime depuis l'hôte. Toute VM distante vient désormais avec son guide de connexion, qui manquait depuis le début +- Des réparations pour ce qu'un palier Odoo laisse derrière : les copies de site qui ne savent plus se rendre — prédites AVANT le palier plutôt que découvertes en 500 après — les index qu'Odoo 17 crée en double, et les réglages qu'aucun événement ne remet +- Des analyses qui lisent une base plutôt que de la compter : qui dépend de qui, à l'écran ; ce qui est installé, en cours et appliqué ; l'état d'une instance lu pour l'usage qu'on en fait ; et l'auscultation d'une base qui n'est pas ici +- L'anonymisation d'une copie sans IA : une copie de production sert à reproduire un défaut, les identifiants restent donc cohérents entre les tables une fois les noms remplacés +- Les statistiques de chaque VM : écriture, RAM, disque +- Le choix de la base au démarrage, sans jamais bloquer sur ce choix +- Le redémarrage fait partie de l'installation de Proxmox VE : install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celle de la VM — installation, reboot, attente que « uname -r » porte le noyau Proxmox, puis vérification. Le ✅ ne s'écrit qu'après, et veut donc dire « hyperviseur utilisable » + + +## Changed + +## Modifié + + +- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes +- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently +- One shared base describes the guest system, where each form used to describe it again + + + +- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement +- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement +- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait ## Fixed @@ -198,11 +228,29 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for - The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails +- The 13-to-18 migration rested on assumptions: a percent-encoded page anchor the parser could not read, web_responsive that does not survive the bump to 18, a failed OpenUpgrade that passed for done, a rebuilt clone that kept the old one's preparation, and one faulty module taking the whole uninstall batch down +- Proxmox aimed at the wrong machine: the install went to the host instead of the VM, the disk it reported was the host's, and four screens spoke of a local machine while driving a remote one. The jump host is now the only route to a VM — aiming directly worked only while the VM had a routable address. Six further defects came from an audit rather than from use +- One name per `~/.ssh/config` entry, and the old one leaves with the convention that replaced it +- Monitoring no longer bins a VM before being sure, and deleting from a reopened monitor checks the VM's identity first +- sshfs announced a mount that had not happened +- Odoo 15 declares xlsxwriter, which report_xlsx has always needed +- The db_restore master-password probe validated nothing +- An analysis looked for the price list's external identifier rather than the list itself, and reported fields that had never held data +- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation - Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés - Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours +- La migration des paliers 13 à 18 tenait sur des suppositions : une ancre de page encodée en pourcent que l'analyseur ne lisait pas, web_responsive qui ne survit pas au passage en 18, un OpenUpgrade en échec qui passait pour fait, un clone rebâti qui gardait la préparation de l'ancien, et un module fautif qui emportait tout le lot de désinstallation +- Proxmox visait la mauvaise machine : l'installation partait sur l'hôte plutôt que sur la VM, le disque annoncé était celui de l'hôte, et quatre écrans parlaient d'une machine locale alors qu'ils pilotaient une machine distante. Le rebond est désormais le seul chemin vers une VM — viser directement ne marchait que tant que la VM avait une adresse routable. Six autres défauts sont venus d'un audit, pas de l'usage +- Un seul nom par entrée `~/.ssh/config`, et l'ancienne s'en va avec la convention qui l'a remplacée +- Le suivi ne met plus une VM à la poubelle avant d'en être sûr, et effacer depuis un suivi rouvert vérifie d'abord l'identité de la VM +- sshfs annonçait un montage qui n'avait pas eu lieu +- Odoo 15 déclare xlsxwriter, dont report_xlsx a toujours eu besoin +- La sonde du mot de passe maître de db_restore ne validait rien +- Une analyse cherchait l'identifiant externe de la liste de prix plutôt que la liste, et signalait des champs qui n'avaient jamais porté de donnée +- Le pont NAT s'écrivait avant de savoir si le NAT existe. Six lignes d'iptables et « code de retour 1 » arrivaient après que la strophe soit déjà posée dans /etc/network/interfaces, et rien dans ce bruit ne disait qu'il fallait redémarrer : l'hôte tournait le noyau cloud de Debian, dépouillé de netfilter. C'est notre propre install_proxmox.sh qui produit cet état, donc une Proxmox imbriquée fraîchement installée y est TOUJOURS — le garde va désormais là où la conséquence est, et non à la confirmation de l'hôte ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index f2d1db0..49284e2 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -88,12 +88,34 @@ Recréer l'environnement virtuel, utiliser le guide d'installation depuis l'outi - virt-viewer ouvre l'écran d'une VM depuis le menu - Le tableau de bord d'installation affiche la RAM de l'hôte, la RAM utilisée et l'uptime d'une VM, et depuis combien de temps un journal est muet - Une VM accueille sa connexion SSH avec les commandes propres à sa distribution +- Proxmox VE comme cible de déploiement : déployer une VM sur un hôte Proxmox distant depuis un écran qui récapitule dans le terminal avant de créer, crée le pont manquant, suit la VM et change son état, montre sa colonne Odoo et le lien web, et la supprime depuis l'hôte. Toute VM distante vient désormais avec son guide de connexion, qui manquait depuis le début +- Des réparations pour ce qu'un palier Odoo laisse derrière : les copies de site qui ne savent plus se rendre — prédites AVANT le palier plutôt que découvertes en 500 après — les index qu'Odoo 17 crée en double, et les réglages qu'aucun événement ne remet +- Des analyses qui lisent une base plutôt que de la compter : qui dépend de qui, à l'écran ; ce qui est installé, en cours et appliqué ; l'état d'une instance lu pour l'usage qu'on en fait ; et l'auscultation d'une base qui n'est pas ici +- L'anonymisation d'une copie sans IA : une copie de production sert à reproduire un défaut, les identifiants restent donc cohérents entre les tables une fois les noms remplacés +- Les statistiques de chaque VM : écriture, RAM, disque +- Le choix de la base au démarrage, sans jamais bloquer sur ce choix +- Le redémarrage fait partie de l'installation de Proxmox VE : install_proxmox.sh pose le noyau puis s'arrête, à raison, car lancé par ssh un reboot couperait sa session et ferait passer l'installation pour un échec. Le redémarrage revient à l'enveloppe de lancement, qui tourne sur NOTRE machine et survit à celle de la VM — installation, reboot, attente que « uname -r » porte le noyau Proxmox, puis vérification. Le ✅ ne s'écrit qu'après, et veut donc dire « hyperviseur utilisable » + +## Modifié + +- todo.py éclaté en neuf fichiers, un par sujet, avec un socle commun par formulaire. Il portait 9 500 lignes de plus qu'un fichier ne devrait et tous les sujets y passaient ; les formulaires de déploiement répétaient la même mécanique de champs et de validation, si bien qu'une correction dans l'un ne gagnait jamais les autres. Aucun changement de comportement +- La branche, le profil et le type se choisissent par VM. Ils étaient globaux, ce qui obligeait à tout basculer pour déployer une seule machine autrement +- Un socle commun décrit le système invité, là où chaque formulaire le redécrivait ## Corrigé - Le vérificateur du transfert mobile n'acceptait que la disposition en packs, quand une compilation réelle livre un tar.gz par dépôt. Il échouait sur ` : index.json absent` et arrêtait `compile_and_run.sh` avant l'APK — depuis le 2026-08-20, pour quiconque est sur le main mobile actuel. Il accepte désormais les deux dispositions, et prouve la présence de CHAQUE fichier promis plutôt qu'un échantillon de vingt : traverser les 139 archives coûte 6 s, et 124 350 fichiers sont comptés - Le test du bundle gardait la limite d'entrées du ZIP en exigeant un champ `chunk` sur chaque fichier, c'est-à-dire la disposition en packs plutôt que la limite elle-même. Il compte maintenant les entrées que portera l'APK — 278 pour un plafond de 65 535 — si bien que les deux dispositions passent et qu'un retour au fichier-par-source échoue toujours +- La migration des paliers 13 à 18 tenait sur des suppositions : une ancre de page encodée en pourcent que l'analyseur ne lisait pas, web_responsive qui ne survit pas au passage en 18, un OpenUpgrade en échec qui passait pour fait, un clone rebâti qui gardait la préparation de l'ancien, et un module fautif qui emportait tout le lot de désinstallation +- Proxmox visait la mauvaise machine : l'installation partait sur l'hôte plutôt que sur la VM, le disque annoncé était celui de l'hôte, et quatre écrans parlaient d'une machine locale alors qu'ils pilotaient une machine distante. Le rebond est désormais le seul chemin vers une VM — viser directement ne marchait que tant que la VM avait une adresse routable. Six autres défauts sont venus d'un audit, pas de l'usage +- Un seul nom par entrée `~/.ssh/config`, et l'ancienne s'en va avec la convention qui l'a remplacée +- Le suivi ne met plus une VM à la poubelle avant d'en être sûr, et effacer depuis un suivi rouvert vérifie d'abord l'identité de la VM +- sshfs annonçait un montage qui n'avait pas eu lieu +- Odoo 15 déclare xlsxwriter, dont report_xlsx a toujours eu besoin +- La sonde du mot de passe maître de db_restore ne validait rien +- Une analyse cherchait l'identifiant externe de la liste de prix plutôt que la liste, et signalait des champs qui n'avaient jamais porté de donnée +- Le pont NAT s'écrivait avant de savoir si le NAT existe. Six lignes d'iptables et « code de retour 1 » arrivaient après que la strophe soit déjà posée dans /etc/network/interfaces, et rien dans ce bruit ne disait qu'il fallait redémarrer : l'hôte tournait le noyau cloud de Debian, dépouillé de netfilter. C'est notre propre install_proxmox.sh qui produit cet état, donc une Proxmox imbriquée fraîchement installée y est TOUJOURS — le garde va désormais là où la conséquence est, et non à la confirmation de l'hôte ## Retiré - Le support d'Ubuntu 20.04 et 22.04, sur toutes les architectures : pikepdf réclame qpdf 12.2, dont la compilation exige C++20, quand focal livre GCC 9 et ne publie pas de `g++-10` pour s390x diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e4e37f..54a57d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -86,12 +86,34 @@ Recreating the virtual environment, use installation guide from tool `make`. - virt-viewer opens a VM screen from the menu - The install dashboard shows the host RAM, a VM's used RAM and uptime, and how long a log has been silent - A VM greets its SSH login with the distribution's own commands +- Proxmox VE as a deployment target: deploy a VM on a remote Proxmox host from a screen that recaps in the terminal before creating, creates the missing bridge, follows the VM and changes its state, shows its Odoo column and the web link, and deletes it from the host. Every remote VM now comes with its connection guide, which was missing from the start +- Repairs for what an Odoo bump leaves behind: website copies that no longer render — predicted BEFORE the bump rather than found as a 500 after it — the indexes Odoo 17 creates twice, and the settings no event restores +- Analyses that read a database rather than count it: which module depends on which, on screen; what is installed, in progress and applied; the state of an instance read for the use made of it; and the examination of a database that is not local +- Anonymising a copy without AI: a production copy exists to reproduce a defect, so identifiers stay consistent across tables once the names are replaced +- Per-VM statistics: writes, RAM and disk +- Choosing the database at startup without ever blocking on the choice +- The reboot is part of installing Proxmox VE: install_proxmox.sh lays down the kernel and stops, rightly so, because launched over ssh a reboot would cut its session and make the install look failed. The reboot now belongs to the launching wrapper, which runs on OUR machine and survives the VM's — install, reboot, wait for `uname -r` to carry the Proxmox kernel, then verify. The ✅ is written only after that, so it means "usable hypervisor" + +## Changed + +- todo.py split into nine files, one per subject, with a shared base per form. It carried 9 500 lines more than a file should and every subject went through it; the deployment forms repeated the same field-and-validation machinery, so a fix in one never reached the others. No behaviour changes +- Branch, profile and type are chosen per VM. They were global, which meant switching everything to deploy a single machine differently +- One shared base describes the guest system, where each form used to describe it again ## Fixed - The mobile bundle check accepted only the pack layout, when a real build ships one tar.gz per repository. It failed on ` : index.json absent` and stopped `compile_and_run.sh` before the APK — since 2026-08-20, for anyone on the current mobile main. It now accepts both layouts, and proves the presence of EVERY promised file rather than a sample of twenty: streaming all 139 archives costs 6 s, and 124 350 files are accounted for - The bundle test guarded the ZIP entry limit by demanding a `chunk` field on every file, which is the pack layout rather than the limit itself. It now counts the entries the APK will carry — 278 against a ceiling of 65 535 — so either layout passes and a return to file-per-source still fails +- The 13-to-18 migration rested on assumptions: a percent-encoded page anchor the parser could not read, web_responsive that does not survive the bump to 18, a failed OpenUpgrade that passed for done, a rebuilt clone that kept the old one's preparation, and one faulty module taking the whole uninstall batch down +- Proxmox aimed at the wrong machine: the install went to the host instead of the VM, the disk it reported was the host's, and four screens spoke of a local machine while driving a remote one. The jump host is now the only route to a VM — aiming directly worked only while the VM had a routable address. Six further defects came from an audit rather than from use +- One name per `~/.ssh/config` entry, and the old one leaves with the convention that replaced it +- Monitoring no longer bins a VM before being sure, and deleting from a reopened monitor checks the VM's identity first +- sshfs announced a mount that had not happened +- Odoo 15 declares xlsxwriter, which report_xlsx has always needed +- The db_restore master-password probe validated nothing +- An analysis looked for the price list's external identifier rather than the list itself, and reported fields that had never held data +- The NAT bridge was written before knowing whether NAT exists. Six lines of iptables and "return code 1" came after the stanza had already gone into /etc/network/interfaces, and nothing in that noise said a reboot was needed: the host was running Debian's cloud kernel, stripped of netfilter. Our own install_proxmox.sh produces that state, so a freshly installed nested Proxmox is ALWAYS in it — the guard now sits where the consequence is, not at host confirmation ## Removed - Ubuntu 20.04 and 22.04 support, on every architecture: pikepdf needs qpdf 12.2, whose build requires C++20, while focal ships GCC 9 and publishes no `g++-10` for s390x