diff --git a/script/database/db_duplicate.py b/script/database/db_duplicate.py new file mode 100755 index 0000000..6cf3518 --- /dev/null +++ b/script/database/db_duplicate.py @@ -0,0 +1,259 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer une base, et la neutraliser si on le demande. + +Pourquoi passer par Odoo plutôt que par `CREATE DATABASE … TEMPLATE` +------------------------------------------------------------------- +La commande PostgreSQL copie les tables, et RIEN d'autre. Odoo, lui, fait +quatre choses de plus que `exp_duplicate_database` réunit : + +1. Il coupe les connexions ouvertes sur la source. Sans cela PostgreSQL + refuse : « is being accessed by other users » — un shell Odoo laissé + ouvert suffit à faire échouer la copie. +2. Il RÉGÉNÈRE le `database.uuid`. Deux bases qui partagent le leur se + présentent comme la même instance au service de garantie d'Odoo. +3. Il copie le FILESTORE. Sans lui, chaque pièce jointe de la copie + pointe vers un fichier qui n'existe pas — 57 Mo perdus en silence sur + la base qui a servi à écrire ceci. +4. Il neutralise pour de bon, quand on le demande. + +Ce que « neutraliser » veut dire, mesuré +---------------------------------------- +Le dépôt a trois modules maison — `disable_mail_server`, +`disable_auto_backup`, `disable_payment_provider` — qui font trois gestes. +Aucun ne pose le drapeau, et l'un d'eux ouvre une porte : supprimer tous +les `ir.mail_server` fait retomber Odoo sur le `smtp_server` du fichier +de configuration. + +`neutralize_database` d'Odoo exécute les 73 fichiers `neutralize.sql` +livrés par les modules INSTALLÉS. Mesuré sur une base migrée 12 → 18 : + + database.is_neutralized ABSENT → true + crons actifs 33 → 1 (l'autovacuum, voulu) + 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. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +try: + from script.todo.todo_i18n import t +except Exception: # pragma: no cover - repli si i18n indisponible + + def t(key: str) -> str: + return key + + +REPO_ROOT = os.path.normpath( + os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..") +) + +# La neutralisation n'existe pas avant cette version. +PREMIERE_VERSION_NEUTRALISABLE = 16.0 + + +def lire_version(nom): + """Le contenu d'un fichier `.xxx-version`, ou None.""" + chemin = os.path.join(REPO_ROOT, nom) + try: + with open(chemin, "r", encoding="utf-8") as handle: + return handle.read().strip() or None + except OSError: + return None + + +def chemins_odoo(): + """(python du venv, racine d'Odoo) — résolus comme odoo_bin.sh le fait. + + Le nom du venv porte les DEUX versions, Odoo et Python : le composer + de tête revient à se tromper un jour sur l'une des deux. + """ + erplibre = lire_version(".erplibre-version") + odoo = lire_version(".odoo-version") + if not erplibre or not odoo: + return None, None + python = os.path.join(REPO_ROOT, f".venv.{erplibre}", "bin", "python") + racine = os.path.join(REPO_ROOT, f"odoo{odoo}", "odoo") + return python, racine + + +# « rien n'a été passé » et « on m'a passé une version illisible » sont +# deux questions différentes, et un `None` ne doit pas se faire prendre +# pour l'autre : l'appelant dont la lecture a échoué se verrait répondre +# sur la version du checkout, c'est-à-dire sur autre chose. +_NON_FOURNI = object() + + +def supporte_neutralisation(version=_NON_FOURNI): + """La version sait-elle neutraliser ? Sans argument : celle du checkout.""" + if version is _NON_FOURNI: + version = lire_version(".odoo-version") + try: + return float(version) >= PREMIERE_VERSION_NEUTRALISABLE + except (TypeError, ValueError): + return False + + +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: + return False + if not (nom[0].isalpha() or nom[0] == "_"): + return False + return all(c.isalnum() or c in "_-$" for c in nom) + + +def script_python(source, cible, neutraliser, config): + """Le programme qu'on remet à l'interpréteur d'Odoo. + + `parse_config` est indispensable : sans lui, `Registry.new` ne sait ni + où se connecter ni où sont les addons, et la duplication échoue après + avoir DÉJÀ créé la base. + """ + return ( + "import sys\n" + "sys.path.insert(0, %r)\n" + "import odoo\n" + "odoo.tools.config.parse_config(['-c', %r])\n" + "from odoo.service.db import exp_duplicate_database\n" + "exp_duplicate_database(%r, %r%s)\n" + % ( + chemins_odoo()[1], + config, + source, + cible, + ", True" if neutraliser else "", + ) + ) + + +def bases_existantes(): + """Les bases que PostgreSQL expose, ou None si on n'a pas pu demander.""" + try: + fait = subprocess.run( + [ + "psql", + "-X", + "-w", + "-tA", + "-d", + "postgres", + "-c", + "SELECT datname FROM pg_database WHERE NOT datistemplate", + ], + capture_output=True, + text=True, + timeout=60, + ) + except (OSError, subprocess.SubprocessError): + return None + if fait.returncode: + return None + return { + ligne.strip() for ligne in fait.stdout.splitlines() if ligne.strip() + } + + +def verifier(source, cible, neutraliser): + """Ce qui empêche de commencer. Rendre la liste des refus.""" + refus = [] + for nom, role in ((source, "source"), (cible, "cible")): + if not nom_valide(nom): + refus.append(f"{t('Invalid database name: ')}{nom!r} ({role})") + if source == cible: + refus.append(t("The source and the copy cannot share a name.")) + bases = bases_existantes() + if bases is not None: + if source not in bases: + refus.append(f"{t('No such database: ')}{source}") + if cible in bases: + # É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.')}" + ) + 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.") + fait = subprocess.run( + [python, "-c", script_python(source, cible, neutraliser, config)], + cwd=REPO_ROOT, + capture_output=True, + text=True, + timeout=timeout, + ) + if fait.returncode: + return fait.returncode, (fait.stderr or fait.stdout).strip() + return 0, (fait.stdout or "").strip() + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=t("Duplicate a database, filestore included."), + ) + parser.add_argument("-s", "--source", required=True) + parser.add_argument("-d", "--database", required=True, help=t("the copy")) + parser.add_argument("-c", "--config", default="config.conf") + parser.add_argument( + "--neutralize", + action="store_true", + help=t("disable crons, outgoing mail and payment providers"), + ) + args = parser.parse_args(argv) + + refus = verifier(args.source, args.database, args.neutralize) + if refus: + for ligne in refus: + print(f"❌ {ligne}", file=sys.stderr) + return 2 + + print( + f"🧬 {args.source} → {args.database}" + f"{' (' + t('neutralised') + ')' if args.neutralize else ''}" + ) + code, sortie = dupliquer( + args.source, args.database, args.neutralize, args.config + ) + if code: + print(f"❌ {t('The duplication failed.')}", file=sys.stderr) + if sortie: + print(sortie[-2000:], file=sys.stderr) + return 2 + print(f"✅ {t('Done:')} {args.database}") + if not args.neutralize: + print( + f"⚠️ {t('NOT neutralised: its crons run, its mail leaves and')}" + f" {t('its payment providers can charge.')}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/script/todo/database_manager.py b/script/todo/database_manager.py index a6e51ba..cee81cf 100644 --- a/script/todo/database_manager.py +++ b/script/todo/database_manager.py @@ -199,6 +199,76 @@ class DatabaseManager: source_erplibre=False, ) + def duplicate_database(self) -> None: + """Copier une base, et proposer de la neutraliser. + + On passe par `db_duplicate.py`, donc par Odoo, et non par un + `CREATE DATABASE … TEMPLATE` : lui seul coupe les connexions + ouvertes sur la source, régénère le `database.uuid`, copie le + filestore et sait neutraliser pour de bon. + + La neutralisation est proposée par DÉFAUT. Mesuré sur trois + migrations de suite : la copie gardait 33 crons actifs, aucun + serveur de courriel — donc le repli sur `smtp_server` de la + configuration — et une clé de paiement vivante. Le défaut à + « oui » est le seul qui protège celui qui appuie sur Entrée. + """ + source = self.select_database() + if not source: + return + defaut = f"{source}_neutralize" + cible = input( + f"\U0001f4ac {t('Name of the copy (default=')}{defaut}) : " + ).strip() + cible = cible or defaut + + reponse = ( + input(f"\U0001f4ac {t('Neutralize the copy (Y/n)? ')}") + .strip() + .lower() + ) + neutraliser = reponse != "n" + if not neutraliser: + print( + f"⚠️ {t('The copy will keep its scheduled actions, its')}" + f" {t('outgoing mail and its payment providers.')}" + ) + + commande = ( + f"python3 ./script/database/db_duplicate.py" + f" -s {source} -d {cible}" + ) + if neutraliser: + commande += " --neutralize" + status, _ = self._execute.exec_command_live( + commande, + return_status_and_output=True, + single_source_erplibre=True, + source_erplibre=False, + ) + if status: + print(f"❌ {t('The duplication failed.')}") + return + # RELIRE plutôt que croire : c'est le contrôle qui manquait aux + # trois modules maison, dont aucun ne posait le drapeau. + if neutraliser: + self._report_neutralize(cible) + + @staticmethod + def _report_neutralize(database: str) -> None: + """Dire ce que la neutralisation a réellement pris.""" + try: + from script.analyse import monitoring + + print() + print( + monitoring.neutralize_report( + monitoring.neutralize_state(database), colour=True + ) + ) + except Exception as exc: # noqa: BLE001 - un rapport, pas le sujet + print(f"ℹ️ {t('Cannot read the copy back: ')}{exc}") + def create_backup_from_database( self, show_remote_list: bool = True ) -> None: diff --git a/script/todo/todo.py b/script/todo/todo.py index 0a5fb21..08fca71 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -2893,6 +2893,8 @@ class TODO( }, {"section": t("Restore")}, {"prompt_description": t("Restore from backup (.zip)")}, + {"section": t("Duplicate")}, + {"prompt_description": t("Duplicate a database")}, {"section": t("Danger zone")}, {"prompt_description": t("Erase a database")}, ] @@ -2910,6 +2912,8 @@ class TODO( elif status == "3": self.db_manager.restore_from_database() elif status == "4": + self.db_manager.duplicate_database() + elif status == "5": self.db_manager.drop_database() else: print(t("Command not found !")) diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index d12f08e..9466b5a 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -10008,6 +10008,90 @@ TRANSLATIONS = { "fr": "Mode inconnu :", "en": "Unknown mode:", }, + "Duplicate": { + "fr": "Copie", + "en": "Copy", + }, + "Duplicate a database": { + "fr": "Dupliquer une base de données", + "en": "Duplicate a database", + }, + "Name of the copy (default=": { + "fr": "Nom de la copie (défaut=", + "en": "Name of the copy (default=", + }, + "Neutralize the copy (Y/n)? ": { + "fr": "Neutraliser la copie (O/n) ? ", + "en": "Neutralize the copy (Y/n)? ", + }, + "The copy will keep its scheduled actions, its": { + "fr": "La copie gardera ses actions planifiées, son", + "en": "The copy will keep its scheduled actions, its", + }, + "outgoing mail and its payment providers.": { + "fr": "courriel sortant et ses fournisseurs de paiement.", + "en": "outgoing mail and its payment providers.", + }, + "The duplication failed.": { + "fr": "La duplication a échoué.", + "en": "The duplication failed.", + }, + "Cannot read the copy back: ": { + "fr": "Impossible de relire la copie : ", + "en": "Cannot read the copy back: ", + }, + "Duplicate a database, filestore included.": { + "fr": "Dupliquer une base, filestore compris.", + "en": "Duplicate a database, filestore included.", + }, + "the copy": { + "fr": "la copie", + "en": "the copy", + }, + "disable crons, outgoing mail and payment providers": { + "fr": "désactiver les crons, le courriel sortant et les fournisseurs de paiement", + "en": "disable crons, outgoing mail and payment providers", + }, + "The source and the copy cannot share a name.": { + "fr": "La source et la copie ne peuvent pas porter le même nom.", + "en": "The source and the copy cannot share a name.", + }, + "No such database: ": { + "fr": "Base introuvable : ", + "en": "No such database: ", + }, + "This database already exists: ": { + "fr": "Cette base existe déjà : ", + "en": "This database already exists: ", + }, + "cannot neutralise: that arrived in Odoo 16.": { + "fr": "ne sait pas neutraliser : c'est arrivé avec Odoo 16.", + "en": "cannot neutralise: that arrived in Odoo 16.", + }, + "Cannot find the virtualenv for this checkout.": { + "fr": "Venv introuvable pour ce checkout.", + "en": "Cannot find the virtualenv for this checkout.", + }, + "Cannot find the Odoo source for this checkout.": { + "fr": "Sources Odoo introuvables pour ce checkout.", + "en": "Cannot find the Odoo source for this checkout.", + }, + "neutralised": { + "fr": "neutralisée", + "en": "neutralised", + }, + "Done:": { + "fr": "Fait :", + "en": "Done:", + }, + "NOT neutralised: its crons run, its mail leaves and": { + "fr": "PAS neutralisée : ses crons tournent, son courriel part et", + "en": "NOT neutralised: its crons run, its mail leaves and", + }, + "its payment providers can charge.": { + "fr": "ses fournisseurs de paiement peuvent débiter.", + "en": "its payment providers can charge.", + }, } diff --git a/test/test_db_duplicate.py b/test/test_db_duplicate.py new file mode 100644 index 0000000..6865291 --- /dev/null +++ b/test/test_db_duplicate.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Dupliquer : ce qui se refuse AVANT de créer quoi que ce soit. + +Une duplication rate de deux façons. Elle peut échouer — c'est le cas +bénin, on recommence. Ou elle peut RÉUSSIR sur la mauvaise base : écraser +une copie qui contenait autre chose, ou rendre une copie qui n'est pas +neutralisée alors qu'on l'a demandé. Ces deux-là ne se rattrapent pas, et +c'est ce que ce fichier épingle. + +La neutralisation n'existe qu'à partir d'Odoo 16 : `neutralize.py` est +absent avant, et `exp_duplicate_database` n'y prend que deux arguments. +Demander la neutralisation sous Odoo 15 doit être REFUSÉ, jamais ignoré +en silence — une copie qu'on croit neutralisée est pire qu'une copie +qu'on sait brute. +""" + +import unittest + +from script.database import db_duplicate as dup + + +class TestWhatANameMayBe(unittest.TestCase): + """Le nom entre dans du SQL par `database_identifier` : il se filtre.""" + + def test_ordinary_names_pass(self): + for nom in ("chezlepro", "el_essai", "a", "base-2024", "_interne"): + self.assertTrue(dup.nom_valide(nom), nom) + + def test_a_name_that_could_carry_sql_is_refused(self): + for nom in ('a"; DROP DATABASE x; --', "a b", "a'b", "a;b", "a(b)"): + self.assertFalse(dup.nom_valide(nom), nom) + + def test_an_empty_or_oversized_name_is_refused(self): + self.assertFalse(dup.nom_valide("")) + self.assertFalse(dup.nom_valide(None)) + self.assertFalse(dup.nom_valide("x" * 64)) + self.assertTrue(dup.nom_valide("x" * 63)) + + def test_a_name_starting_with_a_digit_is_refused(self): + self.assertFalse(dup.nom_valide("2024_base")) + + +class TestWhatIsRefusedBeforeAnythingIsCreated(unittest.TestCase): + def setUp(self): + # On ne veut pas d'un PostgreSQL réel : le sujet est la décision, + # pas la connexion. `None` = « je n'ai pas pu demander », ce qui + # doit laisser passer plutôt que d'inventer un refus. + self._vrai = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + self.addCleanup(setattr, dup, "bases_existantes", self._vrai) + + def test_the_same_name_twice_is_refused(self): + refus = dup.verifier("source", "source", False) + self.assertTrue(any("nom" in r or "name" in r for r in refus)) + + def test_an_unknown_source_is_refused(self): + self.assertTrue(dup.verifier("pas_la", "neuve", False)) + + def test_overwriting_an_existing_database_is_refused(self): + """Le contenu d'avant n'existerait plus nulle part.""" + refus = dup.verifier("source", "deja_la", False) + self.assertTrue(refus) + self.assertTrue(any("deja_la" in r for r in refus)) + + def test_a_plain_duplication_passes(self): + self.assertEqual(dup.verifier("source", "neuve", False), []) + + def test_when_postgres_cannot_be_asked_we_do_not_invent_a_refusal(self): + dup.bases_existantes = lambda: None + self.assertEqual(dup.verifier("source", "neuve", False), []) + + +class TestNeutralisationIsRefusedBeforeOdoo16(unittest.TestCase): + def test_the_boundary_is_sixteen(self): + self.assertFalse(dup.supporte_neutralisation("15.0")) + self.assertTrue(dup.supporte_neutralisation("16.0")) + self.assertTrue(dup.supporte_neutralisation("18.0")) + + def test_an_unreadable_version_is_treated_as_unsupported(self): + """Dans le doute, refuser : le silence serait un faux calme.""" + for version in (None, "", "quatorze"): + self.assertFalse(dup.supporte_neutralisation(version)) + + def test_asking_for_it_on_an_old_checkout_is_refused(self): + 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) + finally: + dup.bases_existantes = self._vrai + dup.lire_version = vraie_version + self.assertTrue(refus) + self.assertTrue(any("16" in r for r in refus)) + + +class TestTheProgramHandedToOdoo(unittest.TestCase): + """Ce qu'on exécute, et surtout ce qu'on n'exécute pas par mégarde.""" + + def test_it_parses_the_config_before_duplicating(self): + """Sans parse_config, Registry.new échoue APRÈS avoir créé la base.""" + code = dup.script_python("a", "b", False, "config.conf") + self.assertIn("parse_config", code) + self.assertLess( + code.index("parse_config"), code.index("exp_duplicate") + ) + + 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")) + + def test_the_names_travel_as_python_literals(self): + """Ils sont interpolés dans du code : `repr` et rien d'autre.""" + code = dup.script_python("a'b", "c", False, "config.conf") + self.assertIn(repr("a'b"), code) + + def test_the_odoo_path_is_read_from_the_version_files(self): + """Composer le nom de tête, c'est se tromper un jour sur l'une + des deux versions qu'il porte.""" + import inspect + + source = inspect.getsource(dup.chemins_odoo) + self.assertIn(".erplibre-version", source) + self.assertIn(".odoo-version", source) + + +class TestTheContractOfTheCommandLine(unittest.TestCase): + def test_a_refusal_exits_two_and_creates_nothing(self): + appels = [] + vrai = dup.dupliquer + dup.dupliquer = lambda *a, **k: appels.append(a) or (0, "") + vraies_bases = dup.bases_existantes + dup.bases_existantes = lambda: {"source", "deja_la"} + try: + code = dup.main(["-s", "source", "-d", "deja_la"]) + finally: + dup.dupliquer = vrai + dup.bases_existantes = vraies_bases + self.assertEqual(code, 2) + self.assertEqual(appels, [], "la duplication a été tentée quand même") + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index f88855f..5c38677 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -380,6 +380,38 @@ class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase): } +class TestDatabaseMenuNumbering(MenuCoherence, unittest.TestCase): + """Le menu Database, qui manie des bases entières. + + Il n'avait aucun garde, et c'est celui où une renumérotation coûte le + plus cher : sa dernière entrée EFFACE une base. Insérer « Dupliquer » + avant elle décale « Effacer » de [4] à [5] — si le dispatch ne suit + pas, taper [4] efface au lieu de copier. + """ + + SOURCE = TODO_DIR / "todo.py" + ENTRY = "def prompt_execute_database(self):" + END = "def prompt_execute_analyse(self):" + MINIMUM = 4 + + # Ce menu délègue à `self.db_manager.methode()`, pas à `self.methode()`. + # Le motif du socle ne voit que la forme courte : sans cette surcharge + # il lit ZÉRO dispatch et ne compare plus rien — un garde qui passe au + # vert sans rien garder. + RE_DISPATCH_CALL = re.compile( + r'(?:el)?if status == "(\d+)":\s*\n(?:\s*#.*\n)*' + r"\s*(?:status = )?self\.(?:\w+\.)*(\w+)\(" + ) + + EXPECTED = { + "Create backup": "create_backup_from_database", + "Download database": "download_database_backup_cli", + "Restore from backup": "restore_from_database", + "Duplicate a database": "duplicate_database", + "Erase a database": "drop_database", + } + + class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase): """Le menu Proxmox : dix-huit entrées, le même piège.