diff --git a/script/analyse/check_filestore.py b/script/analyse/check_filestore.py index 2a38bf9..4c62870 100755 --- a/script/analyse/check_filestore.py +++ b/script/analyse/check_filestore.py @@ -175,9 +175,9 @@ def scan_filestores(racine, sauf=None): qu'on cherchera — mais notés à part : les remettre en place est un déplacement, pas une copie depuis ailleurs. """ - ailleurs, niches = {}, {} + ailleurs, niches, par_base = {}, {}, {} if not os.path.isdir(racine): - return ailleurs, niches + return ailleurs, niches, par_base for base in sorted(os.listdir(racine)): chemin = os.path.join(racine, base) if not os.path.isdir(chemin): @@ -187,19 +187,25 @@ def scan_filestores(racine, sauf=None): if not os.path.isdir(sous): continue if prefixe == "filestore": - cible, marque = niches, base + # Un même fichier peut être niché dans PLUSIEURS bases — + # le clone les recopie toutes. L'index n'en retient qu'une + # (le premier `setdefault` gagne) : il sert à retrouver un + # fichier, pas à compter. D'où le décompte par base, sans + # lequel une base nichée s'entendait dire que le problème + # était chez les voisines. for deux in sorted(os.listdir(sous)): profond = os.path.join(sous, deux) if not os.path.isdir(profond): continue for nom in os.listdir(profond): - cible.setdefault(f"{deux}/{nom}", marque) + niches.setdefault(f"{deux}/{nom}", base) + par_base[base] = par_base.get(base, 0) + 1 continue if base == sauf: continue for nom in os.listdir(sous): ailleurs.setdefault(f"{prefixe}/{nom}", base) - return ailleurs, niches + return ailleurs, niches, par_base def scan_backups(dossier): @@ -358,6 +364,18 @@ def render_verify(rapport): return lignes +def is_dead_field(piece, champs_vivants): + """La pièce jointe porte-t-elle un champ qui n'existe plus ? + + Sans `res_field` la question ne se pose pas : c'est un document + téléversé, pas la valeur d'un champ. Le juger sur un champ absent le + ferait disparaître du rapport. + """ + if not piece.get("field"): + return False + return f"{piece['model']}.{piece['field']}" not in champs_vivants + + def classify(piece, present, ailleurs, niches, sauvegardes, champs_vivants): """Le verdict d'une pièce jointe. None si son fichier est là. @@ -368,10 +386,8 @@ def classify(piece, present, ailleurs, niches, sauvegardes, champs_vivants): return None # Un champ disparu n'a rien à récupérer : la ligne est une scorie. # Le tester EN PREMIER évite de proposer une remise en place inutile. - if piece["field"]: - cle = f"{piece['model']}.{piece['field']}" - if cle not in champs_vivants: - return ("dead_field", cle) + if is_dead_field(piece, champs_vivants): + return ("dead_field", f"{piece['model']}.{piece['field']}") if piece["store_fname"] in niches: return ("nested", niches[piece["store_fname"]]) if piece["store_fname"] in ailleurs: @@ -401,18 +417,26 @@ def audit(database, config_path=None, backups=None): for nom in os.listdir(sous): if os.path.isfile(os.path.join(sous, nom)): present.add(f"{prefixe}/{nom}") - ailleurs, niches = scan_filestores(racine, sauf=database) + ailleurs, niches, par_base = scan_filestores(racine, sauf=database) sauvegardes = scan_backups(backups or os.path.join(REPO_ROOT, "image_db")) champs_vivants = live_fields(database) groupes = {verdict: [] for verdict in VERDICTS} vus = set() morts = [] + gardees = [] for piece in pieces: verdict = classify( piece, present, ailleurs, niches, sauvegardes, champs_vivants ) if not verdict: + # Fichier présent — mais son champ vit-il encore ? Un outil + # nommé « fichiers absents » ne regardait pas là, et laissait + # dormir 1860 lignes et 30 Mo que plus rien ne lit. + if is_dead_field(piece, champs_vivants): + gardees.append(piece) + if str(piece.get("id", "")).isdigit(): + morts.append(int(piece["id"])) continue # La déduplication qui suit sert à compter des FICHIERS. Pour # effacer des LIGNES il les faut toutes : vingt-deux lignes @@ -436,9 +460,14 @@ def audit(database, config_path=None, backups=None): "files_present": len(present), "missing": len(vus), "groups": groupes, - "nested_total": len(niches), + "nested_total": par_base.get(database, 0), + "nested_elsewhere": sum( + combien for base, combien in par_base.items() if base != database + ), "root": mien, "dead_ids": morts, + "dead_kept": gardees, + "dead_kept_size": sum(piece["size"] for piece in gardees), } @@ -452,7 +481,7 @@ def render(rapport, limit=20): ] if not rapport["missing"]: lignes.append(f" ✅ {t('every attachment file is present')}") - return lignes + render_nested(rapport) + return lignes + render_dead_kept(rapport) + render_nested(rapport) lignes.append(f" {rapport['missing']} {t('file(s) missing')} :") for verdict in VERDICTS: groupe = rapport["groups"][verdict] @@ -487,17 +516,53 @@ def render(rapport, limit=20): ) if limit and len(groupe) > limit: lignes.append(f" … {len(groupe) - limit} {t('more')}") - return lignes + render_nested(rapport) + return lignes + render_dead_kept(rapport) + render_nested(rapport) + + +def render_dead_kept(rapport, limit=4): + """Les lignes mortes dont le FICHIER est toujours là. + + Elles ne manquent à personne — c'est justement le problème : rien ne + les lit, et leur fichier occupe le disque tant que la ligne existe, + puisque le ramasse-miettes d'Odoo ne retire que ce qui n'est plus + référencé. Un outil nommé « fichiers absents » ne regardait pas là, + et laissait dormir 1860 lignes et 30 Mo. + """ + gardees = rapport.get("dead_kept") or [] + if not gardees: + return [] + lignes = [ + "", + f" 🕳 {len(gardees)}" + f" {t('row(s) whose field is gone still hold their file')}" + f" ({rapport.get('dead_kept_size', 0) // 1024} ko)", + ] + apercu = summarise(gardees) + for texte in apercu[: limit or None]: + lignes.append(f" {texte}") + if limit and len(apercu) > limit: + lignes.append(f" … {len(apercu) - limit} {t('more')}") + return lignes def render_nested(rapport): + ailleurs = rapport.get("nested_elsewhere") or 0 if not rapport.get("nested_total"): + # Rien ici, mais peut-être chez les voisines : le dire sans + # laisser croire que CETTE base est concernée. + if ailleurs: + return [ + "", + f" ↳ {ailleurs}" + f" {t('such file(s) sit in OTHER databases filestores.')}", + ] return [] - return [ + lignes = [ "", f" ↳ {rapport['nested_total']}" - f" {t('file(s) sit in nested filestores Odoo never reads.')}", + f" {t('file(s) sit in a nested filestore Odoo never reads.')}", ] + return lignes def alive_mark(piece): diff --git a/script/database/db_restore.py b/script/database/db_restore.py index 4442d63..1a72d48 100755 --- a/script/database/db_restore.py +++ b/script/database/db_restore.py @@ -7,6 +7,7 @@ import configparser import getpass import logging import os +import shutil import sys from subprocess import check_output @@ -113,6 +114,41 @@ def verify_filestore(database, image): rapport = check_filestore.verify_restore(database, chemin) for ligne in check_filestore.render_verify(rapport): print(ligne) + 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.") def restore_or_clone(config, arg_base, cache_database, lst_db_cache): diff --git a/script/todo/todo_upgrade.py b/script/todo/todo_upgrade.py index 8dd444c..84793da 100755 --- a/script/todo/todo_upgrade.py +++ b/script/todo/todo_upgrade.py @@ -2825,6 +2825,7 @@ class TodoUpgrade: f"✨ {t('Re-update i18n, purge the data and the tables')}" f" ({t('except mail_test and mail_test_full')})" ) + self.prompt_purge_dead_attachments(database_name_upgrade) # waiting_input = self.ask("💬print Press any keyboard key to continue...") msg = "6 - Migration finished" self.print_step(msg) diff --git a/test/test_check_filestore.py b/test/test_check_filestore.py index 412185b..b86ac72 100644 --- a/test/test_check_filestore.py +++ b/test/test_check_filestore.py @@ -149,18 +149,32 @@ class TestScanning(unittest.TestCase): shutil.rmtree(self.racine) def test_other_filestores_are_indexed_and_mine_is_skipped(self): - ailleurs, _n = fs.scan_filestores(self.racine, sauf="ma_base") + ailleurs, _n, _p = fs.scan_filestores(self.racine, sauf="ma_base") self.assertEqual(ailleurs.get("bb/ailleurs"), "autre") self.assertNotIn("aa/present", ailleurs) def test_nested_files_are_indexed_under_their_logical_name(self): # C'est sous « cc/niche » qu'on les cherchera, pas sous # « filestore/cc/niche ». - _a, niches = fs.scan_filestores(self.racine, sauf=None) + _a, niches, _p = fs.scan_filestores(self.racine, sauf=None) self.assertEqual(niches.get("cc/niche"), "ma_base") + def test_the_same_nested_file_in_two_databases_counts_in_BOTH(self): + # Le clone recopie le nichage : un même fichier dort dans + # plusieurs bases. L'index n'en retient qu'une — le premier + # `setdefault` gagne, et l'ordre est alphabétique — donc la 17 + # s'entendait dire que le problème était chez les voisines. + for base in ("aaa_base", "zzz_base"): + complet = os.path.join(self.racine, base, "filestore", "ee", "x") + os.makedirs(os.path.dirname(complet), exist_ok=True) + with open(complet, "w", encoding="utf-8") as handle: + handle.write("x") + _a, _n, par_base = fs.scan_filestores(self.racine, sauf=None) + self.assertEqual(par_base.get("aaa_base"), 1) + self.assertEqual(par_base.get("zzz_base"), 1) + def test_a_missing_root_is_empty_not_a_crash(self): - self.assertEqual(fs.scan_filestores("/nulle/part"), ({}, {})) + self.assertEqual(fs.scan_filestores("/nulle/part"), ({}, {}, {})) def test_backups_are_read_from_the_central_directory(self): dossier = tempfile.mkdtemp() @@ -197,7 +211,7 @@ class TestTheAudit(unittest.TestCase): fs.filestore_root, ) fs.live_fields = lambda base: set() - fs.scan_filestores = lambda racine, sauf=None: ({}, {}) + fs.scan_filestores = lambda racine, sauf=None: ({}, {}, {}) fs.scan_backups = lambda dossier: {} fs.filestore_root = lambda config=None: "/nulle/part" @@ -751,6 +765,220 @@ class TestTidyingForReal(unittest.TestCase): self.assertEqual(handle.read(), "bon") +class TestTheDeadRowsThatKeptTheirFile(unittest.TestCase): + """1860 lignes, 30 Mo, que l'outil ne voyait pas. + + Il s'appelle « fichiers absents » et ne regardait donc que les + fichiers absents. Or une ligne dont le champ a disparu retient son + fichier tant qu'elle existe : le ramasse-miettes d'Odoo ne retire + que ce qui n'est plus référencé. + """ + + def setUp(self): + self.vrais = ( + fs.attachments, + fs.live_fields, + fs.scan_filestores, + fs.scan_backups, + fs.filestore_root, + ) + fs.live_fields = lambda base: {"res.partner.image_1920"} + fs.scan_filestores = lambda racine, sauf=None: ({}, {}, {}) + fs.scan_backups = lambda dossier: {} + fs.filestore_root = lambda config=None: "/nulle/part" + + def tearDown(self): + ( + fs.attachments, + fs.live_fields, + fs.scan_filestores, + fs.scan_backups, + fs.filestore_root, + ) = self.vrais + + def pose_fichier(self, chemin): + """Un VRAI fichier : c'est la présence qui distingue les deux cas.""" + complet = os.path.join(self.racine, "db", 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_dead_row_WITH_its_file_lands_in_dead_kept(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine, True) + fs.filestore_root = lambda config=None: self.racine + self.pose_fichier("aa/bb") + fs.attachments = lambda base: [ + piece("aa/bb", "res.partner", "image", size=4096, pid="5") + ] + rapport = fs.audit("db") + # Fichier présent : ce n'est PAS un fichier manquant… + self.assertEqual(rapport["missing"], 0) + # …mais la ligne est morte, et son fichier occupe le disque. + self.assertEqual(len(rapport["dead_kept"]), 1) + self.assertEqual(rapport["dead_kept_size"], 4096) + self.assertEqual(rapport["dead_ids"], [5]) + + def test_a_living_row_with_its_file_is_left_alone(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine, True) + fs.filestore_root = lambda config=None: self.racine + self.pose_fichier("aa/bb") + fs.attachments = lambda base: [ + piece("aa/bb", "res.partner", "image_1920", size=4096, pid="5") + ] + rapport = fs.audit("db") + self.assertEqual(rapport["dead_kept"], []) + self.assertEqual(rapport["dead_ids"], []) + + def test_the_size_is_summed_by_the_audit_itself(self): + self.racine = tempfile.mkdtemp() + self.addCleanup(shutil.rmtree, self.racine, True) + fs.filestore_root = lambda config=None: self.racine + for chemin in ("aa/bb", "cc/dd"): + self.pose_fichier(chemin) + fs.attachments = lambda base: [ + piece("aa/bb", "res.partner", "image", size=1024, pid="5"), + piece("cc/dd", "res.partner", "image", size=1024, pid="6"), + ] + self.assertEqual(fs.audit("db")["dead_kept_size"], 2048) + + def test_the_report_shows_the_weight(self): + gardees = [piece("a/1", "res.partner", "image", size=2048)] + texte = "\n".join( + fs.render_dead_kept({"dead_kept": gardees, "dead_kept_size": 2048}) + ) + self.assertIn("2 ko", texte) + self.assertIn("res.partner / image", texte) + + def test_nothing_kept_says_nothing(self): + self.assertEqual(fs.render_dead_kept({"dead_kept": []}), []) + + def test_a_living_field_is_never_counted(self): + self.assertFalse( + fs.is_dead_field( + piece("a/1", "res.partner", "image_1920"), + {"res.partner.image_1920"}, + ) + ) + + def test_an_uploaded_document_has_no_field_so_is_never_dead(self): + self.assertFalse(fs.is_dead_field(piece("a/1", "project.task"), set())) + + +class TestTheNestedCountIsPerDatabase(unittest.TestCase): + """« 1168 fichiers échoués » devant une base qu'on vient de ranger. + + Le compte agrégeait tous les filestores de la machine : on rangeait, + le rapport affichait le même chiffre, et l'on rangeait à nouveau. + """ + + def setUp(self): + self.vrais = ( + fs.attachments, + fs.live_fields, + fs.scan_filestores, + fs.scan_backups, + fs.filestore_root, + ) + fs.attachments = lambda base: [] + fs.live_fields = lambda base: set() + fs.scan_backups = lambda dossier: {} + fs.filestore_root = lambda config=None: "/nulle/part" + fs.scan_filestores = lambda racine, sauf=None: ( + {}, + {"a/1": "ma_base", "b/2": "voisine", "c/3": "voisine"}, + {"ma_base": 1, "voisine": 2}, + ) + + def tearDown(self): + ( + fs.attachments, + fs.live_fields, + fs.scan_filestores, + fs.scan_backups, + fs.filestore_root, + ) = self.vrais + + def test_only_this_database_counts_as_nested(self): + rapport = fs.audit("ma_base") + self.assertEqual(rapport["nested_total"], 1) + self.assertEqual(rapport["nested_elsewhere"], 2) + + def test_a_tidy_database_is_not_told_it_has_work(self): + fs.scan_filestores = lambda racine, sauf=None: ( + {}, + {"b/2": "voisine"}, + {"voisine": 1}, + ) + rapport = fs.audit("ma_base") + texte = "\n".join(fs.render_nested(rapport)) + self.assertNotIn( + todo_i18n.t("file(s) sit in a nested filestore Odoo never reads."), + texte, + ) + self.assertIn( + todo_i18n.t("such file(s) sit in OTHER databases filestores."), + texte, + ) + + +class TestTheMigrationWiring(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_the_purge_runs_ONCE_at_the_end_not_between_bumps(self): + # Entre deux paliers, deux à onze champs disparaissent puis + # REVIENNENT : purger là trancherait sur du transitoire. + src = self.source("script/todo/todo_upgrade.py") + self.assertIn("prompt_purge_dead_attachments", src) + appel = "self.prompt_purge_dead_attachments(database_name_upgrade)" + self.assertEqual(src.count(appel), 1) + boucle = src.index("for index, next_version in enumerate(") + self.assertGreater( + src.index(appel), boucle, "l'appel doit suivre la boucle" + ) + etape = src.index('"5 - Cleaning up database after upgrade"') + self.assertGreater(src.index(appel), etape) + + def test_the_purge_runs_BEFORE_the_final_backup(self): + # Qui veut garder l'état d'avant refuse la purge ; la sauvegarde + # qui suit doit capturer l'état nettoyé. + src = self.source("script/todo/todo_upgrade.py") + appel = "self.prompt_purge_dead_attachments(database_name_upgrade)" + self.assertLess(src.index(appel), src.index("cmd_backup_template")) + + def test_the_restore_offers_to_tidy_where_the_fault_is_born(self): + # L'APPEL, pas le nom : `pass` à sa place laisse la fonction + # définie et le test passerait sur du code mort. + src = self.source("script/database/db_restore.py") + self.assertIn("tidy_nested_plan", src) + self.assertIn( + 'if rapport.get("nested"):\n offer_tidy(', + src, + "le rangement n'est plus proposé après la vérification", + ) + + def test_the_restore_never_asks_without_a_terminal(self): + # Ce script tourne aussi sans personne devant : une question + # posée à un stdin fermé arrêterait la migration. + src = self.source("script/database/db_restore.py") + debut = src.index("def offer_tidy") + fin = src.index("input(", debut) + self.assertIn("sys.stdin.isatty()", src[debut:fin]) + + def test_the_clone_path_still_offers_nothing(self): + src = self.source("script/database/db_restore.py") + debut = src.index("--clone --from_database") + fin = src.index("verify_filestore(config.database", debut) + self.assertNotIn("offer_tidy", src[debut:fin]) + + class TestVerifyingARestore(unittest.TestCase): """Le contrôle d'après-restauration, celui qui aurait vu le nichage."""