erplibre/script/todo/database_manager.py
Mathieu Benoit abb605aad3 [ADD] database: dupliquer une base, et la neutraliser pour de bon
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)
2026-08-29 02:11:04 -04:00

404 lines
14 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
import datetime
import getpass
import logging
import os
import zipfile
from script.todo.todo_i18n import t
_logger = logging.getLogger(__name__)
try:
import click
from script.todo import todo_file_browser
except Exception:
click = None
todo_file_browser = None
class DatabaseManager:
def __init__(self, execute, fill_help_info) -> None:
self._execute = execute
self._fill_help_info = fill_help_info
self._dir_path: str | None = None
def _on_dir_selected(self, path: str) -> None:
self._dir_path = path
def select_database(self) -> str | bool:
"""Faire choisir une base parmi celles que PostgreSQL expose.
Le code de retour de « db --list » est vérifié AVANT de construire le
menu. Sans cette vérification, un PostgreSQL injoignable ne se distingue
pas d'une base absente : la sortie et l'erreur sont fusionnées dans le
même flux (`stderr=STDOUT`, execute.py), donc les lignes de la trace
d'appel devenaient les entrées du menu. « Traceback (most recent call
last): » s'affichait comme la base [1], et la choisir renvoyait cette
ligne comme nom de base à l'appelant, qui la passait à sa commande.
"""
cmd_server = "./odoo_bin.sh db --list"
status, output = self._execute.exec_command_live(
cmd_server,
return_status_and_output=True,
quiet=True,
source_erplibre=False,
single_source_erplibre=True,
)
if status:
print(f"❌ {t('Cannot list the databases (exit code): ')}{status}")
print(f" {t('Is PostgreSQL running?')}")
for line in output[-5:]:
print(f" {line}")
return False
databases = [a.strip() for a in output if a.strip()]
if not databases:
print(f"ℹ️ {t('No database on this PostgreSQL server.')}")
return False
choices = [{"prompt_description": a} for a in databases]
help_info = self._fill_help_info(choices)
valid_choices = [str(a + 1) for a in range(len(databases))]
while True:
answer = click.prompt(help_info)
print()
if answer == "0":
return False
elif answer in valid_choices:
database_name = databases[int(answer) - 1]
print(database_name)
return database_name
else:
print(t("Command not found !"))
def _confirm_drop(self, message: str) -> bool:
"""Ask for an explicit 'oui'/'yes' confirmation, default is no."""
print(f"⚠️ {message}")
answer = (
input(t("Type 'oui' to confirm (default: no): ")).strip().lower()
)
return answer in ("oui", "yes")
def drop_database(self) -> None:
print(f"⚠️ {t('Erase a database — irreversible operation!')}")
choices = [
{
"prompt_description": t(
"Erase ALL databases (make db_drop_all)"
)
},
{"prompt_description": t("Erase a single database")},
]
help_info = self._fill_help_info(choices)
while True:
status = click.prompt(help_info)
print()
if status == "0":
return
elif status == "1":
self._drop_all_databases()
return
elif status == "2":
self._drop_single_database()
return
else:
print(t("Command not found !"))
def _drop_all_databases(self) -> None:
if not self._confirm_drop(
t("You are about to erase ALL databases. This cannot be undone.")
):
print(t("Database deletion cancelled."))
return
self._execute.exec_command_live(
"make db_drop_all",
source_erplibre=False,
single_source_erplibre=True,
)
def _drop_single_database(self) -> None:
database_name = self.select_database()
if not database_name:
print(t("No database selected."))
return
message = t(
"You are about to erase the database '{database}'."
" This cannot be undone."
).format(database=database_name)
if not self._confirm_drop(message):
print(t("Database deletion cancelled."))
return
self._execute.exec_command_live(
f"./odoo_bin.sh db --drop --database {database_name}",
source_erplibre=False,
single_source_erplibre=True,
)
def restore_from_database(self, show_remote_list: bool = True) -> None:
path_image_db = os.path.join(os.getcwd(), "image_db")
print("[1] By filename from image_db")
print(f"[] Browser image_db {path_image_db}")
status = input("\U0001f4ac Select : ")
if status == "1":
file_name = status
else:
file_name = self.open_file_image_db()
default_database_name = file_name.replace(" ", "_")
if default_database_name.endswith(".zip"):
default_database_name = default_database_name[:-4]
database_name = input(
f"\U0001f4ac Database name (default={default_database_name}) : "
)
if not database_name:
database_name = default_database_name
status = (
input("\U0001f4ac Would you like to neutralize database (n/N)? ")
.strip()
.lower()
)
is_neutralize = False
more_arg = ""
if status != "n":
more_arg = "--neutralize "
is_neutralize = True
database_name += "_neutralize"
status, output_lines = self._execute.exec_command_live(
f"python3 ./script/database/db_restore.py -d {database_name} "
f"{more_arg}--ignore_cache --image {file_name}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
if is_neutralize:
status, output_lines = self._execute.exec_command_live(
f"./script/addons/update_prod_to_dev.sh {database_name}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
status = (
input("\U0001f4ac Would you like to update all addons (y/Y)? ")
.strip()
.lower()
)
if status == "y":
status, output_lines = self._execute.exec_command_live(
f"./script/addons/update_addons_all.sh {database_name}",
return_status_and_output=True,
single_source_erplibre=True,
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:
database_name = self.select_database()
backup_name = input(
"\U0001f4ac Backup name (default = name+date.zip) : "
)
if not backup_name:
backup_name = (
database_name
+ "_"
+ datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss")
+ ".zip"
)
if not backup_name.endswith(".zip"):
backup_name = backup_name + ".zip"
print(backup_name)
cmd = (
f"./odoo_bin.sh db --backup --database {database_name}"
f" --restore_image {backup_name}"
)
status, output_lines = self._execute.exec_command_live(
cmd,
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
def open_file_image_db(self) -> str:
self._dir_path = ""
path_image_db = os.path.join(os.getcwd(), "image_db")
file_browser = todo_file_browser.FileBrowser(
path_image_db, self._on_dir_selected
)
file_browser.run_main_frame()
file_name = os.path.basename(self._dir_path)
print(file_name)
return file_name
def select_backup_path(self, start=None) -> str | None:
"""Faire choisir une sauvegarde .zip, au parcours ou au chemin tapé.
Les deux, parce que ni l'un ni l'autre ne suffit : le parcours part
d'`image_db/` et n'aide pas si la sauvegarde vient d'ailleurs ; le
chemin tapé oblige à le connaître. Le parcours d'abord, et une saisie
directe si l'on en sort sans rien choisir.
"""
directory = start or os.path.join(os.getcwd(), "image_db")
if todo_file_browser is not None and os.path.isdir(directory):
self._dir_path = ""
browser = todo_file_browser.FileBrowser(
directory, self._on_dir_selected
)
browser.run_main_frame()
if self._dir_path and os.path.isfile(self._dir_path):
print(self._dir_path)
return self._dir_path
answer = input(
t("Path to the backup .zip (empty to cancel): ")
).strip()
if not answer:
return None
path = os.path.expanduser(answer)
if not os.path.isfile(path):
print(f"❌ {t('No such file: ')}{path}")
return None
return path
def download_database_backup_cli(
self, show_remote_list: bool = True
) -> tuple[int, str, str]:
database_domain = input("Domain Odoo (ex. https://mondomain.com) : ")
if show_remote_list:
status, output_lines = self._execute.exec_command_live(
f"python3 ./script/database/list_remote.py --raw"
f" --odoo-url {database_domain}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
if len(output_lines) > 1:
for index, output in enumerate(output_lines):
print(f"{index + 1} - {output}")
database_name = input("Select id of database :").strip()
elif len(output_lines) == 1:
database_name = output_lines[0].strip()
else:
database_name = input(
"Cannot read remote database, Database name :\n"
)
else:
database_name = input("Database name :\n")
timestamp = datetime.datetime.now().strftime("%Y-%m-%d_%Hh%Mm%Ss")
default_output_path = f"./image_db/{database_name}_{timestamp}.zip"
output_path = input(
f"Output path (default: {default_output_path}) : "
).strip()
if not output_path:
output_path = default_output_path
master_password = getpass.getpass(prompt="Master password : ")
cmd = "script/database/download_remote.sh --quiet"
my_env = os.environ.copy()
my_env["MASTER_PWD"] = master_password
my_env["DATABASE_NAME"] = database_name
my_env["OUTPUT_FILE_PATH"] = output_path
my_env["ODOO_URL"] = database_domain
status, cmd_executed = self._execute.exec_command_live(
cmd,
source_erplibre=False,
return_status_and_command=True,
new_env=my_env,
)
try:
with zipfile.ZipFile(default_output_path, "r") as zip_ref:
manifest_file_1 = zip_ref.open("manifest.json")
_logger.info(
f"Log file '{default_output_path}' is complete"
" and validated."
)
except Exception as e:
_logger.error(e)
_logger.error(
"Failed to read manifest.json from backup file"
f" '{default_output_path}'."
)
return status, output_path, database_name