La duplication passe par exp_duplicate_database d'Odoo plutôt que par CREATE DATABASE ... TEMPLATE : lui seul coupe les connexions de la source, régénère database.uuid, copie le filestore et neutralise. Les trois modules maison du dépôt n'obtenaient aucun des quatre. Sous Odoo 15 et avant la neutralisation n'existe pas : la demande est REFUSÉE, jamais ignorée — une copie qu'on croit neutralisée est pire qu'une brute. Vérifié sur la copie d'une base migrée 12 → 18 : is_neutralized posé, crons réduits au seul autovacuum, clé de paiement effacée, filestore copié. --- EN --- Duplication goes through Odoo's exp_duplicate_database rather than CREATE DATABASE ... TEMPLATE: only it drops the source's connections, regenerates database.uuid, copies the filestore and neutralises. The repository's three in-house modules obtained none of the four. Under Odoo 15 and earlier neutralisation does not exist: the request is REFUSED, never ignored — a copy believed neutralised is worse than a raw one. Checked on the copy of a 12 → 18 migrated database: is_neutralized set, crons reduced to autovacuum alone, payment key cleared, filestore copied. Assisted-by: Claude Opus 5 (cherry picked from commit af99d450c12e70a891d01d0ccf556c1d560e943b)
259 lines
8.8 KiB
Python
Executable file
259 lines
8.8 KiB
Python
Executable file
#!/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())
|