diff --git a/script/analyse/check_migration_residue.py b/script/analyse/check_migration_residue.py new file mode 100755 index 0000000..d09e1b2 --- /dev/null +++ b/script/analyse/check_migration_residue.py @@ -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()) diff --git a/script/analyse/monitoring.py b/script/analyse/monitoring.py new file mode 100644 index 0000000..1f8afa6 --- /dev/null +++ b/script/analyse/monitoring.py @@ -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 + ) diff --git a/script/analyse/monitoring_tui.py b/script/analyse/monitoring_tui.py new file mode 100644 index 0000000..2802d43 --- /dev/null +++ b/script/analyse/monitoring_tui.py @@ -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() diff --git a/script/todo/todo.py b/script/todo/todo.py index 5f52c1c..1c4e651 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -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 = [ diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index e3e7b54..7ed7c8b 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -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.", + }, } diff --git a/test/test_monitoring.py b/test/test_monitoring.py new file mode 100644 index 0000000..4d1d756 --- /dev/null +++ b/test/test_monitoring.py @@ -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"])) diff --git a/test/test_todo_menu.py b/test/test_todo_menu.py index 1852e20..f88855f 100644 --- a/test/test_todo_menu.py +++ b/test/test_todo_menu.py @@ -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.