260 lines
8.8 KiB
Python
260 lines
8.8 KiB
Python
|
|
#!/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())
|