erplibre/script/analyse/check_filestore.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

491 lines
18 KiB
Python
Executable file
Raw Blame History

This file contains ambiguous Unicode characters

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)
"""Des pièces jointes sans fichier : lesquelles peut-on encore récupérer ?
« 266 fichiers absents du filestore » ne dit pas quoi faire. La question
utile est ailleurs : combien sont PERDUS, et combien dorment quelque part
sur la machine en attendant qu'on les remette ?
Mesuré sur une migration réelle : sur 266 absents, 262 étaient des images
engendrées par des modules — dont 235 drapeaux de pays dont le champ
n'existe même plus en 18 — et QUATRE étaient de vrais documents. Ces
quatre-là sont la réponse. Les 262 autres sont du bruit qu'il ne faut pas
confondre avec eux.
Où l'outil cherche
------------------
1. Les autres filestores de la machine. Une migration laisse une base par
palier ; un fichier perdu dans la 12 est souvent intact dans la 15,
régénéré en chemin par une mise à jour de module.
2. Les `filestore/` NICHÉS. `shutil.move(src, dst)` d'Odoo renomme quand
la destination n'existe pas et IMBRIQUE quand elle existe : une base
restaurée deux fois sous le même nom se retrouve avec
`filestore/<base>/filestore/xx/sha`, qu'Odoo ne lira jamais. Mesuré :
1168 fichiers, 133 Mo, recopiés à l'identique dans les sept bases de
la chaîne par le clone.
3. Les sauvegardes `.zip`. Leur répertoire central se lit sans tout
décompresser.
Ce qui n'est PAS récupérable
----------------------------
Un fichier introuvable partout. On le dit alors franchement, avec le
modèle et l'enregistrement auxquels il se rattache, pour qu'on puisse
juger de la perte — et non « 266 fichiers absents », devant quoi il n'y
a rien à décider.
Un cas mérite sa propre catégorie : la pièce jointe dont le CHAMP
n'existe plus. `res.country.image` était un binaire en 12 ; en 18 c'est
`image_url`, calculé. Les 235 lignes survivent en pointant vers un champ
disparu : rien ne les lit, rien ne les régénérera, et il n'y a rien à
récupérer. Les compter comme des pertes serait faux.
Lecture seule de bout en bout.
Codes de sortie : 0 rien d'irrécupérable, 1 des trouvailles, 2 échec.
"""
import os
import subprocess
import sys
import zipfile
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
SEP = "\x1f"
REPO_ROOT = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
)
# L'ordre EST la gravité : ce qu'on ne peut pas récupérer se lit en premier.
VERDICTS = ("lost", "in_backup", "in_other_filestore", "nested", "dead_field")
ICONE = {
"lost": "❌",
"in_backup": "📦",
"in_other_filestore": "🗂",
"nested": "↳",
"dead_field": "🕳",
}
EXPLICATION = {
"lost": "nowhere to be found — truly lost",
"in_backup": "still in a backup zip",
"in_other_filestore": "intact in another database's filestore",
"nested": "stranded in a nested filestore Odoo never reads",
"dead_field": "its field no longer exists — nothing reads it",
}
def run_psql(database, sql):
"""Interroger la base en lecture seule, garantie par le SERVEUR."""
env = os.environ.copy()
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
env["PSQLRC"] = ""
done = subprocess.run(
["psql", "-X", "-w", "-d", database, "-tAF", SEP, "-c", sql],
capture_output=True,
text=True,
env=env,
)
if done.returncode:
return None
return [ligne.split(SEP) for ligne in done.stdout.splitlines() if ligne]
def data_dir(config_path=None):
"""Le `data_dir` d'Odoo, lu dans la configuration.
Le deviner reviendrait à chercher au mauvais endroit et à déclarer
tout perdu — le pire diagnostic possible pour cet outil.
"""
chemin = config_path or os.path.join(REPO_ROOT, "config.conf")
try:
with open(chemin, "r", encoding="utf-8") as handle:
for ligne in handle:
if ligne.strip().startswith("data_dir"):
return ligne.split("=", 1)[1].strip()
except OSError:
pass
return os.path.expanduser("~/.local/share/Odoo")
def filestore_root(config_path=None):
return os.path.join(data_dir(config_path), "filestore")
def attachments(database):
"""Les pièces jointes stockées sur disque. None si la base se tait."""
lignes = run_psql(
database,
"SELECT a.store_fname, coalesce(a.res_model, ''),"
" coalesce(a.res_field, ''), coalesce(a.res_id::text, ''),"
" coalesce(a.name, ''), coalesce(a.file_size::text, '0'),"
" coalesce(a.mimetype, ''), coalesce(a.create_date::text, '')"
" FROM ir_attachment a WHERE a.store_fname IS NOT NULL"
" ORDER BY a.file_size DESC",
)
if lignes is None:
return None
return [
{
"store_fname": ligne[0],
"model": ligne[1],
"field": ligne[2],
"res_id": ligne[3],
"name": ligne[4],
"size": int(ligne[5] or 0),
"mimetype": ligne[6],
"created": ligne[7][:10],
}
for ligne in lignes
if len(ligne) >= 8
]
def live_fields(database):
"""Les champs qui EXISTENT encore, en « modele.champ ».
Sans eux, une pièce jointe orpheline d'un champ supprimé passerait
pour une perte alors qu'il n'y a rien à perdre.
"""
lignes = run_psql(
database, "SELECT model || '.' || name FROM ir_model_fields"
)
return {ligne[0] for ligne in lignes or [] if ligne and ligne[0]}
def scan_filestores(racine, sauf=None):
"""{store_fname: base} pour toutes les bases, la nôtre exclue.
Les fichiers NICHÉS sont indexés sous leur nom logique — c'est ce
qu'on cherchera — mais notés à part : les remettre en place est un
déplacement, pas une copie depuis ailleurs.
"""
ailleurs, niches = {}, {}
if not os.path.isdir(racine):
return ailleurs, niches
for base in sorted(os.listdir(racine)):
chemin = os.path.join(racine, base)
if not os.path.isdir(chemin):
continue
for prefixe in sorted(os.listdir(chemin)):
sous = os.path.join(chemin, prefixe)
if not os.path.isdir(sous):
continue
if prefixe == "filestore":
cible, marque = niches, base
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)
continue
if base == sauf:
continue
for nom in os.listdir(sous):
ailleurs.setdefault(f"{prefixe}/{nom}", base)
return ailleurs, niches
def scan_backups(dossier):
"""{store_fname: zip} en lisant le répertoire central, sans extraire."""
trouves = {}
if not os.path.isdir(dossier):
return trouves
for nom in sorted(os.listdir(dossier)):
if not nom.endswith(".zip"):
continue
chemin = os.path.join(dossier, nom)
try:
with zipfile.ZipFile(chemin) as archive:
for membre in archive.namelist():
if membre.startswith("filestore/") and not membre.endswith(
"/"
):
trouves.setdefault(membre[len("filestore/") :], nom)
except (OSError, zipfile.BadZipFile):
continue
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à.
L'ordre des questions est l'ordre de l'action à mener : d'abord
« y a-t-il seulement quelque chose à récupérer », puis « où ».
"""
if piece["store_fname"] in present:
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 piece["store_fname"] in niches:
return ("nested", niches[piece["store_fname"]])
if piece["store_fname"] in ailleurs:
return ("in_other_filestore", ailleurs[piece["store_fname"]])
if piece["store_fname"] in sauvegardes:
return ("in_backup", sauvegardes[piece["store_fname"]])
return ("lost", None)
def audit(database, config_path=None, backups=None):
"""Tout ce qu'il faut savoir, en une passe. Lecture seule."""
pieces = attachments(database)
if pieces is None:
return {"unavailable": True, "database": database}
racine = filestore_root(config_path)
mien = os.path.join(racine, database)
present = set()
if os.path.isdir(mien):
for prefixe in os.listdir(mien):
sous = os.path.join(mien, prefixe)
if prefixe == "filestore" or not os.path.isdir(sous):
continue
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)
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()
for piece in pieces:
verdict = classify(
piece, present, ailleurs, niches, sauvegardes, champs_vivants
)
if not verdict:
continue
# Plusieurs pièces jointes partagent un fichier quand leur contenu
# est identique : le compter une fois par ligne gonflerait le
# rapport sans ajouter un seul fichier à récupérer.
if piece["store_fname"] in vus:
continue
vus.add(piece["store_fname"])
groupes[verdict[0]].append(dict(piece, where=verdict[1]))
return {
"database": database,
"attachments": len(pieces),
"files_present": len(present),
"missing": len(vus),
"groups": groupes,
"nested_total": len(niches),
"root": racine,
}
def render(rapport, limit=20):
if rapport.get("unavailable"):
return [f"❌ {t('Cannot read the database: ')}{rapport['database']}"]
lignes = [
f"🗄 {t('Filestore of')} {rapport['database']} :"
f" {rapport['attachments']} {t('stored attachment(s)')},"
f" {rapport['files_present']} {t('file(s) on disk')}"
]
if not rapport["missing"]:
lignes.append(f" ✅ {t('every attachment file is present')}")
return lignes + render_nested(rapport)
lignes.append(f" {rapport['missing']} {t('file(s) missing')} :")
for verdict in VERDICTS:
groupe = rapport["groups"][verdict]
if not groupe:
continue
poids = sum(piece["size"] for piece in groupe)
lignes.append(
f" {ICONE[verdict]} {len(groupe)} {t(EXPLICATION[verdict])}"
f" ({poids // 1024} ko)"
)
# On ne nomme QUE l'irrécupérable : c'est la seule liste devant
# laquelle il y a une décision à prendre. Nommer les autres
# ferait défiler trois cents lignes sans rien apprendre.
if verdict != "lost":
apercu = summarise(groupe)
for texte in apercu[:4]:
lignes.append(f" {texte}")
if len(apercu) > 4:
lignes.append(f" … {len(apercu) - 4} {t('more')}")
continue
for piece in groupe[:limit]:
ou = (
f"{piece['model']} #{piece['res_id']}"
if piece["model"]
else "—"
)
champ = f" [{piece['field']}]" if piece["field"] else ""
lignes.append(
f" {piece['name'][:44] or '(sans nom)':<46}"
f" {piece['size'] // 1024:>6} ko {ou}{champ}"
f" {piece['created']}"
)
if len(groupe) > limit:
lignes.append(f" … {len(groupe) - limit} {t('more')}")
return lignes + render_nested(rapport)
def render_nested(rapport):
if not rapport.get("nested_total"):
return []
return [
"",
f" ↳ {rapport['nested_total']}"
f" {t('file(s) sit in nested filestores Odoo never reads.')}",
]
def summarise(groupe):
"""« modele / champ × N », pour dire beaucoup en peu de lignes."""
compte = {}
for piece in groupe:
cle = f"{piece['model'] or '—'} / {piece['field'] or '—'}"
compte[cle] = compte.get(cle, 0) + 1
return [
f"{cle} × {combien}" if combien > 1 else cle
for cle, combien in sorted(compte.items(), key=lambda x: -x[1])
]
def main(argv=None):
import argparse
parser = argparse.ArgumentParser(
description=(
"List attachments whose file is gone, and say which ones can"
" still be recovered and from where."
)
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument("--backups", help="directory of backup .zip files")
parser.add_argument("--limit", type=int, default=20)
parser.add_argument("--json", action="store_true")
config = parser.parse_args(argv)
rapport = audit(config.database, config.config, config.backups)
if rapport.get("unavailable"):
print(f"❌ {t('Cannot read the database: ')}{config.database}")
return 2
if config.json:
import json
print(
json.dumps(rapport, indent=2, sort_keys=True, ensure_ascii=False)
)
else:
print("\n".join(render(rapport, config.limit)))
return 1 if rapport["groups"]["lost"] else 0
if __name__ == "__main__":
sys.exit(main())