erplibre/script/database/db_duplicate.py

348 lines
12 KiB
Python
Raw Normal View History

#!/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
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
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 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."""
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 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:
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 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 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(
["bash", SCRIPT_PROD_TO_DEV, cible],
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 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."),
)
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
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
)
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 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')}"
f" {t('its payment providers can charge.')}"
)
return 0
if __name__ == "__main__":
sys.exit(main())