[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:
parent
f8bd1c30df
commit
2521267896
7 changed files with 1661 additions and 0 deletions
329
script/analyse/check_migration_residue.py
Executable file
329
script/analyse/check_migration_residue.py
Executable 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())
|
||||
306
script/analyse/monitoring.py
Normal file
306
script/analyse/monitoring.py
Normal 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
|
||||
)
|
||||
191
script/analyse/monitoring_tui.py
Normal file
191
script/analyse/monitoring_tui.py
Normal 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()
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
302
test/test_monitoring.py
Normal 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"]))
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue