From e8ab56a0dfef97be277d1201e75d4592bc71a12a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Mon, 24 Aug 2026 05:29:38 -0400 Subject: [PATCH] [FIX] migration: un module fautif n'emporte plus tout le lot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « --uninstall » prend une liste virgulée et Odoo annule la transaction entière au premier échec : soit tout part, soit rien. Mesuré sur une chaîne 12 → 18 — crm_phone échoue sur une colonne absente de res_users et fait tomber les 22 autres avec lui, dont huit modules maison sans code en 13. Ceux-là sont alors montés d'un palier « installed » sans rien pour les charger, et ne sont partis que trois paliers plus loin, par accident. Le lot part toujours d'abord — un démarrage d'Odoo par nom se paierait cher pour rien. Mais s'il échoue, on reprend un par un : ce qui peut partir part, et l'on nomme ce qui résiste. S'ajoute la liste 12 → 13, qui n'existait pas : sans elle l'étape « Uninstall module » n'avait rien à faire. --- EN --- « --uninstall » takes a comma list and Odoo rolls the whole transaction back on the first failure: all or nothing. Measured on a 12 → 18 chain — crm_phone fails on a column missing from res_users and drags the other 22 down with it, including eight in-house modules with no code in 13. Those rode a step up « installed » with nothing to load them, and only left three steps later, by accident. The batch still goes first — one Odoo start per name would cost dearly for nothing. But if it fails, we retry one by one: what can leave leaves, and we name what resists. Plus the 12 → 13 list, which did not exist: without it the « Uninstall module » step had nothing to do. Assisted-by: Claude Opus 5 --- ...install_module_list_odoo120_to_odoo130.txt | 29 +++++ script/todo/todo_i18n.py | 4 + script/todo/todo_upgrade.py | 32 +++++ test/test_uninstall_verified.py | 111 ++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 script/odoo/migration/uninstall_module_list_odoo120_to_odoo130.txt diff --git a/script/odoo/migration/uninstall_module_list_odoo120_to_odoo130.txt b/script/odoo/migration/uninstall_module_list_odoo120_to_odoo130.txt new file mode 100644 index 0000000..a7a6ce9 --- /dev/null +++ b/script/odoo/migration/uninstall_module_list_odoo120_to_odoo130.txt @@ -0,0 +1,29 @@ +# Modules à retirer AVANT de monter de 12.0 vers 13.0. +# +# Un module par ligne, une justification après « # ». + +# Les huit n'ont de code qu'en 12 : `find odoo{13..18}.0 -name ` ne +# rend rien. Sans cette liste, l'étape « Uninstall module » n'a rien à +# faire et ils montent en 13 « installed » sans rien pour les charger. +# Odoo les écarte alors du graphe — « not installable, skipped » — et +# signale des états incohérents ; le ménage ne vient qu'en fin de palier, +# par database_cleanup, qui s'arrête à mi-course : les données partent, +# l'état reste « to remove ». Or « --uninstall » ne cherche que +# « installed » et sort 0 sans rien faire sur « to remove » : le module +# ne se retire alors plus JAMAIS. C'est ainsi que muk_web_theme a +# traversé quatre paliers en étant réputé parti. +# +# Mesuré sur une copie de test_neutralize avec le code de la 12 : les +# huit se retirent en un appel, 139 modules installés → 131, et le diff +# de ir_module_module ne montre que ces huit lignes. Aucune donnée +# d'affaires ne part — les tables sinerkia sont vides, aucune page ne +# pose les snippets maison, et le fuseau America/Montreal survit dans +# ir_default, que la désinstallation ne touche pas. +erplibre_base # code absent dès la 13 +erplibre_website_snippets_basic_html # code absent dès la 13 +erplibre_website_snippets_cards # code absent dès la 13 +erplibre_website_snippets_jitsi # code absent dès la 13 +erplibre_website_snippets_structures # code absent dès la 13 +erplibre_website_snippets_timelines # code absent dès la 13 +partner_quebec_tz # code absent dès la 13 ; le fuseau reste dans ir_default +sinerkia_jitsi_meet # code absent dès la 13 ; ses tables sont vides diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 5095dce..c68d2c0 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -1756,6 +1756,10 @@ TRANSLATIONS = { "fr": "Rien de désinstallé.", "en": "Nothing uninstalled.", }, + "Retrying alone:": { + "fr": "Reprise seul :", + "en": "Retrying alone:", + }, "The uninstall did not take:": { "fr": "La désinstallation n'a pas pris :", "en": "The uninstall did not take:", diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 276933c..e33b413 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -3875,6 +3875,17 @@ class TodoUpgrade: ) lst_left = self.still_installed(database_name, lst_module_to_uninstall) + # UN SEUL nom fautif emporte tout le lot : « --uninstall » prend + # une liste, et Odoo annule la transaction entière au premier + # échec. Mesuré sur une chaîne 12 → 18 : `crm_phone` échoue sur + # une colonne absente de res_users et fait tomber les 22 autres + # avec lui — dont huit modules sans code en 13, qui sont alors + # montés d'un palier « installed » sans rien pour les charger. + # + # On reprend donc un par un : ce qui peut partir part, et l'on + # nomme précisément ce qui résiste. + if lst_left and len(lst_module_to_uninstall) > 1: + lst_left = self.uninstall_one_by_one(database_name, lst_left) if lst_left is None: # Base illisible : on ne sait pas. Le dire, plutôt que de trancher. print(f"⚠️ {t('Could not verify the uninstall.')}") @@ -3901,6 +3912,27 @@ class TodoUpgrade: ) self.write_config() + def uninstall_one_by_one(self, database_name, lst_module): + """Reprendre module par module, et rendre ce qui résiste encore. + + Appelée seulement quand le lot a échoué : une commande par nom + coûte un démarrage d'Odoo chacune, ce qu'on ne paie pas pour + rien. Ce qu'on y gagne est qu'un module fautif cesse de protéger + les autres. + """ + reste = [] + for nom in lst_module: + print(f"⧖ {t('Retrying alone:')} {nom}") + self.todo_upgrade_execute( + f"./script/addons/uninstall_addons.sh {database_name} {nom}", + single_source_odoo=True, + wait_at_error=False, + ) + encore = self.still_installed(database_name, [nom]) + if encore is None or encore: + reste.append(nom) + return reste + def install_from_database( self, lst_module_to_install, database_name, actual_version ): diff --git a/test/test_uninstall_verified.py b/test/test_uninstall_verified.py index 95a7fc2..b3f3ccd 100644 --- a/test/test_uninstall_verified.py +++ b/test/test_uninstall_verified.py @@ -14,6 +14,7 @@ alors qu'il était « installed » de la 15 à la 18. import ast import io import os +import re import sys import unittest @@ -349,5 +350,115 @@ class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase): self.assertIn("write_config", corps) +class FauxPiloteParNom(FauxPilote): + """Un faux qui reproduit le VRAI mécanisme du lot. + + « --uninstall » prend une liste virgulée, et Odoo annule la + transaction entière au premier échec : soit tout part, soit rien. + C'est cela qu'il faut simuler — un faux où chaque nom part + indépendamment ne montrerait jamais le défaut. + """ + + def __init__(self, fautifs=()): + super().__init__() + self.fautifs = set(fautifs) + self.partis = set() + self.tentes = [] + + def todo_upgrade_execute(self, cmd, **kwargs): + self.commandes.append(cmd) + if kwargs.get("get_output"): + # `still_installed` : ce qu'on demande et qui n'est pas parti. + # + # Les noms se lisent dans la clause `IN (…)` SEULE : une + # capture large ramassait `'uninstalled'` du `state <>` et + # le faux tentait alors de désinstaller un module de ce nom. + dedans = re.search(r"name IN \(([^)]*)\)", cmd) + demandes = re.findall( + r"'([^']+)'", dedans.group(1) if dedans else "" + ) + return 0, cmd, [n for n in demandes if n not in self.partis] + if "uninstall_addons.sh" in cmd: + noms = cmd.rsplit(" ", 1)[-1].split(",") + self.tentes.append(list(noms)) + if not any(n in self.fautifs for n in noms): + self.partis.update(noms) + return 0, cmd + + +class TestOneBadNameNoLongerProtectsTheOthers(unittest.TestCase): + """Un seul module fautif emportait tout le lot. + + « --uninstall » prend une liste et Odoo annule la transaction + entière au premier échec. Mesuré sur une chaîne 12 → 18 : + `crm_phone` échoue sur une colonne absente de res_users et fait + tomber les 22 autres — dont huit modules sans code en 13, qui sont + alors montés d'un palier « installed » sans rien pour les charger. + """ + + def pilote(self, fautifs, tous): + p = FauxPiloteParNom(fautifs) + p.dct_module_per_version = {12: list(tous)} + return p + + def test_the_healthy_ones_leave_even_when_one_resists(self): + tous = ["bon_a", "crm_phone", "bon_b"] + p = self.pilote(["crm_phone"], tous) + p.uninstall_from_database(tous, "db", 12) + # Le lot d'abord, puis chacun seul… + self.assertEqual(tous, p.tentes[0]) + self.assertEqual( + sorted(n for lot in p.tentes[1:] for n in lot), + ["bon_a", "bon_b", "crm_phone"], + ) + # …et la comptabilité ne garde que le fautif. + self.assertEqual(["crm_phone"], p.dct_module_per_version[12]) + + def test_the_one_that_resists_is_named(self): + tous = ["bon_a", "crm_phone"] + p = self.pilote(["crm_phone"], tous) + p.uninstall_from_database(tous, "db", 12) + trace = " ".join(p.commentaires) + self.assertIn("crm_phone", trace) + self.assertNotIn("bon_a", trace) + + def test_a_lone_module_is_not_tried_twice(self): + # La reprise coûte un démarrage d'Odoo par nom : on ne la paie + # pas pour rejouer exactement la même commande. + p = self.pilote(["seul"], ["seul"]) + p.uninstall_from_database(["seul"], "db", 12) + lancements = [c for c in p.commandes if "uninstall_addons.sh" in c] + self.assertEqual(1, len(lancements), lancements) + + def test_a_successful_batch_never_retries(self): + tous = ["bon_a", "bon_b"] + p = self.pilote([], tous) + p.uninstall_from_database(tous, "db", 12) + lancements = [c for c in p.commandes if "uninstall_addons.sh" in c] + self.assertEqual(1, len(lancements), lancements) + + def test_the_retry_does_not_open_the_error_menu(self): + # Un module qui refuse de partir est une trouvaille, pas une + # panne du pilote : s'arrêter là ferait échouer la migration + # entière pour un module que l'on nomme déjà. + import ast + + with io.open( + os.path.join(RACINE, "script", "todo", "todo_upgrade.py"), + encoding="utf-8", + ) as handle: + arbre = ast.parse(handle.read()) + for noeud in ast.walk(arbre): + if ( + isinstance(noeud, ast.FunctionDef) + and noeud.name == "uninstall_one_by_one" + ): + corps = ast.dump(ast.Module(body=noeud.body, type_ignores=[])) + self.assertIn("wait_at_error", corps) + self.assertIn("value=False", corps) + return + self.fail("uninstall_one_by_one introuvable") + + if __name__ == "__main__": unittest.main()