De 12 à 15 la neutralisation d'Odoo n'existe pas et la demande était refusée ; le dépôt a pourtant sa technique de longue date, update_prod_to_dev.sh, et une copie imparfaitement neutralisée vaut mieux qu'une copie brute. Le chemin suivi est ANNONCÉ à l'exécution, car les deux ne posent pas les mêmes gestes : le script ne pose pas is_neutralized, ne désactive pas les crons et laisse les clés de paiement, là où il supprime les serveurs de courriel et pose un compte de développement. Un seul cas reste refusé, le script introuvable, et son échec fait échouer la copie : elle sortirait brute en s'annonçant neutralisée. --- EN --- From 12 to 15 Odoo's neutralisation does not exist and the request was refused; yet the repository has long had its own technique, update_prod_to_dev.sh, and an imperfectly neutralised copy beats a raw one. The route taken is ANNOUNCED at run time, because the two do not make the same gestures: the script does not set is_neutralized, does not disable crons and leaves the payment keys, where it deletes the mail servers and sets up a development account. One case is still refused, a missing script, and its failure fails the copy: it would come out raw while announcing itself neutralised. Assisted-by: Claude Opus 5 (cherry picked from commit bd7b648305a09c37adfe4b280e7479f1f178f20e)
347 lines
12 KiB
Python
Executable file
347 lines
12 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
|
||
|
||
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())
|