[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é.",
|
||||
"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:",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue