[FIX] migration: un module fautif n'emporte plus tout le lot
« --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
This commit is contained in:
parent
07df33b3ff
commit
e8ab56a0df
4 changed files with 176 additions and 0 deletions
|
|
@ -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 <nom>` 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
|
||||||
|
|
@ -1756,6 +1756,10 @@ TRANSLATIONS = {
|
||||||
"fr": "Rien de désinstallé.",
|
"fr": "Rien de désinstallé.",
|
||||||
"en": "Nothing uninstalled.",
|
"en": "Nothing uninstalled.",
|
||||||
},
|
},
|
||||||
|
"Retrying alone:": {
|
||||||
|
"fr": "Reprise seul :",
|
||||||
|
"en": "Retrying alone:",
|
||||||
|
},
|
||||||
"The uninstall did not take:": {
|
"The uninstall did not take:": {
|
||||||
"fr": "La désinstallation n'a pas pris :",
|
"fr": "La désinstallation n'a pas pris :",
|
||||||
"en": "The uninstall did not take:",
|
"en": "The uninstall did not take:",
|
||||||
|
|
|
||||||
|
|
@ -3875,6 +3875,17 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
|
|
||||||
lst_left = self.still_installed(database_name, lst_module_to_uninstall)
|
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:
|
if lst_left is None:
|
||||||
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
|
# Base illisible : on ne sait pas. Le dire, plutôt que de trancher.
|
||||||
print(f"⚠️ {t('Could not verify the uninstall.')}")
|
print(f"⚠️ {t('Could not verify the uninstall.')}")
|
||||||
|
|
@ -3901,6 +3912,27 @@ class TodoUpgrade:
|
||||||
)
|
)
|
||||||
self.write_config()
|
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(
|
def install_from_database(
|
||||||
self, lst_module_to_install, database_name, actual_version
|
self, lst_module_to_install, database_name, actual_version
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ alors qu'il était « installed » de la 15 à la 18.
|
||||||
import ast
|
import ast
|
||||||
import io
|
import io
|
||||||
import os
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
|
|
@ -349,5 +350,115 @@ class TestDiscardingTheCloneDiscardsItsPreparation(unittest.TestCase):
|
||||||
self.assertIn("write_config", corps)
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue