diff --git a/script/database/db_duplicate.py b/script/database/db_duplicate.py index 6cf3518..101a70d 100755 --- a/script/database/db_duplicate.py +++ b/script/database/db_duplicate.py @@ -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')}" diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 9466b5a..8f86596 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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.", + }, } diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py index 6865291..6fc2a9d 100644 --- a/test/test_db_duplicate.py +++ b/test/test_db_duplicate.py @@ -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"))