2024-08-26 14:55:36 -04:00
|
|
|
#!/usr/bin/env python3
|
2026-03-11 23:15:07 -04:00
|
|
|
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
2024-08-26 14:55:36 -04:00
|
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
2021-03-15 02:54:48 -04:00
|
|
|
import argparse
|
2021-07-01 00:52:15 -04:00
|
|
|
import configparser
|
|
|
|
|
import getpass
|
2022-01-24 01:39:49 -05:00
|
|
|
import logging
|
|
|
|
|
import os
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
import shutil
|
2022-01-24 01:39:49 -05:00
|
|
|
import sys
|
2021-03-15 02:54:48 -04:00
|
|
|
from subprocess import check_output
|
|
|
|
|
|
[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-21 22:14:48 -04:00
|
|
|
sys.path.append(
|
|
|
|
|
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
|
|
|
|
|
)
|
|
|
|
|
|
2021-03-15 02:54:48 -04:00
|
|
|
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,
|
2021-06-27 00:49:00 -04:00
|
|
|
description="""\
|
2023-01-17 21:44:30 -05:00
|
|
|
DESCRIPTION
|
|
|
|
|
Restore database, use cache to clone to improve speed.
|
|
|
|
|
|
|
|
|
|
SUGGESTION
|
|
|
|
|
./script/database/db_restore.py -d test
|
2021-06-27 00:49:00 -04:00
|
|
|
""",
|
|
|
|
|
epilog="""\
|
|
|
|
|
""",
|
2021-03-15 02:54:48 -04:00
|
|
|
)
|
|
|
|
|
# parser.add_argument('-d', '--dir', dest="dir", default="./",
|
|
|
|
|
# help="Path of repo to change remote, including submodule.")
|
2023-01-17 21:44:30 -05:00
|
|
|
parser.add_argument("-d", "--database", help="Database to manipulate.")
|
2021-06-27 00:49:00 -04:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--image",
|
|
|
|
|
help=(
|
|
|
|
|
"Image name to restore, from directory image_db, filename without"
|
2025-04-23 03:19:10 -04:00
|
|
|
" '.zip'. Example, use odoo12.0_base to use image"
|
|
|
|
|
" odoo12.0_base.zip. Default value is odoo12.0_base"
|
2021-06-27 00:49:00 -04:00
|
|
|
),
|
|
|
|
|
)
|
|
|
|
|
parser.add_argument(
|
|
|
|
|
"--clean_cache",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Delete all database cache to clone, begin by _cache_.",
|
|
|
|
|
)
|
2025-10-31 01:10:54 -04:00
|
|
|
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.",
|
|
|
|
|
)
|
2025-12-17 01:11:07 -05:00
|
|
|
parser.add_argument(
|
|
|
|
|
"--neutralize",
|
|
|
|
|
action="store_true",
|
|
|
|
|
help="Will disable all cron.",
|
|
|
|
|
)
|
2021-03-15 02:54:48 -04:00
|
|
|
args = parser.parse_args()
|
|
|
|
|
return args
|
|
|
|
|
|
|
|
|
|
|
2021-07-01 00:52:15 -04:00
|
|
|
def get_master_password():
|
|
|
|
|
try:
|
2022-05-15 17:14:14 -04:00
|
|
|
# _logger.info("You have 5 seconds to add master password...")
|
2021-07-16 00:13:05 -04:00
|
|
|
pa = getpass.getpass(prompt="\nEnter master password... ")
|
2021-07-01 00:52:15 -04:00
|
|
|
return pa
|
|
|
|
|
except getpass.GetPassWarning:
|
|
|
|
|
_logger.error("Password echoed, danger!")
|
|
|
|
|
|
|
|
|
|
|
2021-07-16 04:32:17 -04:00
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
[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-21 22:14:48 -04:00
|
|
|
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)
|
[ADD] filestore: purge once at the end, tidy at the restore, see the 30 MB
Not between bumps, and the measurement says why: two to eleven fields
vanish at one step and COME BACK at the next -- hr.employee.phone,
account.move.statement_id. "The field is gone" is a transient state
while a migration runs. And there would be nothing to gain: 1881 dead
rows appear at the 13 bump and the count never moves again, so one
final pass takes them all.
The nesting is born once, at the restore, and the clone copies it
identically into every step -- the six databases carried the same 1168
files. It is offered where it is born, never on a closed stdin.
Widened too: the tool was named after missing files and so looked only
at those. 1860 rows in 18 hold a live file for a field that is gone --
31 MB of res.partner.image and thumbnails from before Odoo 13 computed
them. Odoo's collector will never touch them while the row exists.
--- FR ---
Pas entre les paliers, et la mesure dit pourquoi : deux à onze champs
disparaissent à une étape et REVIENNENT à la suivante --
hr.employee.phone, account.move.statement_id. « Le champ n'existe plus »
est transitoire tant que la migration court. Et il n'y aurait rien à y
gagner : 1881 lignes mortes naissent au palier 13 et le compte ne bouge
plus, donc une passe finale les prend toutes.
Le nichage naît une fois, à la restauration, et le clone le recopie
partout — les six bases portaient les mêmes 1168 fichiers. Il se répare
là où il naît, jamais sur un stdin fermé.
Élargi aussi : l'outil portait le nom des fichiers absents et ne
regardait donc qu'eux. 1860 lignes en 18 retiennent un fichier bien
présent pour un champ disparu — 31 Mo d'images res.partner et de
vignettes d'avant qu'Odoo 13 ne les calcule.
Assisted-by: Claude Opus 5
2026-08-22 00:06:25 -04:00
|
|
|
if rapport.get("nested"):
|
|
|
|
|
offer_tidy(check_filestore, rapport)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def offer_tidy(check_filestore, rapport):
|
|
|
|
|
"""Proposer de ranger TOUT DE SUITE, là où le défaut naît.
|
|
|
|
|
|
|
|
|
|
C'est le seul endroit qui vaille. Le nichage se produit une fois, à
|
|
|
|
|
la restauration, puis le clone le recopie tel quel : mesuré, les six
|
|
|
|
|
bases de la chaîne portaient les mêmes 1168 fichiers. Ranger ici,
|
|
|
|
|
c'est ranger une fois ; ranger plus tard, c'est six fois.
|
|
|
|
|
|
|
|
|
|
Rien ne se fait sans réponse humaine, et rien du tout hors d'un
|
|
|
|
|
terminal : ce script tourne aussi sans personne devant, et une
|
|
|
|
|
question posée à un `stdin` fermé arrêterait la migration.
|
|
|
|
|
"""
|
|
|
|
|
if not sys.stdin.isatty():
|
|
|
|
|
return
|
|
|
|
|
remonter, doublons = check_filestore.tidy_nested_plan(rapport)
|
|
|
|
|
if not remonter and not doublons:
|
|
|
|
|
return
|
|
|
|
|
print(f" {len(remonter)} à remonter, {len(doublons)} doublons purs")
|
|
|
|
|
try:
|
|
|
|
|
reponse = input("💬 Ranger maintenant ? (y/N) : ").strip().lower()
|
|
|
|
|
except EOFError:
|
|
|
|
|
return
|
|
|
|
|
if reponse not in ("y", "yes", "o"):
|
|
|
|
|
return
|
|
|
|
|
for source, cible in remonter:
|
|
|
|
|
os.makedirs(os.path.dirname(cible), exist_ok=True)
|
|
|
|
|
shutil.move(source, cible)
|
|
|
|
|
for source, _cible in doublons:
|
|
|
|
|
os.remove(source)
|
|
|
|
|
shutil.rmtree(check_filestore.nested_dir(rapport), ignore_errors=True)
|
|
|
|
|
print(f"✅ {len(remonter)} remontés, {len(doublons)} doublons supprimés.")
|
[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-21 22:14:48 -04:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
2021-03-15 02:54:48 -04:00
|
|
|
def main():
|
|
|
|
|
config = get_config()
|
|
|
|
|
|
2025-05-04 23:10:34 -04:00
|
|
|
arg_base = "./odoo_bin.sh db"
|
2021-07-01 00:52:15 -04:00
|
|
|
|
2024-08-25 15:54:24 -04:00
|
|
|
if not config.image:
|
|
|
|
|
with open(".odoo-version", "r") as f:
|
|
|
|
|
odoo_version = f.readline()
|
|
|
|
|
config.image = f"odoo{odoo_version}_base"
|
|
|
|
|
|
2022-05-15 17:14:14 -04:00
|
|
|
# check if it needs master password from config file
|
2021-07-01 00:52:15 -04:00
|
|
|
has_config_file = True
|
2022-05-15 17:14:14 -04:00
|
|
|
config_path = "./config.conf"
|
2021-07-01 00:52:15 -04:00
|
|
|
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")
|
2022-05-15 17:14:14 -04:00
|
|
|
if has_admin_password and has_admin_password != "admin":
|
2021-07-01 00:52:15 -04:00
|
|
|
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")
|
|
|
|
|
|
2021-03-15 02:54:48 -04:00
|
|
|
# Get list of database
|
2021-07-16 04:32:17 -04:00
|
|
|
lst_db, lst_db_cache = get_list_db_cache(arg_base)
|
2021-03-15 02:54:48 -04:00
|
|
|
|
|
|
|
|
if config.clean_cache:
|
|
|
|
|
for db in lst_db_cache:
|
|
|
|
|
_logger.info(f"## Delete {db} ##")
|
2021-07-01 00:52:15 -04:00
|
|
|
arg = f"{arg_base} --drop --database {db}"
|
2021-03-15 02:54:48 -04:00
|
|
|
out = check_output(arg.split(" ")).decode()
|
|
|
|
|
print(out)
|
2021-07-16 04:32:17 -04:00
|
|
|
lst_db, lst_db_cache = get_list_db_cache(arg_base)
|
2021-03-15 02:54:48 -04:00
|
|
|
|
|
|
|
|
if config.database:
|
|
|
|
|
cache_database = f"_cache_{config.image}"
|
|
|
|
|
# Drop db
|
|
|
|
|
if config.database in lst_db:
|
|
|
|
|
_logger.info(f"## Drop {config.database} ##")
|
2021-07-01 00:52:15 -04:00
|
|
|
arg = f"{arg_base} --drop --database {config.database}"
|
2021-03-15 02:54:48 -04:00
|
|
|
out = check_output(arg.split(" ")).decode()
|
|
|
|
|
print(out)
|
2025-10-31 01:10:54 -04:00
|
|
|
if config.only_drop:
|
|
|
|
|
return
|
[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-21 22:14:48 -04:00
|
|
|
restore_or_clone(config, arg_base, cache_database, lst_db_cache)
|
2021-03-15 02:54:48 -04:00
|
|
|
|
2023-01-17 21:44:30 -05:00
|
|
|
if not config.clean_cache and not config.database:
|
|
|
|
|
print("Nothing to do.")
|
|
|
|
|
|
2021-03-15 02:54:48 -04:00
|
|
|
|
2021-06-27 00:49:00 -04:00
|
|
|
if __name__ == "__main__":
|
2021-03-15 02:54:48 -04:00
|
|
|
main()
|