[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:
parent
abb605aad3
commit
fe52ad9974
3 changed files with 238 additions and 25 deletions
|
|
@ -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 »
|
||||
clé Stripe présente 1 → 0
|
||||
|
||||
La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est
|
||||
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.
|
||||
Deux techniques, selon la version
|
||||
---------------------------------
|
||||
`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
|
||||
|
|
@ -63,9 +84,14 @@ REPO_ROOT = os.path.normpath(
|
|||
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
|
||||
|
||||
# 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):
|
||||
"""Le contenu d'un fichier `.xxx-version`, ou None."""
|
||||
|
|
@ -109,6 +135,18 @@ def supporte_neutralisation(version=_NON_FOURNI):
|
|||
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):
|
||||
"""Un nom de base acceptable — c'est aussi ce qui entre dans du SQL."""
|
||||
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
|
||||
# pas : le contenu d'avant n'existe plus nulle part.
|
||||
refus.append(f"{t('This database already exists: ')}{cible}")
|
||||
if neutraliser and not supporte_neutralisation():
|
||||
refus.append(
|
||||
f"{t('Odoo')} {lire_version('.odoo-version')}"
|
||||
f" {t('cannot neutralise: that arrived in Odoo 16.')}"
|
||||
)
|
||||
if neutraliser and technique_neutralisation() == NEUTRALISATION_SCRIPT:
|
||||
# PAS un refus : de 12 à 15 on passe par update_prod_to_dev.sh.
|
||||
# Le script doit exister, sinon la copie sortirait brute en se
|
||||
# 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
|
||||
|
||||
|
||||
def dupliquer(
|
||||
source, cible, neutraliser=False, config="config.conf", timeout=3600
|
||||
):
|
||||
"""Faire la copie. Rendre (code, sortie)."""
|
||||
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.")
|
||||
def neutraliser_par_script(cible, timeout=3600):
|
||||
"""La technique du dépôt, pour Odoo 12 à 15. Rendre (code, sortie).
|
||||
|
||||
`update_prod_to_dev.sh` installe puis désinstalle quatre modules
|
||||
maison. Il tourne depuis la racine du dépôt : les chemins qu'il
|
||||
contient sont relatifs, et l'appeler d'ailleurs le fait échouer sur
|
||||
`install_addons_dev.sh` introuvable.
|
||||
"""
|
||||
fait = subprocess.run(
|
||||
[python, "-c", script_python(source, cible, neutraliser, config)],
|
||||
["bash", SCRIPT_PROD_TO_DEV, cible],
|
||||
cwd=REPO_ROOT,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
|
|
@ -214,6 +252,41 @@ def dupliquer(
|
|||
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):
|
||||
parser = argparse.ArgumentParser(
|
||||
description=t("Duplicate a database, filestore included."),
|
||||
|
|
@ -234,10 +307,19 @@ def main(argv=None):
|
|||
print(f"❌ {ligne}", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
print(
|
||||
f"🧬 {args.source} → {args.database}"
|
||||
f"{' (' + t('neutralised') + ')' if args.neutralize else ''}"
|
||||
technique = technique_neutralisation()
|
||||
if args.neutralize:
|
||||
# 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(
|
||||
args.source, args.database, args.neutralize, args.config
|
||||
)
|
||||
|
|
@ -247,6 +329,12 @@ def main(argv=None):
|
|||
print(sortie[-2000:], file=sys.stderr)
|
||||
return 2
|
||||
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:
|
||||
print(
|
||||
f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}"
|
||||
|
|
|
|||
|
|
@ -10092,6 +10092,22 @@ TRANSLATIONS = {
|
|||
"fr": "ses fournisseurs de paiement peuvent débiter.",
|
||||
"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.",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -84,18 +84,50 @@ class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase):
|
|||
for version in (None, "", "quatorze"):
|
||||
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
|
||||
dup.bases_existantes = lambda: {"source"}
|
||||
vraie_version = dup.lire_version
|
||||
dup.lire_version = lambda nom: "15.0"
|
||||
try:
|
||||
refus = dup.verifier("source", "neuve", True)
|
||||
technique = dup.technique_neutralisation()
|
||||
finally:
|
||||
dup.bases_existantes = self._vrai
|
||||
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(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):
|
||||
|
|
@ -109,6 +141,83 @@ class TestTheProgramHandedToOdoo(unittest.TestCase):
|
|||
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):
|
||||
self.assertIn("'a', 'b', True", dup.script_python("a", "b", True, "c"))
|
||||
self.assertIn("'a', 'b')", dup.script_python("a", "b", False, "c"))
|
||||
|
|
|
|||
Loading…
Reference in a new issue