[FIX] database: de 12 à 15, neutraliser par le script plutôt que refuser

De 12 à 15 la neutralisation d'Odoo n'existe pas et la demande était refusée ;
le dépôt a pourtant sa technique de longue date, update_prod_to_dev.sh, et une
copie imparfaitement neutralisée vaut mieux qu'une copie brute. Le chemin suivi
est ANNONCÉ à l'exécution, car les deux ne posent pas les mêmes gestes : le
script ne pose pas is_neutralized, ne désactive pas les crons et laisse les
clés de paiement, là où il supprime les serveurs de courriel et pose un compte
de développement. Un seul cas reste refusé, le script introuvable, et son échec
fait échouer la copie : elle sortirait brute en s'annonçant neutralisée.

--- EN ---

From 12 to 15 Odoo's neutralisation does not exist and the request was refused;
yet the repository has long had its own technique, update_prod_to_dev.sh, and
an imperfectly neutralised copy beats a raw one. The route taken is ANNOUNCED
at run time, because the two do not make the same gestures: the script does not
set is_neutralized, does not disable crons and leaves the payment keys, where
it deletes the mail servers and sets up a development account. One case is
still refused, a missing script, and its failure fails the copy: it would come
out raw while announcing itself neutralised.

Assisted-by: Claude Opus 5
(cherry picked from commit bd7b648305a09c37adfe4b280e7479f1f178f20e)
This commit is contained in:
Mathieu Benoit 2026-08-25 04:12:04 -04:00
parent abb605aad3
commit fe52ad9974
3 changed files with 238 additions and 25 deletions

View file

@ -35,9 +35,30 @@ livrés par les modules INSTALLÉS. Mesuré sur une base migrée 12 → 18 :
serveurs de courriel 0 → 1 le bouchon « invalid » serveurs de courriel 0 → 1 le bouchon « invalid »
clé Stripe présente 1 → 0 clé Stripe présente 1 → 0
La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est Deux techniques, selon la version
absent des versions antérieures, et `exp_duplicate_database` n'y prend ---------------------------------
que deux arguments. On le DIT plutôt que d'ignorer la demande. `neutralize.py` n'existe qu'à partir d'Odoo 16 ; avant,
`exp_duplicate_database` ne prend que deux arguments. De 12 à 15 on
retombe donc sur la technique du dépôt : `update_prod_to_dev.sh`, qui
installe puis désinstalle `user_test`, `disable_mail_server`,
`disable_auto_backup` et `disable_payment_provider`.
Les deux n'obtiennent PAS la même chose, et il faut le savoir :
Odoo ≥ 16 script 12→15
database.is_neutralized posé non posé
crons désactivés oui non
serveur de courriel bouchon supprimés (*)
clés de paiement mises à laissées
NULL en place
compte test/test non oui
(*) supprimer tous les `ir.mail_server` fait retomber Odoo sur le
`smtp_server` du fichier de configuration ; le bouchon d'Odoo existe
précisément pour boucher ce trou-là.
La technique employée est ANNONCÉE à chaque exécution : une copie dont
on ignore par quel chemin elle est passée ne se juge pas.
""" """
from __future__ import annotations from __future__ import annotations
@ -63,9 +84,14 @@ REPO_ROOT = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
) )
# La neutralisation n'existe pas avant cette version. # La neutralisation d'Odoo n'existe pas avant cette version.
PREMIERE_VERSION_NEUTRALISABLE = 16.0 PREMIERE_VERSION_NEUTRALISABLE = 16.0
# Comment on neutralise, selon ce que la version sait faire.
NEUTRALISATION_ODOO = "odoo"
NEUTRALISATION_SCRIPT = "script"
SCRIPT_PROD_TO_DEV = "./script/addons/update_prod_to_dev.sh"
def lire_version(nom): def lire_version(nom):
"""Le contenu d'un fichier `.xxx-version`, ou None.""" """Le contenu d'un fichier `.xxx-version`, ou None."""
@ -109,6 +135,18 @@ def supporte_neutralisation(version=_NON_FOURNI):
return False return False
def technique_neutralisation(version=_NON_FOURNI):
"""Par quel chemin neutraliser, pour cette version.
On ne REFUSE plus sous 12→15 : le dépôt a sa technique, et une copie
imparfaitement neutralisée vaut mieux qu'une copie brute. Ce qui
compte est de dire laquelle a servi.
"""
if supporte_neutralisation(version):
return NEUTRALISATION_ODOO
return NEUTRALISATION_SCRIPT
def nom_valide(nom): def nom_valide(nom):
"""Un nom de base acceptable — c'est aussi ce qui entre dans du SQL.""" """Un nom de base acceptable — c'est aussi ce qui entre dans du SQL."""
if not nom or len(nom) > 63: if not nom or len(nom) > 63:
@ -185,25 +223,25 @@ def verifier(source, cible, neutraliser):
# Écraser une base sans le dire est la faute qu'on ne rattrape # Écraser une base sans le dire est la faute qu'on ne rattrape
# pas : le contenu d'avant n'existe plus nulle part. # pas : le contenu d'avant n'existe plus nulle part.
refus.append(f"{t('This database already exists: ')}{cible}") refus.append(f"{t('This database already exists: ')}{cible}")
if neutraliser and not supporte_neutralisation(): if neutraliser and technique_neutralisation() == NEUTRALISATION_SCRIPT:
refus.append( # PAS un refus : de 12 à 15 on passe par update_prod_to_dev.sh.
f"{t('Odoo')} {lire_version('.odoo-version')}" # Le script doit exister, sinon la copie sortirait brute en se
f" {t('cannot neutralise: that arrived in Odoo 16.')}" # croyant neutralisée — le seul cas vraiment dangereux.
) if not os.path.isfile(os.path.join(REPO_ROOT, SCRIPT_PROD_TO_DEV)):
refus.append(f"{t('Missing script: ')}{SCRIPT_PROD_TO_DEV}")
return refus return refus
def dupliquer( def neutraliser_par_script(cible, timeout=3600):
source, cible, neutraliser=False, config="config.conf", timeout=3600 """La technique du dépôt, pour Odoo 12 à 15. Rendre (code, sortie).
):
"""Faire la copie. Rendre (code, sortie).""" `update_prod_to_dev.sh` installe puis désinstalle quatre modules
python, racine = chemins_odoo() maison. Il tourne depuis la racine du dépôt : les chemins qu'il
if not python or not os.path.isfile(python): contient sont relatifs, et l'appeler d'ailleurs le fait échouer sur
return 2, t("Cannot find the virtualenv for this checkout.") `install_addons_dev.sh` introuvable.
if not racine or not os.path.isdir(racine): """
return 2, t("Cannot find the Odoo source for this checkout.")
fait = subprocess.run( fait = subprocess.run(
[python, "-c", script_python(source, cible, neutraliser, config)], ["bash", SCRIPT_PROD_TO_DEV, cible],
cwd=REPO_ROOT, cwd=REPO_ROOT,
capture_output=True, capture_output=True,
text=True, text=True,
@ -214,6 +252,41 @@ def dupliquer(
return 0, (fait.stdout or "").strip() return 0, (fait.stdout or "").strip()
def dupliquer(
source, cible, neutraliser=False, config="config.conf", timeout=3600
):
"""Faire la copie, puis la neutraliser par le chemin de la version.
La copie d'abord, la neutralisation ensuite : si la seconde échoue,
la base existe et l'on sait qu'elle est BRUTE. L'inverse — une base
à moitié neutralisée — ne se distinguerait pas d'une base traitée.
"""
python, racine = chemins_odoo()
if not python or not os.path.isfile(python):
return 2, t("Cannot find the virtualenv for this checkout.")
if not racine or not os.path.isdir(racine):
return 2, t("Cannot find the Odoo source for this checkout.")
par_odoo = neutraliser and (
technique_neutralisation() == NEUTRALISATION_ODOO
)
fait = subprocess.run(
[python, "-c", script_python(source, cible, par_odoo, config)],
cwd=REPO_ROOT,
capture_output=True,
text=True,
timeout=timeout,
)
if fait.returncode:
return fait.returncode, (fait.stderr or fait.stdout).strip()
if neutraliser and not par_odoo:
code, sortie = neutraliser_par_script(cible, timeout=timeout)
if code:
return code, sortie
return 0, (fait.stdout or "").strip()
def main(argv=None): def main(argv=None):
parser = argparse.ArgumentParser( parser = argparse.ArgumentParser(
description=t("Duplicate a database, filestore included."), description=t("Duplicate a database, filestore included."),
@ -234,10 +307,19 @@ def main(argv=None):
print(f"❌ {ligne}", file=sys.stderr) print(f"❌ {ligne}", file=sys.stderr)
return 2 return 2
print( technique = technique_neutralisation()
f"🧬 {args.source} → {args.database}" if args.neutralize:
f"{' (' + t('neutralised') + ')' if args.neutralize else ''}" # DIRE par quel chemin : les deux ne posent pas les mêmes gestes,
) # et une copie dont on ignore le traitement ne se juge pas.
comment = (
t("Odoo's own neutralisation")
if technique == NEUTRALISATION_ODOO
else f"{SCRIPT_PROD_TO_DEV} ({t('Odoo')}"
f" {lire_version('.odoo-version')})"
)
print(f"🧬 {args.source} → {args.database} — {comment}")
else:
print(f"🧬 {args.source} → {args.database}")
code, sortie = dupliquer( code, sortie = dupliquer(
args.source, args.database, args.neutralize, args.config args.source, args.database, args.neutralize, args.config
) )
@ -247,6 +329,12 @@ def main(argv=None):
print(sortie[-2000:], file=sys.stderr) print(sortie[-2000:], file=sys.stderr)
return 2 return 2
print(f"✅ {t('Done:')} {args.database}") print(f"✅ {t('Done:')} {args.database}")
if args.neutralize and technique == NEUTRALISATION_SCRIPT:
print(
f"ℹ️ {t('This route does not set database.is_neutralized, does')}"
f" {t('not disable crons, and leaves payment keys in place.')}"
)
print(f"ℹ️ {t('You can log in with test / test.')}")
if not args.neutralize: if not args.neutralize:
print( print(
f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}" f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}"

View file

@ -10092,6 +10092,22 @@ TRANSLATIONS = {
"fr": "ses fournisseurs de paiement peuvent débiter.", "fr": "ses fournisseurs de paiement peuvent débiter.",
"en": "its payment providers can charge.", "en": "its payment providers can charge.",
}, },
"Missing script: ": {
"fr": "Script introuvable : ",
"en": "Missing script: ",
},
"Odoo's own neutralisation": {
"fr": "la neutralisation d'Odoo lui-même",
"en": "Odoo's own neutralisation",
},
"This route does not set database.is_neutralized, does": {
"fr": "Ce chemin ne pose pas database.is_neutralized, ne",
"en": "This route does not set database.is_neutralized, does",
},
"not disable crons, and leaves payment keys in place.": {
"fr": "désactive pas les crons, et laisse les clés de paiement en place.",
"en": "not disable crons, and leaves payment keys in place.",
},
} }

View file

@ -84,18 +84,50 @@ class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase):
for version in (None, "", "quatorze"): for version in (None, "", "quatorze"):
self.assertFalse(dup.supporte_neutralisation(version)) self.assertFalse(dup.supporte_neutralisation(version))
def test_asking_for_it_on_an_old_checkout_is_refused(self): def test_an_old_checkout_falls_back_instead_of_refusing(self):
"""De 12 à 15, le dépôt a sa technique : on l'emploie."""
self._vrai = dup.bases_existantes self._vrai = dup.bases_existantes
dup.bases_existantes = lambda: {"source"} dup.bases_existantes = lambda: {"source"}
vraie_version = dup.lire_version vraie_version = dup.lire_version
dup.lire_version = lambda nom: "15.0" dup.lire_version = lambda nom: "15.0"
try: try:
refus = dup.verifier("source", "neuve", True) refus = dup.verifier("source", "neuve", True)
technique = dup.technique_neutralisation()
finally: finally:
dup.bases_existantes = self._vrai dup.bases_existantes = self._vrai
dup.lire_version = vraie_version dup.lire_version = vraie_version
self.assertEqual(
refus, [], "la demande a été refusée au lieu de basculer"
)
self.assertEqual(technique, dup.NEUTRALISATION_SCRIPT)
def test_the_route_is_chosen_by_the_version(self):
for version, attendu in (
("12.0", dup.NEUTRALISATION_SCRIPT),
("15.0", dup.NEUTRALISATION_SCRIPT),
("16.0", dup.NEUTRALISATION_ODOO),
("18.0", dup.NEUTRALISATION_ODOO),
):
self.assertEqual(
dup.technique_neutralisation(version), attendu, version
)
def test_a_missing_script_is_the_one_case_still_refused(self):
"""Sinon la copie sortirait brute en se croyant neutralisée."""
self._vrai = dup.bases_existantes
dup.bases_existantes = lambda: {"source"}
vraie_version = dup.lire_version
vrai_isfile = dup.os.path.isfile
dup.lire_version = lambda nom: "14.0"
dup.os.path.isfile = lambda chemin: False
try:
refus = dup.verifier("source", "neuve", True)
finally:
dup.bases_existantes = self._vrai
dup.lire_version = vraie_version
dup.os.path.isfile = vrai_isfile
self.assertTrue(refus) self.assertTrue(refus)
self.assertTrue(any("16" in r for r in refus)) self.assertTrue(any("update_prod_to_dev" in r for r in refus))
class TestTheProgramHandedToOdoo(unittest.TestCase): class TestTheProgramHandedToOdoo(unittest.TestCase):
@ -109,6 +141,83 @@ class TestTheProgramHandedToOdoo(unittest.TestCase):
code.index("parse_config"), code.index("exp_duplicate") code.index("parse_config"), code.index("exp_duplicate")
) )
def test_odoo_is_not_asked_to_neutralise_when_it_cannot(self):
"""Sous 12→15, exp_duplicate_database ne prend que deux arguments :
lui en passer un troisième ferait échouer la duplication."""
# `lire_version` sert AUSSI à composer le venv : lui faire dire
# « 14.0 » partout rendait le chemin introuvable et `dupliquer`
# sortait avant d'appeler quoi que ce soit — un test vert pour
# rien. On ne détourne donc que la version d'Odoo.
vraie = dup.lire_version
appels = []
dup.lire_version = lambda nom: (
"14.0" if nom == ".odoo-version" else vraie(nom)
)
vrais_chemins = dup.chemins_odoo
dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp")
vrai_run = dup.subprocess.run
class Faux:
returncode = 0
stdout = ""
stderr = ""
def espion(cmd, **kw):
appels.append(cmd)
return Faux()
dup.subprocess.run = espion
try:
dup.dupliquer("a", "b", neutraliser=True)
finally:
dup.lire_version = vraie
dup.chemins_odoo = vrais_chemins
dup.subprocess.run = vrai_run
programme = [c for c in appels if "-c" in c]
self.assertTrue(programme)
self.assertNotIn("True", programme[0][-1])
# …et le script du dépôt a bien été lancé ensuite.
self.assertTrue(
any(
dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, c)) for c in appels
)
)
def test_a_failing_script_makes_the_whole_thing_fail(self):
"""Le cas vraiment dangereux : la copie existe, le script a
échoué, et l'on annoncerait « neutralisée ». Avaler ce code de
retour rendrait une base brute indiscernable d'une base traitée.
"""
vraie = dup.lire_version
dup.lire_version = lambda nom: (
"14.0" if nom == ".odoo-version" else vraie(nom)
)
vrais_chemins = dup.chemins_odoo
dup.chemins_odoo = lambda: ("/usr/bin/python3", "/tmp")
vrai_run = dup.subprocess.run
class Reponse:
def __init__(self, code, sortie=""):
self.returncode = code
self.stdout = sortie
self.stderr = sortie
def espion(cmd, **kw):
# La duplication réussit, le script échoue.
if dup.SCRIPT_PROD_TO_DEV in " ".join(map(str, cmd)):
return Reponse(1, "install_addons_dev.sh a échoué")
return Reponse(0)
dup.subprocess.run = espion
try:
code, sortie = dup.dupliquer("a", "b", neutraliser=True)
finally:
dup.lire_version = vraie
dup.chemins_odoo = vrais_chemins
dup.subprocess.run = vrai_run
self.assertNotEqual(code, 0, "l'échec du script a été avalé")
self.assertIn("install_addons_dev", sortie)
def test_neutralisation_is_passed_only_when_asked(self): def test_neutralisation_is_passed_only_when_asked(self):
self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c")) self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c"))
self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c")) self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c"))