[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
This commit is contained in:
parent
96330a7679
commit
d6a088e926
4 changed files with 348 additions and 34 deletions
|
|
@ -221,6 +221,102 @@ def scan_backups(dossier):
|
|||
return trouves
|
||||
|
||||
|
||||
def files_on_disk(racine, database):
|
||||
"""(au bon niveau, nichés) pour une base. Deux ensembles de noms."""
|
||||
base = os.path.join(racine, database)
|
||||
bons, niches = set(), set()
|
||||
if not os.path.isdir(base):
|
||||
return bons, niches
|
||||
for prefixe in os.listdir(base):
|
||||
sous = os.path.join(base, prefixe)
|
||||
if not os.path.isdir(sous):
|
||||
continue
|
||||
if prefixe == "filestore":
|
||||
for deux in os.listdir(sous):
|
||||
profond = os.path.join(sous, deux)
|
||||
if not os.path.isdir(profond):
|
||||
continue
|
||||
for nom in os.listdir(profond):
|
||||
if os.path.isfile(os.path.join(profond, nom)):
|
||||
niches.add(f"{deux}/{nom}")
|
||||
continue
|
||||
for nom in os.listdir(sous):
|
||||
if os.path.isfile(os.path.join(sous, nom)):
|
||||
bons.add(f"{prefixe}/{nom}")
|
||||
return bons, niches
|
||||
|
||||
|
||||
def verify_restore(database, zip_path, config_path=None):
|
||||
"""La sauvegarde a-t-elle bien atterri ? À vérifier UNE fois.
|
||||
|
||||
Après un clone, il n'y a rien à contrôler : `copytree` recopie la
|
||||
source telle quelle, défauts compris — le contrôle appartient à la
|
||||
restauration d'origine, pas au miroir.
|
||||
|
||||
Ce qu'on cherche est précis. `shutil.move` d'Odoo renomme quand la
|
||||
destination n'existe pas et IMBRIQUE quand elle existe. Un dossier
|
||||
`filestore/<base>/` laissé par une restauration précédente suffit
|
||||
donc à envoyer toute la sauvegarde dans
|
||||
`filestore/<base>/filestore/`, où Odoo ne regardera jamais. Mesuré :
|
||||
1168 fichiers, 133 Mo, recopiés ensuite dans les six bases de la
|
||||
chaîne par le clone, sans que rien ne le signale.
|
||||
"""
|
||||
attendus = set(scan_zip(zip_path))
|
||||
racine = filestore_root(config_path)
|
||||
bons, niches = files_on_disk(racine, database)
|
||||
return {
|
||||
"database": database,
|
||||
"zip": os.path.basename(zip_path),
|
||||
"expected": len(attendus),
|
||||
"placed": len(attendus & bons),
|
||||
"nested": len(attendus & niches),
|
||||
"missing": len(attendus - bons - niches),
|
||||
"root": os.path.join(racine, database),
|
||||
}
|
||||
|
||||
|
||||
def scan_zip(chemin):
|
||||
"""Les noms de fichiers du `filestore/` d'une sauvegarde."""
|
||||
try:
|
||||
with zipfile.ZipFile(chemin) as archive:
|
||||
return [
|
||||
membre[len("filestore/") :]
|
||||
for membre in archive.namelist()
|
||||
if membre.startswith("filestore/") and not membre.endswith("/")
|
||||
]
|
||||
except (OSError, zipfile.BadZipFile):
|
||||
return []
|
||||
|
||||
|
||||
def render_verify(rapport):
|
||||
"""Se taire quand tout va bien : un contrôle bavard finit ignoré."""
|
||||
if not rapport["expected"]:
|
||||
return []
|
||||
if not rapport["nested"] and not rapport["missing"]:
|
||||
return [
|
||||
f"✅ {t('Filestore restored:')} {rapport['placed']}"
|
||||
f"/{rapport['expected']} {t('file(s) in place')}"
|
||||
]
|
||||
lignes = [
|
||||
f"⚠ {t('Filestore restore looks wrong for')} {rapport['database']}"
|
||||
f" ({rapport['zip']}) :",
|
||||
f" {rapport['placed']}/{rapport['expected']}"
|
||||
f" {t('file(s) in place')}",
|
||||
]
|
||||
if rapport["nested"]:
|
||||
lignes.append(
|
||||
f" {rapport['nested']}"
|
||||
f" {t('landed in a nested filestore Odoo never reads')}"
|
||||
)
|
||||
lignes.append(
|
||||
f" {t('To fix:')} rsync -a --remove-source-files"
|
||||
f" {rapport['root']}/filestore/ {rapport['root']}/"
|
||||
)
|
||||
if rapport["missing"]:
|
||||
lignes.append(f" {rapport['missing']} {t('never landed at all')}")
|
||||
return lignes
|
||||
|
||||
|
||||
def classify(piece, present, ailleurs, niches, sauvegardes, champs_vivants):
|
||||
"""Le verdict d'une pièce jointe. None si son fichier est là.
|
||||
|
||||
|
|
|
|||
|
|
@ -10,6 +10,10 @@ 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__)
|
||||
|
|
@ -86,6 +90,74 @@ def get_list_db_cache(arg_base):
|
|||
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()
|
||||
|
||||
|
|
@ -139,40 +211,7 @@ def main():
|
|||
print(out)
|
||||
if config.only_drop:
|
||||
return
|
||||
# Check cache exist
|
||||
if cache_database not in lst_db_cache and not config.ignore_cache:
|
||||
_logger.info(
|
||||
f"## Create cache {cache_database} from image"
|
||||
f" {config.image} ##"
|
||||
)
|
||||
arg = (
|
||||
f"{arg_base} --restore"
|
||||
f" --restore_image {config.image} --database {cache_database}"
|
||||
)
|
||||
out = check_output(arg.split(" ")).decode()
|
||||
print(out)
|
||||
# Clone database
|
||||
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 {config.database} ##"
|
||||
)
|
||||
arg = (
|
||||
f"{arg_base} --clone --from_database"
|
||||
f" {cache_database} --database {config.database}"
|
||||
)
|
||||
if config.neutralize:
|
||||
arg += " --neutralize"
|
||||
print(arg)
|
||||
out = check_output(arg.split(" ")).decode()
|
||||
print(out)
|
||||
restore_or_clone(config, arg_base, cache_database, lst_db_cache)
|
||||
|
||||
if not config.clean_cache and not config.database:
|
||||
print("Nothing to do.")
|
||||
|
|
|
|||
|
|
@ -10196,6 +10196,12 @@ class TODO:
|
|||
"Modules missing from the default package"
|
||||
)
|
||||
},
|
||||
{"section": t("Files")},
|
||||
{
|
||||
"prompt_description": t(
|
||||
"Attachment files missing from the filestore"
|
||||
)
|
||||
},
|
||||
]
|
||||
help_info = self.fill_help_info(choices)
|
||||
|
||||
|
|
@ -10214,6 +10220,8 @@ class TODO:
|
|||
self.execute_analyse_migration_quality()
|
||||
elif status == "5":
|
||||
self.execute_analyse_module_package()
|
||||
elif status == "6":
|
||||
self.execute_analyse_filestore()
|
||||
else:
|
||||
print(t("Command not found !"))
|
||||
|
||||
|
|
@ -10261,6 +10269,45 @@ class TODO:
|
|||
handler,
|
||||
)
|
||||
|
||||
def execute_analyse_filestore(self):
|
||||
"""Ce qui manque au filestore, et ce qu'on peut encore récupérer.
|
||||
|
||||
Pas d'option « sauvegarde .zip » : l'outil compare une BASE à son
|
||||
filestore, et un zip porte les deux ensemble par construction —
|
||||
il n'y a rien à y trouver.
|
||||
"""
|
||||
from script.analyse import check_filestore as filestore
|
||||
|
||||
database = self._analyse_select_database()
|
||||
if not database:
|
||||
return
|
||||
print(f"⧖ {t('Scanning filestores and backups…')}")
|
||||
try:
|
||||
rapport = filestore.audit(database)
|
||||
except Exception as exc:
|
||||
print(f"❌ {t('Analysis failed: ')}{exc}")
|
||||
return
|
||||
if rapport.get("unavailable"):
|
||||
print(f"❌ {t('Cannot read the database: ')}{database}")
|
||||
return
|
||||
print("\n".join(filestore.render(rapport, limit=20)))
|
||||
|
||||
def handler(rank):
|
||||
if rank == 1:
|
||||
print("\n".join(filestore.render(rapport, limit=0)))
|
||||
else:
|
||||
self._analyse_export_json(
|
||||
rapport, os.path.basename(database), "filestore"
|
||||
)
|
||||
|
||||
self._analyse_follow_up(
|
||||
[
|
||||
{"prompt_description": t("Show every entry")},
|
||||
{"prompt_description": t("Export as JSON")},
|
||||
],
|
||||
handler,
|
||||
)
|
||||
|
||||
def _analyse_offer_install(self, database, rapport):
|
||||
"""Proposer d'installer ce qui manque, quand c'est installable.
|
||||
|
||||
|
|
|
|||
|
|
@ -289,6 +289,138 @@ class TestTheReport(unittest.TestCase):
|
|||
self.assertIn("res.country / image × 2", resume)
|
||||
|
||||
|
||||
class TestVerifyingARestore(unittest.TestCase):
|
||||
"""Le contrôle d'après-restauration, celui qui aurait vu le nichage."""
|
||||
|
||||
def setUp(self):
|
||||
self.racine = tempfile.mkdtemp()
|
||||
self.zip = os.path.join(self.racine, "sauv.zip")
|
||||
with zipfile.ZipFile(self.zip, "w") as archive:
|
||||
for nom in ("aa/un", "bb/deux", "cc/trois"):
|
||||
archive.writestr(f"filestore/{nom}", "x")
|
||||
archive.writestr("dump.sql", "x")
|
||||
self.fs = os.path.join(self.racine, "fs")
|
||||
self.vrai = fs.filestore_root
|
||||
fs.filestore_root = lambda config=None: self.fs
|
||||
|
||||
def tearDown(self):
|
||||
fs.filestore_root = self.vrai
|
||||
shutil.rmtree(self.racine)
|
||||
|
||||
def pose(self, chemins):
|
||||
for chemin in chemins:
|
||||
complet = os.path.join(self.fs, "ma_base", chemin)
|
||||
os.makedirs(os.path.dirname(complet), exist_ok=True)
|
||||
with open(complet, "w", encoding="utf-8") as handle:
|
||||
handle.write("x")
|
||||
|
||||
def test_a_clean_restore_reports_everything_in_place(self):
|
||||
self.pose(["aa/un", "bb/deux", "cc/trois"])
|
||||
r = fs.verify_restore("ma_base", self.zip)
|
||||
self.assertEqual((r["expected"], r["placed"]), (3, 3))
|
||||
self.assertEqual((r["nested"], r["missing"]), (0, 0))
|
||||
|
||||
def test_a_nested_restore_is_caught(self):
|
||||
# Le défaut exact qui a coûté 133 Mo par base, sept fois.
|
||||
self.pose(
|
||||
["filestore/aa/un", "filestore/bb/deux", "filestore/cc/trois"]
|
||||
)
|
||||
r = fs.verify_restore("ma_base", self.zip)
|
||||
self.assertEqual(r["nested"], 3)
|
||||
self.assertEqual(r["placed"], 0)
|
||||
|
||||
def test_a_half_nested_restore_counts_both_sides(self):
|
||||
self.pose(["aa/un", "filestore/bb/deux"])
|
||||
r = fs.verify_restore("ma_base", self.zip)
|
||||
self.assertEqual((r["placed"], r["nested"], r["missing"]), (1, 1, 1))
|
||||
|
||||
def test_files_that_never_landed_are_counted(self):
|
||||
self.pose(["aa/un"])
|
||||
self.assertEqual(fs.verify_restore("ma_base", self.zip)["missing"], 2)
|
||||
|
||||
def test_a_zip_without_a_filestore_says_nothing(self):
|
||||
# Se taire quand il n'y a rien à contrôler : un contrôle bavard
|
||||
# à chaque restauration finit par ne plus être lu.
|
||||
vide = os.path.join(self.racine, "vide.zip")
|
||||
with zipfile.ZipFile(vide, "w") as archive:
|
||||
archive.writestr("dump.sql", "x")
|
||||
r = fs.verify_restore("ma_base", vide)
|
||||
self.assertEqual(r["expected"], 0)
|
||||
self.assertEqual(fs.render_verify(r), [])
|
||||
|
||||
def test_the_fix_command_names_the_real_directory(self):
|
||||
self.pose(["filestore/aa/un"])
|
||||
texte = "\n".join(
|
||||
fs.render_verify(fs.verify_restore("ma_base", self.zip))
|
||||
)
|
||||
self.assertIn(os.path.join(self.fs, "ma_base"), texte)
|
||||
self.assertIn(todo_i18n.t("To fix:"), texte)
|
||||
|
||||
def test_a_clean_restore_still_says_so_briefly(self):
|
||||
self.pose(["aa/un", "bb/deux", "cc/trois"])
|
||||
texte = "\n".join(
|
||||
fs.render_verify(fs.verify_restore("ma_base", self.zip))
|
||||
)
|
||||
self.assertIn(todo_i18n.t("Filestore restored:"), texte)
|
||||
self.assertNotIn(todo_i18n.t("To fix:"), texte)
|
||||
|
||||
|
||||
class TestTheRestoreWiring(unittest.TestCase):
|
||||
RACINE = os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
def source(self, chemin):
|
||||
with io.open(
|
||||
os.path.join(self.RACINE, chemin), encoding="utf-8"
|
||||
) as handle:
|
||||
return handle.read()
|
||||
|
||||
def test_db_restore_checks_after_a_real_restore(self):
|
||||
src = self.source("script/database/db_restore.py")
|
||||
self.assertEqual(src.count("verify_filestore("), 3)
|
||||
self.assertIn(
|
||||
"if config.ignore_cache:\n verify_filestore("
|
||||
"config.database",
|
||||
src,
|
||||
"le contrôle d'après-restauration directe n'est plus gardé"
|
||||
" par ignore_cache",
|
||||
)
|
||||
self.assertIn(
|
||||
"not config.ignore_cache:",
|
||||
src,
|
||||
"la création du cache n'est plus conditionnée",
|
||||
)
|
||||
|
||||
def test_the_clone_path_is_NOT_checked(self):
|
||||
# Le miroir recopie sa source, défauts compris : contrôler là
|
||||
# dirait deux fois la même chose, et au mauvais endroit.
|
||||
src = self.source("script/database/db_restore.py")
|
||||
self.assertIn("--clone --from_database", src)
|
||||
debut = src.index("--clone --from_database")
|
||||
marque = "verify_filestore(config.database"
|
||||
self.assertIn(
|
||||
marque, src, "le contrôle d'après-restauration a disparu"
|
||||
)
|
||||
fin = src.index(marque, debut)
|
||||
self.assertNotIn("verify_filestore", src[debut:fin])
|
||||
|
||||
def test_the_analyse_menu_offers_the_tool(self):
|
||||
src = self.source("script/todo/todo.py")
|
||||
self.assertIn("Attachment files missing from the filestore", src)
|
||||
self.assertIn("self.execute_analyse_filestore()", src)
|
||||
self.assertIn("def execute_analyse_filestore", src)
|
||||
|
||||
def test_the_menu_has_as_many_entries_as_branches(self):
|
||||
src = self.source("script/todo/todo.py")
|
||||
debut = src.index("def prompt_execute_analyse")
|
||||
fin = src.index('print(t("Command not found !"))', debut)
|
||||
bloc = src[debut:fin]
|
||||
entrees = bloc.count('"prompt_description"')
|
||||
branches = sum(
|
||||
f'status == "{n}"' in bloc for n in range(1, entrees + 2)
|
||||
)
|
||||
self.assertEqual(entrees, branches)
|
||||
|
||||
|
||||
class TestTheExitCodes(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.vrai = fs.audit
|
||||
|
|
|
|||
Loading…
Reference in a new issue