erplibre/script/database/db_restore.py
Mathieu Benoit d6a088e926 [ADD] db_restore: check the filestore landed, and open the tool from the menu
Odoo's shutil.move renames when the destination is absent and NESTS when
it exists, so a leftover filestore/<db>/ sends a whole backup into
filestore/<db>/filestore/, where Odoo never looks. That happened once
here and the clone copied it into all seven databases of the chain --
1168 files, 133 MB each, and nothing said a word.

The check runs after a real restore only. A clone copies its source as
it stands, faults included: checking the mirror would say the same thing
twice, and in the wrong place. It warns and names the fix rather than
aborting -- the database is restored and usable, it is the layout that
is wrong.

--- FR ---

Le shutil.move d'Odoo renomme quand la destination est absente et
IMBRIQUE quand elle existe : un filestore/<base>/ resté là envoie toute
une sauvegarde dans filestore/<base>/filestore/, où Odoo ne regarde
jamais. C'est arrivé une fois ici et le clone l'a recopié dans les sept
bases de la chaîne -- 1168 fichiers, 133 Mo chacune, sans un mot.

Le contrôle ne suit qu'une vraie restauration. Un clone recopie sa
source telle quelle, défauts compris : contrôler le miroir dirait deux
fois la même chose, au mauvais endroit. Il avertit et nomme la
correction plutôt que d'interrompre -- la base est restaurée et
utilisable, c'est la disposition qui cloche.

Assisted-by: Claude Opus 5
2026-08-23 02:09:59 -04:00

221 lines
7.1 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)
import argparse
import configparser
import getpass
import logging
import os
import sys
from subprocess import check_output
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
logging.basicConfig(level=os.environ.get("LOGLEVEL", "INFO"))
_logger = logging.getLogger(__name__)
def get_config():
"""Parse command line arguments, extracting the config file name,
returning the union of config file and command line arguments
:return: dict of config file settings and command line arguments
"""
# TODO update description
parser = argparse.ArgumentParser(
formatter_class=argparse.RawDescriptionHelpFormatter,
description="""\
DESCRIPTION
Restore database, use cache to clone to improve speed.
SUGGESTION
./script/database/db_restore.py -d test
""",
epilog="""\
""",
)
# parser.add_argument('-d', '--dir', dest="dir", default="./",
# help="Path of repo to change remote, including submodule.")
parser.add_argument("-d", "--database", help="Database to manipulate.")
parser.add_argument(
"--image",
help=(
"Image name to restore, from directory image_db, filename without"
" '.zip'. Example, use odoo12.0_base to use image"
" odoo12.0_base.zip. Default value is odoo12.0_base"
),
)
parser.add_argument(
"--clean_cache",
action="store_true",
help="Delete all database cache to clone, begin by _cache_.",
)
parser.add_argument(
"--ignore_cache",
action="store_true",
help="Ignore creating _cache_ when restoring.",
)
parser.add_argument(
"--only_drop",
action="store_true",
help="Will only drop database if exist.",
)
parser.add_argument(
"--neutralize",
action="store_true",
help="Will disable all cron.",
)
args = parser.parse_args()
return args
def get_master_password():
try:
# _logger.info("You have 5 seconds to add master password...")
pa = getpass.getpass(prompt="\nEnter master password... ")
return pa
except getpass.GetPassWarning:
_logger.error("Password echoed, danger!")
def get_list_db_cache(arg_base):
arg = f"{arg_base} --list"
out = check_output(arg.split(" ")).decode()
lst_db = out.strip().split("\n")
lst_db_cache = [a for a in lst_db if a.startswith("_cache_")]
return lst_db, lst_db_cache
def verify_filestore(database, image):
"""Contrôler qu'une restauration a bien posé ses fichiers.
Une seule fois, à la restauration d'origine. Après un clone il n'y a
rien à vérifier : `copytree` recopie la source telle quelle, défauts
compris — le contrôle appartient à ce qui a créé le défaut, pas à ce
qui l'a dupliqué.
Le contrôle n'interrompt pas : la base est restaurée et utilisable,
c'est la DISPOSITION des fichiers qui est suspecte. Refuser ici
casserait des chaînes qui marchent, pour un défaut qui se répare
d'une commande.
"""
chemin = os.path.join("image_db", f"{image}.zip")
if not os.path.isfile(chemin):
return
try:
from script.analyse import check_filestore
except Exception: # pragma: no cover - l'outil d'analyse est optionnel
return
rapport = check_filestore.verify_restore(database, chemin)
for ligne in check_filestore.render_verify(rapport):
print(ligne)
def restore_or_clone(config, arg_base, cache_database, lst_db_cache):
"""Restaurer depuis l'image, ou cloner le cache déjà restauré.
Le contrôle du filestore ne suit QUE les vraies restaurations. Le
clone recopie sa source telle quelle : contrôler le miroir dirait
deux fois la même chose, et la seconde au mauvais endroit.
"""
if cache_database not in lst_db_cache and not config.ignore_cache:
_logger.info(
f"## Create cache {cache_database} from image {config.image} ##"
)
arg = (
f"{arg_base} --restore"
f" --restore_image {config.image} --database {cache_database}"
)
print(check_output(arg.split(" ")).decode())
verify_filestore(cache_database, config.image)
if config.ignore_cache:
_logger.info(
f"## Restoring {config.image} to database {config.database} ##"
)
arg = (
f"{arg_base} --restore --restore_image"
f" {config.image} --database {config.database}"
)
else:
_logger.info(
f"## Clone cache {cache_database} to database"
f" {config.database} ##"
)
arg = (
f"{arg_base} --clone --from_database"
f" {cache_database} --database {config.database}"
)
if config.neutralize:
arg += " --neutralize"
print(arg)
print(check_output(arg.split(" ")).decode())
if config.ignore_cache:
verify_filestore(config.database, config.image)
def main():
config = get_config()
arg_base = "./odoo_bin.sh db"
if not config.image:
with open(".odoo-version", "r") as f:
odoo_version = f.readline()
config.image = f"odoo{odoo_version}_base"
# check if it needs master password from config file
has_config_file = True
config_path = "./config.conf"
if not os.path.isfile(config_path):
config_path = "/etc/odoo/odoo.conf"
if not os.path.isfile(config_path):
has_config_file = False
if has_config_file:
config_parser = configparser.ConfigParser()
config_parser.read(config_path)
has_admin_password = config_parser.get("options", "admin_passwd")
if has_admin_password and has_admin_password != "admin":
master_password = get_master_password()
if not master_password:
_logger.error("Missing master password, cancel transaction.")
sys.exit(1)
else:
arg_base += f" --master_password={master_password}"
else:
_logger.info("No master password needed... Continue")
# Get list of database
lst_db, lst_db_cache = get_list_db_cache(arg_base)
if config.clean_cache:
for db in lst_db_cache:
_logger.info(f"## Delete {db} ##")
arg = f"{arg_base} --drop --database {db}"
out = check_output(arg.split(" ")).decode()
print(out)
lst_db, lst_db_cache = get_list_db_cache(arg_base)
if config.database:
cache_database = f"_cache_{config.image}"
# Drop db
if config.database in lst_db:
_logger.info(f"## Drop {config.database} ##")
arg = f"{arg_base} --drop --database {config.database}"
out = check_output(arg.split(" ")).decode()
print(out)
if config.only_drop:
return
restore_or_clone(config, arg_base, cache_database, lst_db_cache)
if not config.clean_cache and not config.database:
print("Nothing to do.")
if __name__ == "__main__":
main()