erplibre/script/analyse/check_instance_state.py
Mathieu Benoit 9a7b8cb36f [ADD] analyse: l'état d'une instance, lu pour l'usage qu'on en fait
Le même chiffre veut dire deux choses opposées. Zéro cron actif est le
succès attendu d'une copie et une panne totale sur une production. Un
rapport qui ignore cela crie au loup sur ce qu'on vient de demander, et
l'on cesse de le lire. L'attente est donc déclarée, copy ou live, et
chaque contrôle dit ce qu'il juge sous l'une et sous l'autre.

Deux contrôles ont été ÉCARTÉS sous copy après mesure : sur la base 12
d'origine, jamais démarrée, 11 crons étaient déjà en retard et db_backup
déjà vide — notre propre update_prod_to_dev les efface. Les afficher en
rouge aurait été du bruit ; en vert, un mensonge. Ils sont montrés non
jugés, avec la raison.

Le code Python en base a été mesuré et abandonné : 132 actions serveur,
zéro citant un modèle inexistant, et les 3 « modèles sans table » sont
ir.autovacuum et deux autres modèles abstraits d'Odoo.

--- EN ---

The same number means two opposite things. Zero active cron is the
expected success of a copy and a total outage on production. A report
that ignores this cries wolf over what was just requested, and stops
being read. The expectation is therefore declared, copy or live, and each
check states what it judges under either.

Two checks were DROPPED under copy after measuring: on the untouched 12
source database, 11 crons were already late and db_backup already empty —
our own update_prod_to_dev deletes them. Red would have been noise; green
a lie. They are shown unjudged, with the reason.

In-database Python was measured and dropped: 132 server actions, none
naming a missing model, and the 3 "models without a table" are
ir.autovacuum and two other Odoo abstract models.

Assisted-by: Claude Opus 5
2026-08-25 03:28:39 -04:00

376 lines
13 KiB
Python
Executable file
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
"""Dans quel état est cette instance — et pour quel usage on la destine.
Le même chiffre veut dire deux choses opposées. Zéro cron actif est le
SUCCÈS attendu d'une copie de développement, et une panne totale sur une
production. Zéro serveur de courriel rassure sur l'une, condamne l'autre.
Un rapport qui ignore cette différence crie au loup sur ce que l'on vient
de demander, et l'on cesse alors de le lire — c'est la faute que ce dépôt
a déjà corrigée trois fois.
L'attente est donc DÉCLARÉE, `--expect copy` ou `--expect live`, et
chaque contrôle dit ce qu'il juge sous l'une et sous l'autre. Ce qui n'a
pas de sens sous l'attente courante n'est pas affiché en vert : il est
affiché comme non jugé, avec la raison.
Mesuré, et c'est ce qui a fixé la conception
--------------------------------------------
Sur la base 12 d'ORIGINE et sur sa migrée 18, jamais démarrées :
crons en retard de plus d'un cycle 11 et 8
lignes db_backup 0 et 0
Le retard n'a rien à voir avec la migration : personne ne fait tourner le
cadenceur d'une base restaurée. Sous `--expect copy`, ces deux contrôles
ne sont donc pas jugés du tout. Les afficher en rouge aurait été du bruit
pur ; les afficher en vert, un mensonge.
Ce qui se juge SOUS LES DEUX
----------------------------
La neutralisation, elle, se mesure. Sur sept bases dont le nom portait
« neutralize », `database.is_neutralized` était absent des sept, avec
jusqu'à 35 crons actifs et le domaine de courriel du CLIENT en place.
On ne lit que des BOOLÉENS de présence pour tout ce qui touche à un
secret. Une clé de paiement vivante a été trouvée dans une base de test ;
un rapport finit dans un billet ou devant un agent, et n'a aucune raison
de la porter.
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
COPY = "copy"
LIVE = "live"
ATTENTES = (COPY, LIVE)
COULEURS = {
"broken": "\033[31m",
"watch": "\033[33m",
"ok": "\033[32m",
"dim": "\033[90m",
}
RESET = "\033[0m"
def paint(texte, genre, colour):
if not colour:
return texte
return f"{COULEURS.get(genre, '')}{texte}{RESET}"
# Une politique dit COMMENT lire le nombre sous une attente donnée :
# ("zero", gravité) il doit valoir zéro
# ("nonzero", gravité) il doit être non nul
# ("info",) on montre, on ne juge pas
# ("skip", raison) sans objet ici, et l'on DIT pourquoi
CONTROLES = (
{
"key": "neutralized",
"section": "Neutralisation",
"title": "database.is_neutralized",
"sql": "SELECT count(*) FROM ir_config_parameter"
" WHERE key='database.is_neutralized'"
" AND value IN ('true','True','1')",
"copy": ("nonzero", "broken"),
"live": ("zero", "broken"),
"why_copy": "Odoo does not consider this database neutralised;"
" modules that neutralise themselves never ran.",
"why_live": "A production marked as neutralised has had its"
" scheduled actions and outgoing mail disabled.",
},
{
"key": "mail_server_open",
"section": "Neutralisation",
"title": "Mail servers that could actually send",
"sql": "SELECT count(*) FROM ir_mail_server WHERE active"
" AND coalesce(smtp_host,'') NOT IN ('invalid','localhost.invalid')",
"copy": ("zero", "broken"),
"live": ("info",),
"why_copy": "A copy that can send reaches the customer's real"
" contacts.",
"why_live": "",
},
{
"key": "mail_server_total",
"section": "Neutralisation",
"title": "Mail servers declared at all",
"sql": "SELECT count(*) FROM ir_mail_server",
"copy": ("nonzero", "watch"),
"live": ("nonzero", "watch"),
"why_copy": "With NO server, Odoo falls back to smtp_server from"
" the config file — which is why Odoo's own neutralize.sql"
" INSERTS a blocking one instead of deleting them all.",
"why_live": "With no server at all, Odoo silently uses the"
" smtp_server from the config file.",
},
{
"key": "payment_live",
"section": "Neutralisation",
"title": "Payment providers neither disabled nor in test",
"sql": "SELECT count(*) FROM payment_provider"
" WHERE state NOT IN ('disabled','test')",
"copy": ("zero", "broken"),
"live": ("info",),
"why_copy": "A copy can charge real cards.",
"why_live": "",
},
{
"key": "url_mismatch",
"section": "Neutralisation",
"title": "Mail domain that does not match the base URL",
"sql": "SELECT CASE WHEN EXISTS (SELECT 1 FROM ir_config_parameter c"
" JOIN ir_config_parameter u ON u.key='web.base.url'"
" WHERE c.key='mail.catchall.domain'"
" AND position(c.value in u.value) = 0) THEN 1 ELSE 0 END",
"copy": ("info",),
"live": ("zero", "watch"),
"why_copy": "",
"why_live": "Portal links, invoice QR codes and reply addresses"
" point somewhere else than the instance itself.",
},
{
"key": "cron_active",
"section": "Scheduler",
"title": "Scheduled actions active",
"sql": "SELECT count(*) FROM ir_cron WHERE active",
"copy": ("zero", "watch"),
"live": ("nonzero", "broken"),
"why_copy": "A copy that still runs them sends mail and posts"
" payments.",
"why_live": "Nothing at all runs on a schedule.",
},
{
"key": "cron_late",
"section": "Scheduler",
"title": "Scheduled actions late by more than one cycle",
"sql": "SELECT count(*) FROM ir_cron c WHERE c.active"
" AND c.nextcall <"
" now() - (c.interval_number || ' ' || c.interval_type)::interval",
"copy": (
"skip",
"Nobody runs the scheduler on a restored copy:"
" measured 11 late on an untouched source database.",
),
"live": ("zero", "broken"),
"why_copy": "",
"why_live": "A fixed threshold misses them — a ten-minute job one"
" hour late matters, a monthly one does not.",
},
{
"key": "backup_rows",
"section": "Backups",
"title": "Backup configurations recorded",
"sql": "SELECT count(*) FROM db_backup",
"copy": (
"skip",
"update_prod_to_dev deletes them on purpose;"
" their absence here proves nothing.",
),
"live": ("nonzero", "broken"),
"why_copy": "",
"why_live": "The backup scheduler runs and backs up nothing.",
},
{
"key": "mail_stuck",
"section": "Queues",
"title": "Messages stuck in the outgoing queue",
"sql": "SELECT count(*) FROM mail_mail"
" WHERE state IN ('outgoing','exception')",
"copy": ("info",),
"live": ("zero", "watch"),
"why_copy": "",
"why_live": "Visible to the customer, invisible to the operator.",
},
{
"key": "all_internal_are_admin",
"section": "Users",
"title": "Internal users who are ALL system administrators",
"sql": "SELECT CASE WHEN (SELECT count(*) FROM res_users u"
" WHERE u.active AND EXISTS (SELECT 1 FROM res_groups_users_rel r"
" JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid"
" WHERE r.uid=u.id AND d.module='base' AND d.name='group_user'))"
" = (SELECT count(*) FROM res_users u WHERE u.active"
" AND EXISTS (SELECT 1 FROM res_groups_users_rel r"
" JOIN ir_model_data d ON d.model='res.groups' AND d.res_id=r.gid"
" WHERE r.uid=u.id AND d.module='base' AND d.name='group_system'))"
" THEN 1 ELSE 0 END",
"copy": ("zero", "watch"),
"live": ("zero", "watch"),
"why_copy": "No ordinary user exists to test visibility with — any"
" access-rights check run here proves nothing.",
"why_live": "Everyone can change everything, and no rule is ever"
" exercised.",
},
)
SECTIONS = ("Neutralisation", "Scheduler", "Backups", "Queues", "Users")
def inspect(database, config_path=None):
"""Passer chaque contrôle. Un contrôle illisible n'est PAS un zéro."""
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 verdict(controle, valeur, attente):
"""(genre, gravité, raison) pour ce contrôle sous cette attente.
`genre` vaut ok, bad, info, skip ou unreadable — jamais autre chose,
et surtout jamais « ok » par défaut : un contrôle sans politique est un
oubli, pas une bonne nouvelle.
"""
if isinstance(valeur, dict):
return "unreadable", "watch", valeur.get("error", "")
politique = controle.get(attente)
if not politique:
return "skip", "dim", ""
genre = politique[0]
if genre == "skip":
return "skip", "dim", t(politique[1])
if genre == "info":
return "info", "dim", ""
raison = t(controle.get(f"why_{attente}", "") or "")
if genre == "zero":
return (
("ok", "ok", "") if not valeur else ("bad", politique[1], raison)
)
if genre == "nonzero":
return ("ok", "ok", "") if valeur else ("bad", politique[1], raison)
return "skip", "dim", ""
def findings(resultats, attente):
"""Ce qui ne va pas, et rien d'autre — pour le code de sortie."""
mauvais = []
for controle in CONTROLES:
genre, gravite, raison = verdict(
controle, resultats.get(controle["key"]), attente
)
if genre == "bad":
mauvais.append((controle, gravite, raison))
return mauvais
def render(database, resultats, attente, colour=True):
"""Le rapport, section par section, dans l'ordre déclaré."""
entete = (
t("a development copy") if attente == COPY else t("a live instance")
)
lignes = [
f"🩺 {t('State of')} {database} — {t('read as')} {entete}",
"",
]
for section in SECTIONS:
corps = []
for controle in CONTROLES:
if controle["section"] != section:
continue
valeur = resultats.get(controle["key"])
genre, gravite, raison = verdict(controle, valeur, attente)
nombre = "?" if isinstance(valeur, dict) else str(valeur)
icone = {
"ok": "✅",
"bad": "❌" if gravite == "broken" else "⚠",
"info": "ℹ️ ",
"skip": "·",
"unreadable": "❔",
}[genre]
corps.append(
paint(
f" {icone} {nombre.rjust(5)} {t(controle['title'])}",
gravite if genre in ("bad", "ok") else "dim",
colour,
)
)
if raison:
corps.append(paint(f" {raison}", "dim", colour))
if corps:
lignes.append(t(section))
lignes.extend(corps)
lignes.append("")
return "\n".join(lignes).rstrip()
def main(argv=None):
import argparse
import json
parser = argparse.ArgumentParser(
description=t("What state an instance is in, for its intended use."),
)
parser.add_argument("-d", "--database", required=True)
parser.add_argument("-c", "--config", help="odoo config file")
parser.add_argument(
"--expect",
choices=ATTENTES,
default=COPY,
help="copy: a restored development copy. live: a running instance.",
)
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)
mauvais = findings(resultats, args.expect)
if args.json:
print(
json.dumps(
{
"database": args.database,
"expect": args.expect,
"checks": resultats,
"bad": [c["key"] for c, _, _ in mauvais],
},
indent=2,
ensure_ascii=False,
)
)
else:
colour = sys.stdout.isatty() and not args.no_color
print(render(args.database, resultats, args.expect, colour))
return 1 if mauvais else 0
if __name__ == "__main__":
sys.exit(main())