[ADD] analyse: ausculter une base qui n'est pas ici

Les analyses existaient ; le chemin d'AVANT manquait. La base d'un client
est dans un zip, derrière une URL, ou vivante sur un serveur.

« Restant de migration » a dû être écrit : check_migration_quality compare
les bases de PALIER et exige le journal de progression — devant une
sauvegarde isolée, ni l'un ni l'autre n'existe.

Ses compteurs évidents ont été écartés après mesure. Comparés à la base
d'ORIGINE : champs sans colonne 25 → 72, modèles sans table 90 → 158.
Vingt-cinq et quatre-vingt-dix AVANT toute migration : du bruit. Ne
restent que les constats faux en eux-mêmes, 0 avant, non nuls après —
9 langues au drapeau NULL, 68 tables m2m absentes, 414 index doublés.

Le passe-plat RPC n'accepte que la lecture. psql l'obtient du serveur ;
une session RPC n'a rien d'équivalent, et la liste blanche est donc
appliquée dans le passe-plat, pas chez l'appelant.

--- EN ---

The analyses existed; the path BEFORE them did not. A customer database
sits in a zip, behind a URL, or live on a server.

« Migration leftovers » had to be written: check_migration_quality
compares STEP databases and needs the progression log — facing a lone
backup, neither exists.

Its obvious counters were dropped after measuring. Against the ORIGINAL
database: fields with no column 25 → 72, models with no table 90 → 158.
Twenty-five and ninety BEFORE any migration: noise. Only what is wrong in
itself remains, 0 before and non-zero after — 9 languages with a NULL
flag, 68 missing m2m tables, 414 duplicated indexes.

The RPC proxy only reads. psql gets that from the server; an RPC session
has no equivalent, so the allowlist lives in the proxy, not the caller.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-08-24 07:21:37 -04:00
parent f8bd1c30df
commit 2521267896
7 changed files with 1661 additions and 0 deletions

View file

@ -0,0 +1,329 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ce qu'une migration a laissé derrière elle, lu dans UNE seule base.
`check_migration_quality` répond à une autre question : il compare les
bases de PALIER qu'une migration locale a laissées, et il lui faut le
journal de progression. Devant la sauvegarde d'un client, ni l'une ni
l'autre n'existe. Il fallait donc un outil qui n'ait besoin que de la
base qu'on a sous la main.
Le piège, et pourquoi la moitié des contrôles évidents ont été écartés
--------------------------------------------------------------------
Un compteur absolu ne prouve rien. Mesuré sur une chaîne 12 → 18 réelle,
en comparant la base d'ORIGINE à la migrée :
champs stockés sans colonne 25 → 72 ← 25 AVANT toute migration
modèles sans table 90 → 158 ← 90 AVANT
contraintes orphelines 155 → 530 ← 155 AVANT
Ces trois-là paraissent accablants et ne le sont pas : un modèle abstrait
n'a jamais de table, un champ hérité n'a jamais sa colonne à lui. Affichés
bruts, ils font peur pour rien — et un rapport qui fait peur pour rien
finit par être ignoré en entier.
Ne restent ici que les constats qui se jugent SANS point de comparaison,
parce qu'ils sont faux en eux-mêmes. Les mêmes bases, mêmes mesures :
res_lang.active à NULL 0 → 9 un booléen NULL est un bug
ir_model_relation sans table 0 → 68 la table m2m est nommée,
elle n'existe pas
index doublés convention 17 0 → 414 Odoo 17 renomme, sans
supprimer l'ancien
liste de prix par défaut absente 0 → 1 `product` installé, son
xmlid pas là
Zéro avant, non nul après : aucun de ces quatre ne peut s'expliquer
autrement que par la migration.
Chaque constat nomme l'outil qui le répare. Un rapport qui montre un
dégât sans dire quoi lancer oblige à chercher, et on ne cherche pas.
Lecture seule : `default_transaction_read_only=on`, imposé par le serveur.
"""
from __future__ import annotations
import os
import sys
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
from script.analyse import lib_analyse # noqa: E402
COULEURS = {
"broken": "\033[31m",
"watch": "\033[33m",
"ok": "\033[32m",
"dim": "\033[90m",
}
RESET = "\033[0m"
def paint(texte, genre, colour):
"""Teinter, ou rendre le texte tel quel quand la couleur est coupée."""
if not colour:
return texte
return f"{COULEURS.get(genre, '')}{texte}{RESET}"
# `sql` doit rendre UN nombre. `repair` nomme l'outil qui corrige, ou None
# quand il n'y en a pas encore — le dire vaut mieux que de laisser croire.
CONTROLES = (
{
"key": "stuck_modules",
"title": "Modules stuck between two states",
"why": "A migration that stopped mid-flight leaves them there;"
" Odoo will retry the transition at every start.",
"sql": "SELECT count(*) FROM ir_module_module"
" WHERE state IN ('to install','to upgrade','to remove')",
"gravity": "broken",
"repair": "script/todo/todo_upgrade.py (uninstall_one_by_one)",
},
{
"key": "lang_active_null",
"title": "Languages whose active flag is NULL",
"why": "A boolean that is neither true nor false: the language is"
" listed nowhere and cannot be re-enabled from the interface.",
"sql": "SELECT count(*) FROM res_lang WHERE active IS NULL",
"gravity": "broken",
"repair": None,
},
{
"key": "orphan_relation",
"title": "Many-to-many tables named but missing",
"why": "ir_model_relation still names a table PostgreSQL does not"
" have; the next module update tries to alter it and fails.",
"sql": "SELECT count(*) FROM ir_model_relation r WHERE NOT EXISTS"
" (SELECT 1 FROM pg_class c WHERE c.relname = r.name)",
"gravity": "broken",
"repair": "script/analyse/database_cleanup.py",
},
{
"key": "duplicate_index",
"title": "Indexes duplicated by the Odoo 17 renaming",
"why": "Odoo 17 changed the naming convention without dropping the"
" old index: both are maintained on every write.",
"sql": "SELECT count(*) FROM pg_indexes a WHERE a.schemaname='public'"
" AND a.indexname ~ '__[a-z0-9_]+_index$'"
" AND EXISTS (SELECT 1 FROM pg_indexes b WHERE b.schemaname='public'"
" AND b.tablename = a.tablename"
" AND b.indexname = replace(a.indexname, '__', '_'))",
"gravity": "watch",
"repair": "script/odoo/migration/fix_duplicate_index.py --apply",
},
{
"key": "missing_pricelist",
"title": "Default pricelist missing while product is installed",
"why": "product.list0 was declared up to Odoo 16 only; nothing"
" recreates it, and a quotation has no price list to pick.",
"sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_module_module"
" WHERE name='product' AND state='installed')"
" AND NOT EXISTS (SELECT 1 FROM ir_model_data"
" WHERE module='product' AND name='list0')"
" THEN 1 ELSE 0 END",
"gravity": "broken",
"repair": "script/odoo/migration/restore_config_defaults.py --apply",
},
{
"key": "view_model_gone",
"title": "Views bound to a model that no longer exists",
"why": "Opening one raises; the menu that leads to it is a dead end.",
"sql": "SELECT count(*) FROM ir_ui_view v"
" WHERE v.model IS NOT NULL AND v.model <> ''"
" AND NOT EXISTS (SELECT 1 FROM ir_model m WHERE m.model = v.model)",
"gravity": "broken",
"repair": None,
},
{
"key": "view_parent_gone",
"title": "Views inheriting a view that is gone",
"why": "The whole inheritance chain below them stops rendering.",
"sql": "SELECT count(*) FROM ir_ui_view v WHERE v.inherit_id IS NOT"
" NULL AND NOT EXISTS"
" (SELECT 1 FROM ir_ui_view p WHERE p.id = v.inherit_id)",
"gravity": "broken",
"repair": "script/odoo/migration/fix_cow_render.py --apply",
},
{
"key": "xmlid_model_gone",
"title": "External ids pointing at a model that is gone",
"why": "Every module update that resolves one of them fails.",
"sql": "SELECT count(*) FROM ir_model_data d WHERE NOT EXISTS"
" (SELECT 1 FROM ir_model m WHERE m.model = d.model)",
"gravity": "broken",
"repair": "script/analyse/database_cleanup.py",
},
{
"key": "attachment_field_gone",
"title": "Attachments whose carrying field is gone",
"why": "Odoo raises a KeyError merely checking them, and nothing"
" will ever read them again.",
"sql": "SELECT count(*) FROM ir_attachment a"
" WHERE a.res_field IS NOT NULL AND a.res_field <> ''"
" AND NOT EXISTS (SELECT 1 FROM ir_model_fields f"
" WHERE f.model = a.res_model AND f.name = a.res_field)",
"gravity": "watch",
"repair": "script/analyse/check_filestore.py",
},
)
def inspect(database, config_path=None):
"""Passer chaque contrôle, et rendre son nombre.
Un contrôle qui ÉCHOUE n'est pas un contrôle qui rend zéro : une table
absente parce que le module n'est pas installé n'est pas un dégât. On
garde l'erreur telle quelle, et le rendu la distingue.
"""
resultats = {}
for controle in CONTROLES:
try:
brut = lib_analyse.run_psql(
database, controle["sql"], config_path=config_path
).strip()
resultats[controle["key"]] = int(brut.splitlines()[0])
except Exception as exc: # noqa: BLE001 - on rapporte, on ne meurt pas
resultats[controle["key"]] = {
"error": str(exc).splitlines()[0][:120]
}
return resultats
def judge(resultats):
"""Ne garder que ce qui compte, le plus grave d'abord.
Un contrôle illisible remonte AVEC les autres : ne pas avoir pu
regarder n'est pas la même chose que n'avoir rien trouvé, et taire la
différence est exactement ce qui fait prendre un rapport pour une
garantie.
"""
trouve, illisibles = [], []
for controle in CONTROLES:
valeur = resultats.get(controle["key"])
if isinstance(valeur, dict):
illisibles.append((controle, valeur.get("error", "")))
elif valeur:
trouve.append((controle, valeur))
trouve.sort(key=lambda pair: (pair[0]["gravity"] != "broken", -pair[1]))
return trouve, illisibles
def render(database, resultats, version=None, colour=True):
"""Le rapport lisible. Chaque constat dit quoi lancer pour le réparer."""
trouve, illisibles = judge(resultats)
lignes = [f"🚚 {t('Migration residue in')} {database}"]
if version:
lignes.append(f" {t('base module version:')} {version}")
lignes.append("")
if not trouve and not illisibles:
lignes.append(
paint(
f"✅ {t('None of the checks found anything.')}", "ok", colour
)
)
lignes.append(
paint(
f" {t('This reads one database on its own — it cannot see')}"
f" {t('what an earlier step silently dropped.')}",
"dim",
colour,
)
)
return "\n".join(lignes)
for controle, combien in trouve:
icone = "❌" if controle["gravity"] == "broken" else "⚠"
lignes.append(
paint(
f"{icone} {str(combien).rjust(6)} {t(controle['title'])}",
controle["gravity"],
colour,
)
)
lignes.append(paint(f" {t(controle['why'])}", "dim", colour))
if controle["repair"]:
lignes.append(f" → {controle['repair']}")
else:
lignes.append(
paint(f" → {t('no repair tool yet')}", "dim", colour)
)
lignes.append("")
for controle, erreur in illisibles:
lignes.append(
paint(
f"❔ {t(controle['title'])} — {t('could not read')}",
"watch",
colour,
)
)
lignes.append(paint(f" {erreur}", "dim", colour))
return "\n".join(lignes).rstrip()
def main(argv=None):
import argparse
import json
parser = argparse.ArgumentParser(
description=t("What a migration left behind, read in one database."),
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument("--json", action="store_true")
parser.add_argument("--no-color", action="store_true")
args = parser.parse_args(argv)
try:
lib_analyse.require_odoo_database(
args.database, config_path=args.config
)
except Exception as exc: # noqa: BLE001
print(f"❌ {exc}", file=sys.stderr)
return 2
resultats = inspect(args.database, args.config)
try:
version = lib_analyse.database_version(
args.database, config_path=args.config
)
except Exception: # noqa: BLE001 - la version est un confort, pas le sujet
version = None
trouve, illisibles = judge(resultats)
if args.json:
print(
json.dumps(
{
"database": args.database,
"base_version": version,
"checks": resultats,
"found": [c["key"] for c, _ in trouve],
"unreadable": [c["key"] for c, _ in illisibles],
},
indent=2,
ensure_ascii=False,
)
)
else:
colour = sys.stdout.isatty() and not args.no_color
print(render(args.database, resultats, version, colour))
return 1 if trouve else 0
if __name__ == "__main__":
sys.exit(main())

View file

@ -0,0 +1,306 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Ausculter une instance : d'où vient la base, et que peut-on y lire.
Ce module ne réanalyse rien. Les analyses existent déjà — vues
personnalisées, champs x_, restant de migration — et chacune sait parler
à une base. Ce qui manquait, c'est le chemin d'AVANT : la base n'est pas
toujours ici. Elle est dans un zip, ou chez un client, ou vivante sur un
serveur auquel on n'a qu'un identifiant.
Trois provenances, deux destins
-------------------------------
Le zip et la sauvegarde distante finissent au même endroit : restaurés en
base locale. À partir de là, tout ce que le dépôt sait faire fonctionne,
sans une ligne de plus.
L'instance VIVANTE, non. Aucun outil d'ici ne parle autre chose que psql,
et on n'a pas la base — on a une session Odoo. Ce que RPC laisse lire est
un sous-ensemble : les tables `ir_*` oui, `pg_catalog` non. Une analyse
qui compte des index ne peut donc pas tourner là, et le dire est le
travail de ce module. Une analyse muette qu'on croit rassurante est pire
que pas d'analyse du tout.
Pourquoi la TUI ne fait que CHOISIR
-----------------------------------
`run_tui` des six écrans du dépôt refuse de s'ouvrir dans une boucle
asyncio déjà en cours, et une analyse lourde appelée depuis un
gestionnaire de touche gèle l'affichage sans rien dire. Un hub qui
CONTIENT les analyses se heurte aux deux. Celui-ci demande ce qu'on veut,
se referme, et laisse l'analyse s'ouvrir chez elle, en pleine possession
du terminal.
"""
from __future__ import annotations
import os
import subprocess
import sys
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
REPO_ROOT = os.path.normpath(
os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..")
)
# Ce qu'une provenance donne à la fin. `database` couvre le zip restauré
# comme la sauvegarde téléchargée : une fois restaurés, ils ne se
# distinguent plus.
KIND_DATABASE = "database"
KIND_LIVE = "live"
ANALYSES = (
{
"key": "migration_residue",
"title": "Migration leftovers",
"why": "What a migration left behind, judged without needing the"
" step-by-step databases.",
"script": "script/analyse/check_migration_residue.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "It reads pg_catalog — indexes and real tables — which"
" no RPC session exposes.",
},
{
"key": "cow_views",
"title": "Customised views, website copies included",
"why": "Every view someone changed, and every website copy that"
" shadows a module view.",
"script": "script/analyse/analyse_view_custom.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "Comparing a copy with the module view it hides is a"
" join on arch_db, done in SQL.",
},
{
"key": "custom_fields",
"title": "Studio and hand-made x_ fields",
"why": "Fields and models added outside any module, and which of"
" them have no column behind them.",
"script": "script/analyse/analyse_custom_field.py",
"kinds": (KIND_DATABASE,),
"needs_sql": "Telling a declared field from a real column means"
" reading pg_attribute.",
},
)
def analysis_by_key(key):
"""L'analyse portant cette clé, ou None."""
for analyse in ANALYSES:
if analyse["key"] == key:
return analyse
return None
def available(kind):
"""Les analyses qui savent lire CETTE provenance.
Rendre la liste complète et laisser l'appelant filtrer ferait afficher
des choix qui échoueraient à l'ouverture ; c'est le genre de menu qui
apprend à ne plus faire confiance au menu.
"""
return tuple(a for a in ANALYSES if kind in a["kinds"])
def unavailable(kind):
"""Celles qui ne le savent pas, avec la raison — pour la DIRE."""
return tuple(a for a in ANALYSES if kind not in a["kinds"])
def command_for(analyse, database, config_path=None, extra=None):
"""La ligne de commande qui lance l'analyse sur cette base."""
cmd = [sys.executable, analyse["script"], "-d", database]
if config_path:
cmd += ["-c", config_path]
if extra:
cmd += list(extra)
return cmd
def run_analysis(analyse, database, config_path=None, extra=None, env=None):
"""Lancer l'analyse et rendre son code de sortie.
Sans capture : l'analyse écrit sur le terminal, et certaines ouvrent
leur propre TUI. Les intercepter reviendrait à leur retirer l'écran.
"""
cmd = command_for(analyse, database, config_path, extra)
completed = subprocess.run(cmd, cwd=REPO_ROOT, env=env)
return completed.returncode
def describe_source(kind, target):
"""Une ligne qui dit sur QUOI l'on travaille, avant d'analyser.
Après une restauration, le nom de la base n'est plus celui du fichier —
l'annoncer évite d'analyser une base pour une autre.
"""
if kind == KIND_LIVE:
return f"🛰 {t('Live instance:')} {target}"
return f"💾 {t('Database:')} {target}"
# ── La neutralisation a-t-elle vraiment eu lieu ? ────────────────────────
#
# Mesuré sur les sept bases d'une chaîne 12 → 18 dont le nom porte pourtant
# « neutralize » : `database.is_neutralized` ABSENT dans les sept, 16 à 35
# crons actifs, et `mail.catchall.domain` toujours au domaine du CLIENT.
# Demander « voulez-vous neutraliser ? », recevoir oui, et ne rien vérifier
# reproduit exactement cette illusion.
#
# On ne lit que des booléens de PRÉSENCE. Une clé de paiement vivante a été
# trouvée dans cette base de test ; un rapport finit dans un billet ou
# devant un agent, et n'a aucune raison de la porter.
NEUTRALIZE_SQL = {
"flag": "SELECT count(*) FROM ir_config_parameter"
" WHERE key='database.is_neutralized' AND value IN ('true','True','1')",
"cron_active": "SELECT count(*) FROM ir_cron WHERE active",
"mail_server": "SELECT count(*) FROM ir_mail_server",
"payment_live": "SELECT count(*) FROM payment_provider"
" WHERE state NOT IN ('disabled','test')",
}
def neutralize_state(database, config_path=None):
"""Ce que la base dit d'elle-même après une neutralisation.
Un contrôle illisible rend None et n'est pas compté comme zéro : la
table `payment_provider` n'existe pas si le module n'est pas installé,
et cette absence n'est pas une bonne nouvelle à afficher.
"""
from script.analyse import lib_analyse
etat = {}
for cle, sql in NEUTRALIZE_SQL.items():
try:
brut = lib_analyse.run_psql(
database, sql, config_path=config_path
).strip()
etat[cle] = int(brut.splitlines()[0])
except Exception: # noqa: BLE001 - absent n'est pas nul
etat[cle] = None
return etat
def neutralize_report(etat, colour=False):
"""Dire ce qui a pris et ce qui n'a pas pris, sans rien inventer.
`ir_mail_server` à zéro n'est PAS une preuve de sûreté : Odoo retombe
alors sur le `smtp_server` du fichier de configuration — c'est
précisément pourquoi son propre `neutralize.sql` INSÈRE un serveur
bouchon au lieu de tout supprimer.
"""
lignes = []
drapeau = etat.get("flag")
if drapeau:
lignes.append(f"✅ {t('database.is_neutralized is set.')}")
else:
lignes.append(
f"❌ {t('database.is_neutralized is NOT set — Odoo does not')}"
f" {t('consider this database neutralised.')}"
)
crons = etat.get("cron_active")
if crons:
lignes.append(f"⚠ {crons} {t('scheduled actions are still active.')}")
elif crons == 0:
lignes.append(f"✅ {t('No scheduled action is active.')}")
serveurs = etat.get("mail_server")
if serveurs == 0:
lignes.append(
f"⚠ {t('No mail server at all: Odoo falls back to smtp_server')}"
f" {t('from the config file. A blocking one is safer than none.')}"
)
vivants = etat.get("payment_live")
if vivants:
lignes.append(
f"❌ {vivants} {t('payment provider(s) are neither disabled nor')}"
f" {t('in test mode.')}"
)
return "\n".join(paint_plain(lignes, colour))
def paint_plain(lignes, colour):
"""Teinter selon l'icône de tête — une seule règle, pas une par ligne."""
if not colour:
return lignes
teinte = {"❌": "\033[31m", "⚠": "\033[33m", "✅": "\033[32m"}
sorties = []
for ligne in lignes:
code = teinte.get(ligne[:1], "")
sorties.append(f"{code}{ligne}\033[0m" if code else ligne)
return sorties
# ── L'instance vivante ───────────────────────────────────────────────────
#
# Depuis Odoo 14, une CLÉ D'API se présente exactement comme un mot de
# passe à `authenticate` : même appel, même place. La différence n'est pas
# technique, elle est humaine — une clé se révoque sans changer le mot de
# passe de personne. On demande donc laquelle on donne, et l'on envoie la
# même chose.
def live_connect(base_url, database, login, secret, timeout=30):
"""Ouvrir une session XML-RPC. Rendre (uid, version), ou lever.
C'est un vrai contrôle, pas une politesse : sans lui, une faute dans
l'URL ou la clé ne se verrait qu'à la première analyse, et passerait
pour un défaut de l'analyse.
"""
import xmlrpc.client
url = base_url.rstrip("/")
common = xmlrpc.client.ServerProxy(
f"{url}/xmlrpc/2/common", allow_none=True
)
import socket
ancien = socket.getdefaulttimeout()
socket.setdefaulttimeout(timeout)
try:
version = (common.version() or {}).get("server_serie", "?")
uid = common.authenticate(database, login, secret, {})
finally:
socket.setdefaulttimeout(ancien)
if not uid:
raise PermissionError(t("The instance refused these credentials."))
return uid, version
# Ce qu'une analyse a le droit d'appeler sur une instance vivante. La liste
# est appliquée ICI et non chez l'appelant : une production n'a pas à
# dépendre de la prudence de chaque analyse à venir. `write`, `create` et
# `unlink` ne peuvent pas s'y glisser par distraction.
RPC_READ_ONLY = (
"search_read",
"search_count",
"read",
"read_group",
"fields_get",
)
def live_call(base_url, database, uid, secret, model, method, *args, **kw):
"""Un appel RPC en lecture. Tout le reste est refusé sans être tenté."""
if method not in RPC_READ_ONLY:
raise PermissionError(
f"{t('Monitoring only reads; refused method:')} {method}"
)
import xmlrpc.client
models = xmlrpc.client.ServerProxy(
f"{base_url.rstrip('/')}/xmlrpc/2/object", allow_none=True
)
return models.execute_kw(
database, uid, secret, model, method, list(args), kw
)

View file

@ -0,0 +1,191 @@
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Demander quelle auscultation lancer — et rien d'autre.
Cet écran ne CONTIENT aucune analyse. Il en choisit une, se referme, et
rend la clé choisie ; l'appelant lance ensuite l'outil, qui dispose alors
du terminal entier et peut ouvrir sa propre TUI.
Deux raisons, toutes deux mesurées dans ce dépôt. `run_tui` refuse de
s'ouvrir quand une boucle asyncio tourne déjà — un écran qui en ouvrirait
un autre afficherait « open it in its own process instead » et rien
d'autre. Et une analyse lourde appelée depuis un gestionnaire de touche
bloque la boucle d'événements : plus de rafraîchissement, plus de frappe,
un écran qui paraît planté pendant des minutes.
Les analyses que la provenance ne permet pas restent AFFICHÉES, grisées,
avec la raison. Les retirer laisserait croire qu'elles n'existent pas ;
les proposer les ferait échouer à l'ouverture.
"""
from __future__ import annotations
import os
import sys
sys.path.append(
os.path.normpath(os.path.join(os.path.dirname(__file__), "..", ".."))
)
from script.analyse import monitoring # noqa: E402
try:
from script.todo.todo_i18n import t
except Exception: # pragma: no cover - repli si i18n indisponible
def t(key: str) -> str:
return key
CSS = """
Screen { layout: vertical; }
#head { height: 3; padding: 0 1; background: $panel; color: $text; }
#body { height: 1fr; }
#left { width: 52; border-right: solid $accent; }
#pane { width: 1fr; padding: 0 1; }
.off { color: $text-disabled; }
"""
def rows(kind):
"""Les lignes de l'écran : (clé, étiquette, utilisable, raison).
Fonction pure, et c'est délibéré — c'est elle qui décide ce qu'on peut
lancer, et une décision qui ne se teste qu'en ouvrant un terminal ne
se teste pas.
"""
lignes = []
for analyse in monitoring.ANALYSES:
utilisable = kind in analyse["kinds"]
lignes.append(
(
analyse["key"],
t(analyse["title"]),
utilisable,
"" if utilisable else t(analyse["needs_sql"]),
)
)
return lignes
def detail(key, kind):
"""Le texte du panneau de droite pour cette analyse."""
analyse = monitoring.analysis_by_key(key)
if not analyse:
return ""
morceaux = [t(analyse["title"]), "", t(analyse["why"]), ""]
if kind in analyse["kinds"]:
morceaux.append(f"▶ {t('Enter to run it.')}")
else:
morceaux.append(f"✖ {t('Not available for this source.')}")
morceaux.append(f" {t(analyse['needs_sql'])}")
morceaux.append("")
morceaux.append(f" {analyse['script']}")
return "\n".join(morceaux)
def build_app(kind, target):
"""Construire l'écran. ImportError si Textual manque — l'appelant le dit."""
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Footer, Header, ListItem, ListView, Static
lignes = rows(kind)
class MonitoringChooser(App):
CSS = globals()["CSS"]
BINDINGS = [
("enter", "choose", t("Run")),
("q", "quit", t("Quit")),
("escape", "quit", t("Quit")),
]
def compose(self) -> ComposeResult:
yield Header()
yield Static(monitoring.describe_source(kind, target), id="head")
with Horizontal(id="body"):
items = []
for key, label, utilisable, _ in lignes:
marque = " " if utilisable else "✖ "
item = ListItem(Static(f"{marque}{label}"))
if not utilisable:
item.add_class("off")
items.append(item)
yield ListView(*items, id="left")
yield Static("", id="pane")
yield Footer()
def on_mount(self) -> None:
self.query_one("#left", ListView).focus()
self._refresh_pane(0)
def _refresh_pane(self, index) -> None:
if 0 <= index < len(lignes):
self.query_one("#pane", Static).update(
detail(lignes[index][0], kind)
)
def on_list_view_selected(self, event) -> None:
# `ListView` consomme Entrée pour émettre `Selected` : la
# liaison de l'application ne la voit jamais. Sans ceci,
# l'écran ne répondait pas à Entrée — et aucune fonction pure
# ne pouvait le montrer.
self.action_choose()
def on_list_view_highlighted(self, event) -> None:
self._refresh_pane(self.query_one("#left", ListView).index or 0)
def action_choose(self) -> None:
index = self.query_one("#left", ListView).index
if index is None or not (0 <= index < len(lignes)):
return
key, _, utilisable, raison = lignes[index]
if not utilisable:
# Refuser en le DISANT : un Entrée sans effet se lit comme
# un écran figé, et l'on appuie plus fort.
self.query_one("#pane", Static).update(
f"✖ {t('Not available for this source.')}\n\n {raison}"
)
return
self.exit(key)
return MonitoringChooser()
def in_event_loop():
"""Une boucle asyncio tourne-t-elle déjà dans CE processus ?"""
import asyncio
try:
asyncio.get_running_loop()
except RuntimeError:
return False
return True
def run_tui(kind, target, run_app=True):
"""Ouvrir le choix. Rendre la clé choisie, ou None — en disant pourquoi."""
try:
from script.todo import textual_setup
except Exception: # pragma: no cover - repli si l'aide manque
textual_setup = None
if textual_setup and not textual_setup.ensure():
return None
try:
app = build_app(kind, target)
except ImportError:
print(
f"ℹ️ {t('Textual is missing from this interpreter:')}"
f" {sys.executable}"
)
return None
if not run_app:
return app
if in_event_loop():
print(
f"ℹ️ {t('Already inside a running screen: open it in its own')}"
f" {t('process instead.')}"
)
return None
return app.run()

View file

@ -2832,6 +2832,12 @@ class TODO(
"Attachment files missing from the filestore"
)
},
{"section": t("Instance")},
{
"prompt_description": t(
"Monitoring - a backup, a remote copy or a live instance"
)
},
]
help_info = self.fill_help_info(choices)
@ -2854,6 +2860,8 @@ class TODO(
self.execute_analyse_module_dependency()
elif status == "7":
self.execute_analyse_filestore()
elif status == "8":
self.execute_analyse_monitoring()
else:
print(t("Command not found !"))
@ -3515,6 +3523,209 @@ class TODO(
handler,
)
def execute_analyse_monitoring(self):
"""Ausculter une instance dont la base n'est pas forcément ici.
Les analyses existent déjà ; ce qui manquait est le chemin d'avant.
Une sauvegarde, locale ou distante, se RESTAURE — après quoi tout
ce que le dépôt sait faire s'applique. Une instance vivante, non :
on n'a pas la base, on a une session, et ce que RPC laisse lire est
un sous-ensemble. L'écran de choix le dit au lieu de proposer des
analyses qui échoueraient à l'ouverture.
"""
from script.analyse import monitoring, monitoring_tui
print(f"🛰 {t('Inspect an instance.')}")
source = self._monitoring_select_source()
if not source:
return
kind, target = source
print()
print(monitoring.describe_source(kind, target))
print()
choix = monitoring_tui.run_tui(kind, target)
if not choix:
return
analyse = monitoring.analysis_by_key(choix)
if not analyse:
return
if kind not in analyse["kinds"]:
print(f"✖ {t('Not available for this source.')}")
return
monitoring.run_analysis(analyse, target)
def _monitoring_select_source(self):
"""(genre, cible), ou None si l'on renonce.
Le zip et la sauvegarde distante mènent tous deux à une base
restaurée : passé la restauration, ils ne se distinguent plus, et
le reste du code n'a pas à savoir d'où ils venaient.
"""
from script.analyse import monitoring
print()
print(f"[1] {t('A local backup .zip')}")
print(f"[2] {t('A remote backup (https + master password)')}")
print(f"[3] {t('A live remote instance')}")
print(f"[0] {t('Back')}")
answer = click.prompt(t("Command:"))
print()
if answer == "1":
path = self.db_manager.select_backup_path()
database = self._monitoring_restore(path) if path else None
return (monitoring.KIND_DATABASE, database) if database else None
if answer == "2":
status, path, _name = (
self.db_manager.download_database_backup_cli()
)
if status or not path or not os.path.isfile(path):
print(f"❌ {t('The download did not produce a usable file.')}")
return None
database = self._monitoring_restore(path)
return (monitoring.KIND_DATABASE, database) if database else None
if answer == "3":
return self._monitoring_live()
return None
def _monitoring_live(self):
"""Se connecter pour de bon, ou ne pas prétendre l'être.
Une faute dans l'URL ou dans la clé ne se verrait sinon qu'à la
première analyse, et passerait pour un défaut de l'analyse.
"""
import getpass
from script.analyse import monitoring
base_url = input(t("Instance URL (ex. https://example.com): ")).strip()
if not base_url:
return None
database = input(t("Database name on that instance: ")).strip()
if not database:
return None
login = input(t("User login: ")).strip()
if not login:
return None
print()
print(f"[1] {t('An API key')}")
print(f"[2] {t('A password')}")
genre = click.prompt(t("Command:"))
secret = getpass.getpass(
t("API key: ") if genre == "1" else t("Password: ")
)
if not secret:
return None
try:
uid, version = monitoring.live_connect(
base_url, database, login, secret
)
except Exception as exc:
print(f"❌ {t('Cannot connect: ')}{exc}")
return None
print(f"✅ {t('Connected as uid')} {uid}, {t('Odoo')} {version}")
return (monitoring.KIND_LIVE, f"{base_url} · {database}")
def _monitoring_restore(self, zip_path):
"""Restaurer la sauvegarde, puis DIRE ce que la neutralisation a pris.
Mesuré sur sept bases dont le nom portait « neutralize » :
`database.is_neutralized` absent partout, jusqu'à 35 crons actifs,
et le domaine de courriel du client toujours en place. Poser la
question, recevoir oui et ne rien vérifier reproduit exactement
cette illusion — on relit donc la base.
"""
from script.analyse import monitoring
image = self._monitoring_image_name(zip_path)
if not image:
return None
defaut = image
database = input(
f"💬 {t('Database name (default=')}{defaut}) : "
).strip()
database = database or defaut
neutralise = (
input(f"💬 {t('Neutralize the database (Y/n)? ')}").strip().lower()
)
more_arg = ""
if neutralise != "n":
more_arg = "--neutralize "
database += "_neutralize"
status, _ = self._execute.exec_command_live(
f"python3 ./script/database/db_restore.py -d {database} "
f"{more_arg}--ignore_cache --image {image}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
if status:
print(f"❌ {t('The restore failed.')}")
return None
if more_arg:
status, _ = self._execute.exec_command_live(
f"./script/addons/update_prod_to_dev.sh {database}",
return_status_and_output=True,
single_source_erplibre=True,
source_erplibre=False,
)
print()
print(
monitoring.neutralize_report(
monitoring.neutralize_state(database), colour=True
)
)
if status:
# Le compte test/test vient de `user_test`, posé par ce
# script. S'il n'a pas fini, l'annoncer quand même enverrait
# se heurter à un refus d'authentification.
print(
f"⚠ {t('update_prod_to_dev did not finish: do not')}"
f" {t('count on the test/test account.')}"
)
else:
print(f"ℹ️ {t('You can log in with test / test.')}")
return database
def _monitoring_image_name(self, zip_path):
"""db_restore veut un NOM sous image_db/, pas un chemin.
Une sauvegarde qui vient d'ailleurs n'est donc pas restaurable telle
quelle. Plutôt que de recopier plusieurs gigaoctets, on propose un
lien — et on le demande, parce que cela pose un fichier dans un
répertoire qui n'est pas à nous.
"""
image_db = os.path.join(os.getcwd(), "image_db")
nom = os.path.basename(zip_path)
if nom.endswith(".zip"):
nom = nom[:-4]
if os.path.dirname(os.path.abspath(zip_path)) == image_db:
return nom
cible = os.path.join(image_db, f"{nom}.zip")
if os.path.exists(cible):
print(f"ℹ️ {t('Using the file already in image_db: ')}{cible}")
return nom
answer = (
input(
f"💬 {t('db_restore only reads image_db/. Link it there')}"
f" ({cible}) (Y/n)? "
)
.strip()
.lower()
)
if answer == "n":
return None
try:
os.makedirs(image_db, exist_ok=True)
os.symlink(os.path.abspath(zip_path), cible)
except OSError as exc:
print(f"❌ {t('Cannot link into image_db: ')}{exc}")
return None
print(f"🔗 {cible}")
return nom
def prompt_execute_process(self):
print(f"🤖 {t('Manage execution processes!')}")
choices = [

View file

@ -9280,6 +9280,302 @@ TRANSLATIONS = {
"en": "no .idea: open PyCharm once, then",
},
"project": {"fr": "projet", "en": "project"},
"Instance": {
"fr": "🛰 Instance",
"en": "🛰 Instance",
},
"Monitoring - a backup, a remote copy or a live instance": {
"fr": "🛰 Monitoring - une sauvegarde, une copie distante ou une instance vivante",
"en": "🛰 Monitoring - a backup, a remote copy or a live instance",
},
"Inspect an instance.": {
"fr": "Ausculter une instance.",
"en": "Inspect an instance.",
},
"A local backup .zip": {
"fr": "Une sauvegarde .zip locale",
"en": "A local backup .zip",
},
"A remote backup (https + master password)": {
"fr": "Une sauvegarde distante (https + mot de passe maître)",
"en": "A remote backup (https + master password)",
},
"A live remote instance": {
"fr": "Une instance vivante distante",
"en": "A live remote instance",
},
"An API key": {
"fr": "Une clé d'API",
"en": "An API key",
},
"A password": {
"fr": "Un mot de passe",
"en": "A password",
},
"API key: ": {
"fr": "Clé d'API : ",
"en": "API key: ",
},
"Password: ": {
"fr": "Mot de passe : ",
"en": "Password: ",
},
"User login: ": {
"fr": "Identifiant : ",
"en": "User login: ",
},
"Instance URL (ex. https://example.com): ": {
"fr": "URL de l'instance (ex. https://exemple.com) : ",
"en": "Instance URL (ex. https://example.com): ",
},
"Database name on that instance: ": {
"fr": "Nom de la base sur cette instance : ",
"en": "Database name on that instance: ",
},
"Cannot connect: ": {
"fr": "Connexion impossible : ",
"en": "Cannot connect: ",
},
"Connected as uid": {
"fr": "Connecté sous l'uid",
"en": "Connected as uid",
},
"The instance refused these credentials.": {
"fr": "L'instance a refusé ces identifiants.",
"en": "The instance refused these credentials.",
},
"Monitoring only reads; refused method:": {
"fr": "Le monitoring ne fait que lire ; méthode refusée :",
"en": "Monitoring only reads; refused method:",
},
"Database:": {
"fr": "Base :",
"en": "Database:",
},
"Live instance:": {
"fr": "Instance vivante :",
"en": "Live instance:",
},
"Database name (default=": {
"fr": "Nom de la base (défaut=",
"en": "Database name (default=",
},
"Neutralize the database (Y/n)? ": {
"fr": "Neutraliser la base (O/n) ? ",
"en": "Neutralize the database (Y/n)? ",
},
"The restore failed.": {
"fr": "La restauration a échoué.",
"en": "The restore failed.",
},
"The download did not produce a usable file.": {
"fr": "Le téléchargement n'a pas produit de fichier utilisable.",
"en": "The download did not produce a usable file.",
},
"db_restore only reads image_db/. Link it there": {
"fr": "db_restore ne lit que image_db/. Y poser un lien",
"en": "db_restore only reads image_db/. Link it there",
},
"Using the file already in image_db: ": {
"fr": "On prend le fichier déjà dans image_db : ",
"en": "Using the file already in image_db: ",
},
"Cannot link into image_db: ": {
"fr": "Impossible de poser le lien dans image_db : ",
"en": "Cannot link into image_db: ",
},
"You can log in with test / test.": {
"fr": "Vous pouvez vous connecter avec test / test.",
"en": "You can log in with test / test.",
},
"update_prod_to_dev did not finish: do not": {
"fr": "update_prod_to_dev n'a pas fini : ne comptez pas",
"en": "update_prod_to_dev did not finish: do not",
},
"count on the test/test account.": {
"fr": "sur le compte test/test.",
"en": "count on the test/test account.",
},
"database.is_neutralized is set.": {
"fr": "database.is_neutralized est posé.",
"en": "database.is_neutralized is set.",
},
"database.is_neutralized is NOT set — Odoo does not": {
"fr": "database.is_neutralized n'est PAS posé — Odoo ne considère pas",
"en": "database.is_neutralized is NOT set — Odoo does not",
},
"consider this database neutralised.": {
"fr": "cette base comme neutralisée.",
"en": "consider this database neutralised.",
},
"scheduled actions are still active.": {
"fr": "actions planifiées sont encore actives.",
"en": "scheduled actions are still active.",
},
"No scheduled action is active.": {
"fr": "Aucune action planifiée n'est active.",
"en": "No scheduled action is active.",
},
"No mail server at all: Odoo falls back to smtp_server": {
"fr": "Aucun serveur de courriel : Odoo retombe sur le smtp_server",
"en": "No mail server at all: Odoo falls back to smtp_server",
},
"from the config file. A blocking one is safer than none.": {
"fr": "du fichier de configuration. Un serveur bouchon vaut mieux que rien.",
"en": "from the config file. A blocking one is safer than none.",
},
"payment provider(s) are neither disabled nor": {
"fr": "fournisseur(s) de paiement ne sont ni désactivés ni",
"en": "payment provider(s) are neither disabled nor",
},
"in test mode.": {
"fr": "en mode test.",
"en": "in test mode.",
},
"Run": {
"fr": "Lancer",
"en": "Run",
},
"Enter to run it.": {
"fr": "Entrée pour le lancer.",
"en": "Enter to run it.",
},
"Not available for this source.": {
"fr": "Indisponible pour cette provenance.",
"en": "Not available for this source.",
},
"Migration leftovers": {
"fr": "Restant de migration",
"en": "Migration leftovers",
},
"What a migration left behind, judged without needing the step-by-step databases.": {
"fr": "Ce qu'une migration a laissé derrière elle, jugé sans avoir besoin des bases de palier.",
"en": "What a migration left behind, judged without needing the step-by-step databases.",
},
"It reads pg_catalog — indexes and real tables — which no RPC session exposes.": {
"fr": "Il lit pg_catalog — les index et les vraies tables — qu'aucune session RPC n'expose.",
"en": "It reads pg_catalog — indexes and real tables — which no RPC session exposes.",
},
"Every view someone changed, and every website copy that shadows a module view.": {
"fr": "Chaque vue que quelqu'un a modifiée, et chaque copie de site web qui masque une vue de module.",
"en": "Every view someone changed, and every website copy that shadows a module view.",
},
"Comparing a copy with the module view it hides is a join on arch_db, done in SQL.": {
"fr": "Comparer une copie à la vue de module qu'elle masque est une jointure sur arch_db, faite en SQL.",
"en": "Comparing a copy with the module view it hides is a join on arch_db, done in SQL.",
},
"Fields and models added outside any module, and which of them have no column behind them.": {
"fr": "Champs et modèles ajoutés hors de tout module, et lesquels n'ont aucune colonne derrière eux.",
"en": "Fields and models added outside any module, and which of them have no column behind them.",
},
"Telling a declared field from a real column means reading pg_attribute.": {
"fr": "Distinguer un champ déclaré d'une vraie colonne demande de lire pg_attribute.",
"en": "Telling a declared field from a real column means reading pg_attribute.",
},
"What a migration left behind, read in one database.": {
"fr": "Ce qu'une migration a laissé derrière elle, lu dans une seule base.",
"en": "What a migration left behind, read in one database.",
},
"Migration residue in": {
"fr": "Restant de migration dans",
"en": "Migration residue in",
},
"base module version:": {
"fr": "version du module base :",
"en": "base module version:",
},
"None of the checks found anything.": {
"fr": "Aucun contrôle n'a rien trouvé.",
"en": "None of the checks found anything.",
},
"This reads one database on its own — it cannot see": {
"fr": "Ceci ne lit qu'une base isolée — il ne peut pas voir",
"en": "This reads one database on its own — it cannot see",
},
"what an earlier step silently dropped.": {
"fr": "ce qu'un palier antérieur a laissé tomber en silence.",
"en": "what an earlier step silently dropped.",
},
"could not read": {
"fr": "lecture impossible",
"en": "could not read",
},
"no repair tool yet": {
"fr": "pas encore d'outil de réparation",
"en": "no repair tool yet",
},
"Modules stuck between two states": {
"fr": "Modules figés entre deux états",
"en": "Modules stuck between two states",
},
"A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.": {
"fr": "Une migration arrêtée en vol les laisse ainsi ; Odoo retentera la transition à chaque démarrage.",
"en": "A migration that stopped mid-flight leaves them there; Odoo will retry the transition at every start.",
},
"Languages whose active flag is NULL": {
"fr": "Langues dont le drapeau actif est NULL",
"en": "Languages whose active flag is NULL",
},
"A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.": {
"fr": "Un booléen qui n'est ni vrai ni faux : la langue n'est listée nulle part et ne peut plus être réactivée depuis l'interface.",
"en": "A boolean that is neither true nor false: the language is listed nowhere and cannot be re-enabled from the interface.",
},
"Many-to-many tables named but missing": {
"fr": "Tables plusieurs-à-plusieurs nommées mais absentes",
"en": "Many-to-many tables named but missing",
},
"ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.": {
"fr": "ir_model_relation nomme encore une table que PostgreSQL n'a pas ; la prochaine mise à jour de module tente de la modifier et échoue.",
"en": "ir_model_relation still names a table PostgreSQL does not have; the next module update tries to alter it and fails.",
},
"Indexes duplicated by the Odoo 17 renaming": {
"fr": "Index doublés par le renommage d'Odoo 17",
"en": "Indexes duplicated by the Odoo 17 renaming",
},
"Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write.": {
"fr": "Odoo 17 a changé la convention de nommage sans supprimer l'ancien index : les deux sont entretenus à chaque écriture.",
"en": "Odoo 17 changed the naming convention without dropping the old index: both are maintained on every write.",
},
"Default pricelist missing while product is installed": {
"fr": "Liste de prix par défaut absente alors que product est installé",
"en": "Default pricelist missing while product is installed",
},
"product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.": {
"fr": "product.list0 n'a été déclaré que jusqu'à Odoo 16 ; rien ne le recrée, et un devis n'a aucune liste de prix à choisir.",
"en": "product.list0 was declared up to Odoo 16 only; nothing recreates it, and a quotation has no price list to pick.",
},
"Views bound to a model that no longer exists": {
"fr": "Vues liées à un modèle qui n'existe plus",
"en": "Views bound to a model that no longer exists",
},
"Opening one raises; the menu that leads to it is a dead end.": {
"fr": "En ouvrir une lève une erreur ; le menu qui y mène est un cul-de-sac.",
"en": "Opening one raises; the menu that leads to it is a dead end.",
},
"Views inheriting a view that is gone": {
"fr": "Vues héritant d'une vue disparue",
"en": "Views inheriting a view that is gone",
},
"The whole inheritance chain below them stops rendering.": {
"fr": "Toute la chaîne d'héritage en dessous cesse de s'afficher.",
"en": "The whole inheritance chain below them stops rendering.",
},
"External ids pointing at a model that is gone": {
"fr": "Identifiants externes pointant un modèle disparu",
"en": "External ids pointing at a model that is gone",
},
"Every module update that resolves one of them fails.": {
"fr": "Chaque mise à jour de module qui en résout un échoue.",
"en": "Every module update that resolves one of them fails.",
},
"Attachments whose carrying field is gone": {
"fr": "Pièces jointes dont le champ porteur a disparu",
"en": "Attachments whose carrying field is gone",
},
"Odoo raises a KeyError merely checking them, and nothing will ever read them again.": {
"fr": "Odoo lève un KeyError rien qu'en les contrôlant, et plus rien ne les lira jamais.",
"en": "Odoo raises a KeyError merely checking them, and nothing will ever read them again.",
},
}

302
test/test_monitoring.py Normal file
View file

@ -0,0 +1,302 @@
#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Le monitoring : ce qu'il refuse, et ce qu'il avoue ne pas savoir.
Deux propriétés valent d'être épinglées, et ce ne sont pas les plus
visibles.
La première est un refus. Toutes les analyses du dépôt lisent par psql en
`default_transaction_read_only=on` : c'est le SERVEUR qui interdit
d'écrire. Une session RPC n'a pas d'équivalent — rien n'empêche un
`write` sur la production d'un client. La liste blanche est donc appliquée
dans le passe-plat lui-même, et non chez l'appelant, pour qu'aucune
analyse à venir ne puisse s'en dispenser par distraction.
La seconde est un aveu. Une provenance qui ne permet pas une analyse doit
le DIRE. Une analyse muette qu'on prend pour rassurante est pire que pas
d'analyse : c'est la faute que ce dépôt a déjà corrigée trois fois.
"""
import os
import sys
import unittest
sys.path.insert(
0, os.path.normpath(os.path.join(os.path.dirname(__file__), ".."))
)
from script.analyse import check_migration_residue as residue # noqa: E402
from script.analyse import monitoring # noqa: E402
from script.analyse import monitoring_tui # noqa: E402
class TestWhatTheProxyRefuses(unittest.TestCase):
"""Le passe-plat RPC n'accepte que la lecture."""
def test_a_write_is_refused_before_any_network_call(self):
# Aucune URL joignable ici : si l'appel partait, le test échouerait
# par timeout au lieu de PermissionError. C'est le contrôle qu'on
# veut — le refus vient AVANT le réseau.
for methode in ("write", "create", "unlink", "execute", "load"):
with self.assertRaises(PermissionError):
monitoring.live_call(
"http://127.0.0.1:1", "db", 1, "x", "res.partner", methode
)
def test_the_allowlist_holds_nothing_that_writes(self):
interdits = ("write", "create", "unlink", "copy", "load", "execute")
for methode in monitoring.RPC_READ_ONLY:
self.assertNotIn(methode, interdits)
def test_the_refusal_names_the_method(self):
with self.assertRaises(PermissionError) as capture:
monitoring.live_call(
"http://127.0.0.1:1", "db", 1, "x", "res.partner", "unlink"
)
self.assertIn("unlink", str(capture.exception))
class TestWhatASourceAdmits(unittest.TestCase):
"""Ce qu'une provenance ne permet pas doit être dit, pas caché."""
def test_every_analysis_says_why_it_cannot_do_live(self):
for analyse in monitoring.ANALYSES:
self.assertTrue(analyse["needs_sql"].strip(), analyse["key"])
def test_available_and_unavailable_cover_every_analysis(self):
for genre in (monitoring.KIND_DATABASE, monitoring.KIND_LIVE):
total = len(monitoring.available(genre)) + len(
monitoring.unavailable(genre)
)
self.assertEqual(total, len(monitoring.ANALYSES))
def test_a_restored_backup_and_a_database_are_the_same_thing(self):
"""Passé la restauration, la provenance ne se distingue plus."""
self.assertTrue(monitoring.available(monitoring.KIND_DATABASE))
def test_an_unknown_key_gives_none_rather_than_raising(self):
self.assertIsNone(monitoring.analysis_by_key("pas_une_analyse"))
def test_the_command_names_the_database(self):
analyse = monitoring.ANALYSES[0]
cmd = monitoring.command_for(analyse, "ma_base")
self.assertIn("-d", cmd)
self.assertEqual(cmd[cmd.index("-d") + 1], "ma_base")
self.assertIn(analyse["script"], cmd)
class TestWhetherTheNeutralisationTook(unittest.TestCase):
"""Poser la question et ne pas vérifier, c'est l'illusion mesurée."""
def test_a_missing_flag_is_reported_as_not_neutralised(self):
rapport = monitoring.neutralize_report(
{"flag": 0, "cron_active": 35, "mail_server": 0, "payment_live": 1}
)
self.assertIn("❌", rapport)
self.assertIn("35", rapport)
def test_no_mail_server_is_a_warning_and_never_a_reassurance(self):
"""Zéro serveur NE prouve PAS la sûreté : Odoo retombe sur la conf.
C'est pour cela que le `neutralize.sql` d'Odoo INSÈRE un serveur
bouchon au lieu de tout supprimer. Compter zéro et conclure « sûr »
se tromperait dans le mauvais sens.
"""
rapport = monitoring.neutralize_report(
{"flag": 1, "cron_active": 0, "mail_server": 0, "payment_live": 0}
)
self.assertIn("⚠", rapport)
self.assertIn("smtp_server", rapport)
def test_a_table_that_does_not_exist_is_not_counted_as_zero(self):
"""Absent n'est pas nul, et surtout pas une bonne nouvelle."""
appels = []
class FauxLib:
@staticmethod
def run_psql(database, sql, config_path=None):
appels.append(sql)
raise RuntimeError("relation does not exist")
# `from script.analyse import lib_analyse` lit l'ATTRIBUT du paquet :
# remplacer l'entrée de sys.modules ne change rien, et le vrai psql
# tourne. Le compteur d'appels est là pour le prouver — sans lui, ce
# test passait au vert avec ZÉRO appel bouchonné.
import script.analyse as paquet
sauvegarde = paquet.lib_analyse
paquet.lib_analyse = FauxLib
try:
etat = monitoring.neutralize_state("peu_importe")
finally:
paquet.lib_analyse = sauvegarde
for valeur in etat.values():
self.assertIsNone(valeur)
self.assertEqual(len(appels), len(monitoring.NEUTRALIZE_SQL))
def test_a_live_payment_provider_is_an_error_not_a_warning(self):
rapport = monitoring.neutralize_report(
{"flag": 1, "cron_active": 0, "mail_server": 1, "payment_live": 2}
)
ligne = [
texte
for texte in rapport.splitlines()
if "payment" in texte or "paiement" in texte
]
self.assertTrue(ligne)
self.assertTrue(ligne[0].startswith("❌"))
class TestTheChooserScreen(unittest.TestCase):
"""L'écran ne fait que choisir — mais il doit choisir juste."""
def test_every_analysis_is_shown_even_when_it_cannot_run(self):
lignes = monitoring_tui.rows(monitoring.KIND_LIVE)
self.assertEqual(len(lignes), len(monitoring.ANALYSES))
def test_an_unusable_line_carries_its_reason(self):
for _key, _label, utilisable, raison in monitoring_tui.rows(
monitoring.KIND_LIVE
):
if not utilisable:
self.assertTrue(raison.strip())
def test_a_usable_line_carries_no_excuse(self):
for _key, _label, utilisable, raison in monitoring_tui.rows(
monitoring.KIND_DATABASE
):
if utilisable:
self.assertEqual(raison, "")
def test_the_detail_pane_says_when_a_source_cannot_serve(self):
texte = monitoring_tui.detail(
monitoring.ANALYSES[0]["key"], monitoring.KIND_LIVE
)
self.assertIn("✖", texte)
def test_the_detail_pane_of_an_unknown_key_is_empty(self):
self.assertEqual(
monitoring_tui.detail("pas_une_analyse", monitoring.KIND_DATABASE),
"",
)
class TestWhatAMigrationLeftBehind(unittest.TestCase):
"""Le classement, et surtout ce qu'il refuse de taire."""
def test_broken_is_read_before_watch(self):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["duplicate_index"] = 400 # watch
resultats["lang_active_null"] = 1 # broken
trouve, _ = residue.judge(resultats)
self.assertEqual(trouve[0][0]["key"], "lang_active_null")
def test_a_check_that_could_not_run_is_not_a_check_that_found_nothing(
self,
):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["missing_pricelist"] = {"error": "relation absente"}
trouve, illisibles = residue.judge(resultats)
self.assertEqual(trouve, [])
self.assertEqual(len(illisibles), 1)
def test_the_report_shows_the_unreadable_ones(self):
resultats = {c["key"]: 0 for c in residue.CONTROLES}
resultats["missing_pricelist"] = {"error": "relation absente"}
texte = residue.render("base", resultats, colour=False)
self.assertIn("❔", texte)
self.assertIn("relation absente", texte)
def test_a_clean_database_states_what_it_could_not_see(self):
"""Un vert qui ne dit pas sa portée se lit comme une garantie."""
resultats = {c["key"]: 0 for c in residue.CONTROLES}
texte = residue.render("base", resultats, colour=False)
self.assertIn("✅", texte)
self.assertIn(
residue.t("This reads one database on its own — it cannot see"),
texte,
)
def test_every_finding_names_a_repair_or_says_there_is_none(self):
resultats = {c["key"]: 1 for c in residue.CONTROLES}
texte = residue.render("base", resultats, colour=False)
for controle in residue.CONTROLES:
attendu = controle["repair"] or residue.t("no repair tool yet")
self.assertIn(attendu, texte)
def test_each_check_is_declared_completely(self):
cles = set()
for controle in residue.CONTROLES:
for champ in ("key", "title", "why", "sql", "gravity"):
self.assertTrue(controle.get(champ), controle["key"])
self.assertIn(controle["gravity"], ("broken", "watch"))
self.assertNotIn(controle["key"], cles)
cles.add(controle["key"])
def test_each_query_reads_and_returns_one_number(self):
"""Une seule colonne, un seul SELECT : `inspect` en fait un int."""
for controle in residue.CONTROLES:
sql = " ".join(controle["sql"].split())
self.assertTrue(sql.upper().startswith("SELECT"), controle["key"])
for interdit in ("INSERT", "UPDATE", "DELETE", "DROP", "ALTER"):
self.assertNotIn(f" {interdit} ", f" {sql.upper()} ")
def test_the_exit_code_separates_nothing_from_something(self):
vide = {c["key"]: 0 for c in residue.CONTROLES}
self.assertEqual(residue.judge(vide)[0], [])
plein = dict(vide, lang_active_null=3)
self.assertTrue(residue.judge(plein)[0])
if __name__ == "__main__":
unittest.main()
class TestTheScreenAnswersKeys(unittest.TestCase):
"""Presser les touches, et pas seulement lire les fonctions pures.
`ListView` consomme Entrée pour émettre son propre `Selected` : la
liaison de l'application ne la voit jamais. L'écran ne répondait donc
pas à Entrée, et `rows()` comme `detail()` restaient parfaitement
justes — aucun test de fonction pure ne pouvait le montrer.
"""
def setUp(self):
try:
import textual # noqa: F401
except ImportError:
self.skipTest("Textual absent de cet interpréteur")
def _presser(self, kind, touches):
import asyncio
async def jouer():
app = monitoring_tui.build_app(kind, "ma_base")
async with app.run_test() as pilot:
for touche in touches:
await pilot.press(touche)
await pilot.pause()
return app.return_value
return asyncio.run(jouer())
def test_enter_chooses_the_highlighted_analysis(self):
self.assertEqual(
self._presser(monitoring.KIND_DATABASE, ["enter"]),
monitoring.ANALYSES[0]["key"],
)
def test_moving_down_then_enter_chooses_the_second(self):
self.assertEqual(
self._presser(monitoring.KIND_DATABASE, ["down", "enter"]),
monitoring.ANALYSES[1]["key"],
)
def test_enter_on_an_unusable_line_chooses_nothing(self):
"""Et ne lève pas : un écran qui plante vaut moins qu'un refus."""
self.assertIsNone(self._presser(monitoring.KIND_LIVE, ["enter"]))
def test_q_gives_up(self):
self.assertIsNone(self._presser(monitoring.KIND_DATABASE, ["q"]))

View file

@ -354,6 +354,32 @@ class TestQemuMenuNumbering(MenuCoherence, unittest.TestCase):
}
class TestAnalyseMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Analyse, qui n'avait aucun garde.
Il en a pourtant besoin plus que les autres : ses entrées sont
regroupées en cinq sections, et une section ne consomme pas de numéro.
Ajouter « Instance » avant la dernière entrée décalait tout ce qui
suivait sans que rien ne proteste.
"""
SOURCE = TODO_DIR / "todo.py"
ENTRY = "def prompt_execute_analyse(self):"
END = "def execute_analyse_module_package(self):"
MINIMUM = 5
EXPECTED = {
"Tables and database size": "execute_analyse_schema_size",
"Customised views": "execute_analyse_view_custom",
"Studio and hand-made": "execute_analyse_custom_field",
"Quality of a migration": "execute_analyse_migration_quality",
"Modules missing": "execute_analyse_module_package",
"Dependencies between": "execute_analyse_module_dependency",
"Attachment files missing": "execute_analyse_filestore",
"Monitoring - a backup": "execute_analyse_monitoring",
}
class TestProxmoxMenuNumbering(MenuCoherence, unittest.TestCase):
"""Le menu Proxmox : dix-huit entrées, le même piège.