377 lines
13 KiB
Python
377 lines
13 KiB
Python
|
|
#!/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())
|