The report named the sitemap URL, never the one the redirect chain ended on. On this site every page goes through two or three hops -- measured, 146 for 55 pages, between the language prefix and the canonical slug -- so a 500 at the end was reported against a page that answers perfectly well. One goes and checks it, finds it healthy, and concludes the tool is wrong. urllib carries the answer: HTTPError.url is the URL that produced the error, not the one requested. fetch now returns it and the report shows it when it differs. A timeout was never reported as 500 -- fetch returns 0 and the report writes "no answer" -- but the evidence for a real 500 did evaporate: the Odoo log was opened with "w", so replaying the test erased the trace of the failure one had just seen. One generation is kept now. --- FR --- Le rapport nommait l'URL du sitemap, jamais celle où la chaîne de redirections aboutit. Sur ce site chaque page en traverse deux ou trois — mesuré, 146 pour 55 pages — donc un 500 au bout était imputé à une page qui répond très bien. On va la vérifier, on la trouve saine, et l'on conclut que l'outil se trompe. urllib porte la réponse : HTTPError.url est l'URL qui a produit l'erreur. `fetch` la rend, et le rapport l'affiche quand elle diffère. Un dépassement de délai n'a jamais été rendu comme un 500 — `fetch` rend 0, écrit « aucune réponse » — mais la preuve d'un vrai 500 s'évaporait : le journal Odoo était ouvert en « w », donc rejouer le test effaçait la trace qu'on venait de voir. Une génération est gardée. Assisted-by: Claude Opus 5
944 lines
33 KiB
Python
Executable file
944 lines
33 KiB
Python
Executable file
#!/usr/bin/env python3
|
||
# © 2021-2026 TechnoLibre (http://www.technolibre.ca)
|
||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
||
"""Request every public URL of a migrated database, and report what breaks.
|
||
|
||
Why this exists
|
||
---------------
|
||
A migration can finish, load every module, and still serve a 500 on a page
|
||
nobody thought to open. Measured on a real one: ``/blog/<blog>/post/<post>``
|
||
answered 500 because a COW copy frozen on the previous version no longer held
|
||
the section a child view xpaths into. Nothing in the migration log said so —
|
||
the module loading had succeeded.
|
||
|
||
Where the list comes from
|
||
-------------------------
|
||
``/sitemap.xml``: the list Odoo itself publishes for search engines, built by
|
||
``website.enumerate_pages()``. It covers the controller routes declared with
|
||
``sitemap=True`` and the records behind them — pages, blog posts, products.
|
||
|
||
It cannot be read from ``odoo-bin shell``: ``enumerate_pages()`` asks for
|
||
``http.root.get_db_router(request.db)`` and raises « object unbound » without
|
||
a real request. So the server is started, asked, and stopped.
|
||
|
||
What counts as a failure
|
||
------------------------
|
||
Any status >= 400. The sitemap is the PUBLIC list: a page listed there and
|
||
answering 403 or 404 is as wrong as one answering 500, just less loud.
|
||
|
||
Exit codes: 0 every URL answered, 1 some failed, 2 the tool failed.
|
||
"""
|
||
|
||
import argparse
|
||
import os
|
||
import re
|
||
import signal
|
||
import socket
|
||
import subprocess
|
||
import sys
|
||
import time
|
||
import urllib.error
|
||
import urllib.parse
|
||
import urllib.request
|
||
|
||
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
|
||
|
||
|
||
try:
|
||
from script.todo import auto_ask
|
||
except Exception: # pragma: no cover - repli si le pilote est absent
|
||
auto_ask = None
|
||
|
||
|
||
def can_ask():
|
||
"""Peut-on poser une question ICI ?
|
||
|
||
Il faut deux choses, pas une : de quoi LIRE la réponse (stdin sur un
|
||
terminal) et de quoi MONTRER la question (stdout aussi). Ne tester que
|
||
stdin laisse poser une invite qui part dans un tube : elle reste en
|
||
tampon, invisible, pendant que le processus attend — on croit à un
|
||
blocage et l'on tape Entrée à l'aveugle.
|
||
"""
|
||
return sys.stdin.isatty() and sys.stdout.isatty()
|
||
|
||
|
||
RE_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.I)
|
||
|
||
# « [view_id: 3282, xml_id: n/a, model: n/a, parent_id: 3281] ». Seule la
|
||
# phrase qui précède est traduite ; cette ligne-ci ne l'est pas, donc la lire
|
||
# marche dans les deux langues. Le PARENT est le coupable : c'est la copie
|
||
# figée dans laquelle l'enfant ne trouve plus son xpath.
|
||
RE_CONTEXT = re.compile(r"\[view_id: (\d+),.*?parent_id: (\d+)\]")
|
||
|
||
# « Template: website.submenu ». Une QWebException de RENDU ne porte pas le
|
||
# bloc [view_id …] : elle nomme le gabarit. Mesuré au palier 17 — une copie
|
||
# figée appelait `submenu.clean_url()`, méthode renommée `_clean_url()` dans
|
||
# la version, et 34 URL sur 37 rendaient 500 sans que rien ne désigne la vue
|
||
# fautive.
|
||
RE_TEMPLATE = re.compile(r"^Template:\s*([\w.]+)\s*$", re.M)
|
||
|
||
# Un port à part : la migration tourne souvent à côté d'une instance vivante,
|
||
# et lui voler 8069 ferait échouer le test pour une raison sans rapport.
|
||
DEFAULT_PORT = 8169
|
||
|
||
|
||
def run_psql(database, sql):
|
||
"""Lecture seule, garantie par le serveur PostgreSQL lui-même."""
|
||
env = os.environ.copy()
|
||
env["PGOPTIONS"] = "-c default_transaction_read_only=on"
|
||
env["PSQLRC"] = ""
|
||
done = subprocess.run(
|
||
["psql", "-X", "-w", "-d", database, "-tAF", "\x1f", "-c", sql],
|
||
capture_output=True,
|
||
text=True,
|
||
env=env,
|
||
)
|
||
if done.returncode:
|
||
return []
|
||
return [line.split("\x1f") for line in done.stdout.splitlines() if line]
|
||
|
||
|
||
def require_matching_version(database):
|
||
"""Refuser de démarrer sur une base d'une autre version qu'un Odoo.
|
||
|
||
Sans cela le rapport ment de la pire façon : mesuré ici même, un
|
||
checkout passé en 18.0 démarré sur une base 17.0 rend 500 sur les
|
||
trente-sept URL et sur /web/login, et l'on conclut à un site
|
||
entièrement cassé alors que rien ne l'est. Le checkout suit la
|
||
migration ; rien ne garantit qu'il soit resté sur la version de la
|
||
base qu'on veut interroger.
|
||
|
||
On délègue à `database_cleanup`, qui porte déjà cette garde : deux
|
||
implémentations divergeraient, et une garde qui diverge ne garde rien.
|
||
"""
|
||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||
try:
|
||
from database_cleanup import require_matching_version as verifier
|
||
except ImportError:
|
||
return None
|
||
return verifier(database)
|
||
|
||
|
||
def start_server(database, port, config_path="./config.conf", log_path=None):
|
||
"""Démarrer Odoo, son journal dans un FICHIER.
|
||
|
||
Pas un tube : Python bufferise par blocs quand sa sortie n'est pas un
|
||
terminal, et un fil de lecture court alors après des lignes qui n'ont
|
||
pas encore été écrites. Mesuré — 24 lignes vues sur 198, et la trace qui
|
||
nomme la vue fautive faisait partie des absentes. Un fichier se relit
|
||
entièrement, quand on veut.
|
||
"""
|
||
# Garder l'exécution PRÉCÉDENTE. Le journal était ouvert en « w » :
|
||
# relancer le test effaçait la trace de l'échec qu'on venait de voir,
|
||
# et il ne restait plus rien à examiner. Une seule génération suffit —
|
||
# c'est celle d'avant qu'on vient chercher.
|
||
if log_path and os.path.isfile(log_path):
|
||
try:
|
||
os.replace(log_path, log_path + ".1")
|
||
except OSError:
|
||
pass
|
||
handle = open(log_path, "w", encoding="utf-8") if log_path else None
|
||
server = subprocess.Popen(
|
||
[
|
||
"./run.sh",
|
||
"-c",
|
||
config_path,
|
||
"-d",
|
||
database,
|
||
"--http-port",
|
||
str(port),
|
||
"--log-level=warn",
|
||
# Deux lignes par requête, et elles portent le CHEMIN. Sans
|
||
# elles une trace du journal ne peut être rattachée à rien :
|
||
# on l'attribuait à la dernière requête, qui n'était pas la
|
||
# sienne. Mesuré — /my accusé d'une panne appartenant à une
|
||
# application testée après lui.
|
||
"--log-handler=werkzeug:INFO",
|
||
],
|
||
stdout=handle or subprocess.DEVNULL,
|
||
stderr=subprocess.STDOUT,
|
||
text=True,
|
||
# Son propre groupe de processus : « ./run.sh » est un script bash
|
||
# qui ne transmet rien à son enfant. Un terminate() sur lui tuait le
|
||
# script et laissait odoo-bin vivant, tenant le port. L'essai suivant
|
||
# démarrait alors un serveur qui ne pouvait pas se lier, et
|
||
# interrogeait sans le savoir CELUI D'AVANT — mesuré, deux fois.
|
||
start_new_session=True,
|
||
)
|
||
server.erplibre_log = handle
|
||
return server
|
||
|
||
|
||
def read_log(log_path):
|
||
"""Le journal du serveur, tel qu'écrit jusqu'ici."""
|
||
if not log_path or not os.path.isfile(log_path):
|
||
return []
|
||
with open(log_path, "r", encoding="utf-8", errors="replace") as handle:
|
||
return handle.read().splitlines()
|
||
|
||
|
||
def fetch(url, timeout=30):
|
||
"""(statut, corps, url finale). Statut 0 si la connexion échoue.
|
||
|
||
L'URL FINALE, pas seulement celle qu'on a demandée. Sur ce site
|
||
chaque page traverse deux ou trois redirections — mesuré, 146 pour
|
||
55 pages — et quand la dernière rend 500, l'outil nommait la
|
||
première. On allait vérifier une page saine et l'on concluait que le
|
||
test se trompait.
|
||
"""
|
||
try:
|
||
with urllib.request.urlopen(url, timeout=timeout) as answer:
|
||
return (
|
||
answer.getcode(),
|
||
answer.read().decode("utf-8", errors="replace"),
|
||
answer.geturl(),
|
||
)
|
||
except urllib.error.HTTPError as exc:
|
||
return (
|
||
exc.code,
|
||
exc.read().decode("utf-8", errors="replace"),
|
||
exc.url or url,
|
||
)
|
||
except Exception:
|
||
return 0, "", url
|
||
|
||
|
||
def wait_ready(base_url, timeout=180, sleep=2):
|
||
"""Attendre que le serveur réponde. False s'il n'est jamais venu."""
|
||
deadline = time.time() + timeout
|
||
while time.time() < deadline:
|
||
status, _body, _fin = fetch(base_url + "/web/login", timeout=5)
|
||
if status:
|
||
return True
|
||
time.sleep(sleep)
|
||
return False
|
||
|
||
|
||
def sitemap_urls(base_url):
|
||
"""Les URL du sitemap, index compris, ramenées sur l'hôte local.
|
||
|
||
Le sitemap porte le domaine du site (technolibre.ca) ; on teste une base
|
||
servie en local. Garder le domaine ferait interroger la production —
|
||
c'est le genre d'erreur qui ne se voit qu'après.
|
||
"""
|
||
status, body, _fin = fetch(base_url + "/sitemap.xml")
|
||
if not status or status >= 400:
|
||
return [], status
|
||
lst_loc = RE_LOC.findall(body)
|
||
# Un index de sitemaps ne contient que des sitemaps : on descend d'un cran.
|
||
if "<sitemapindex" in body.lower():
|
||
lst_page = []
|
||
for loc in lst_loc:
|
||
_status, sub, _fin = fetch(local_url(base_url, loc))
|
||
lst_page.extend(RE_LOC.findall(sub))
|
||
lst_loc = lst_page
|
||
seen, lst_url = set(), []
|
||
for loc in lst_loc:
|
||
url = local_url(base_url, loc)
|
||
if url not in seen:
|
||
seen.add(url)
|
||
lst_url.append(url)
|
||
return lst_url, status
|
||
|
||
|
||
def local_url(base_url, loc):
|
||
"""Remplacer le schéma et l'hôte du sitemap par ceux qu'on teste."""
|
||
parsed = urllib.parse.urlparse(loc)
|
||
path = parsed.path or "/"
|
||
if parsed.query:
|
||
path += "?" + parsed.query
|
||
return base_url.rstrip("/") + path
|
||
|
||
|
||
# Odoo écrit sa trace APRÈS avoir répondu, et par le tube d'un script shell :
|
||
# mesuré, elle peut arriver près de trois secondes plus tard. Une demi-seconde
|
||
# concluait « aucune vue en cause » sur des pages qui en nommaient une.
|
||
LOG_DELAY = 3.0
|
||
|
||
|
||
def attach_missing_parents(lst_failure, lst_log):
|
||
"""Repêcher les contextes arrivés trop tard pour leur tranche.
|
||
|
||
Le découpage par URL est une commodité, pas une garantie : le journal est
|
||
asynchrone. Ce second passage lit TOUT ce qui a été capturé et rattache
|
||
ce qui n'avait été rattaché à rien — mieux vaut un coupable mal attribué
|
||
qu'un coupable perdu.
|
||
"""
|
||
known = {pid for _u, _s, lst, _f in lst_failure for pid in lst}
|
||
extra = []
|
||
for line in lst_log:
|
||
# LES DEUX : le parent ET l'enfant. Mesuré sur /contactus — le
|
||
# parent était identique à sa vue module, et c'est l'ENFANT qui
|
||
# portait l'arch périmée. Ne proposer que le parent envoyait
|
||
# réinitialiser une copie qui allait déjà bien.
|
||
for view_id, parent_id in RE_CONTEXT.findall(line):
|
||
for candidate in (parent_id, view_id):
|
||
if candidate not in known and candidate not in extra:
|
||
extra.append(candidate)
|
||
if not extra:
|
||
return lst_failure
|
||
rebuilt = []
|
||
placed = False
|
||
for url, status, lst_parent, finale in lst_failure:
|
||
if not lst_parent and not placed:
|
||
lst_parent = list(extra)
|
||
placed = True
|
||
rebuilt.append((url, status, lst_parent, finale))
|
||
if not placed and rebuilt:
|
||
url, status, lst_parent, finale = rebuilt[0]
|
||
rebuilt[0] = (url, status, lst_parent + extra, finale)
|
||
return rebuilt
|
||
|
||
|
||
def check_urls(lst_url, timeout=30):
|
||
"""[(url, statut, [vues], url finale)] pour celles qui ont échoué.
|
||
|
||
TOUJOURS quatre éléments, le dernier étant l'URL réellement atteinte.
|
||
Un tuple de taille variable obligerait chaque lecteur à s'en méfier.
|
||
|
||
Les vues en cause sont rattachées après coup, en relisant le journal du
|
||
serveur : elles y arrivent quand Odoo vide son tampon, pas quand la
|
||
requête revient.
|
||
"""
|
||
lst_failure = []
|
||
for url in lst_url:
|
||
status, _body, finale = fetch(url, timeout=timeout)
|
||
if status == 0 or status >= 400:
|
||
lst_failure.append((url, status, [], finale))
|
||
return lst_failure
|
||
|
||
|
||
def template_keys(lst_log, database):
|
||
"""Les gabarits nommés par une QWebException, s'ils ont une copie COW.
|
||
|
||
Nommer une clé sans copie enverrait réinitialiser une vue module — donc
|
||
ne rien faire, en silence. On ne propose que ce qui peut l'être.
|
||
"""
|
||
lst_key = []
|
||
for line in lst_log:
|
||
for key in RE_TEMPLATE.findall(line):
|
||
if key not in lst_key:
|
||
lst_key.append(key)
|
||
if not lst_key:
|
||
return []
|
||
quoted = ",".join("'" + k.replace("'", "''") + "'" for k in lst_key)
|
||
rows = run_psql(
|
||
database,
|
||
"SELECT DISTINCT key FROM ir_ui_view"
|
||
f" WHERE website_id IS NOT NULL AND key IN ({quoted});",
|
||
)
|
||
with_copy = {row[0] for row in rows if row and row[0]}
|
||
return [key for key in lst_key if key in with_copy]
|
||
|
||
|
||
def culprit_keys(database, lst_failure):
|
||
"""Les clés des vues parentes mises en cause, sans doublon.
|
||
|
||
C'est ce qu'on passe à reset_stale_cow_views : il travaille par clé, et
|
||
relever un identifiant dans une trace pour le traduire à la main est
|
||
exactement la recopie où l'on se trompe.
|
||
"""
|
||
lst_id = []
|
||
for _url, _status, lst_parent, _finale in lst_failure:
|
||
for parent_id in lst_parent:
|
||
if parent_id not in lst_id:
|
||
lst_id.append(parent_id)
|
||
if not lst_id:
|
||
return []
|
||
ids = ",".join(str(int(x)) for x in lst_id)
|
||
rows = run_psql(
|
||
database,
|
||
f"SELECT id, key, website_id FROM ir_ui_view WHERE id IN ({ids})"
|
||
" AND key IS NOT NULL ORDER BY id;",
|
||
)
|
||
lst_key = []
|
||
for row in rows:
|
||
if len(row) >= 2 and row[1] and row[1] not in lst_key:
|
||
lst_key.append(row[1])
|
||
return lst_key
|
||
|
||
|
||
def render(lst_url, lst_failure, lst_key=None):
|
||
if not lst_url:
|
||
return f"⚠️ {t('The sitemap listed no URL: nothing was tested.')}\n"
|
||
if not lst_failure:
|
||
return (
|
||
f"✅ -> {len(lst_url)} {t('public URL(s) answered without error.')}"
|
||
"\n"
|
||
)
|
||
lines = [
|
||
f"❌ {len(lst_failure)} {t('of')} {len(lst_url)}"
|
||
f" {t('public URL(s) failed')} :"
|
||
]
|
||
for url, status, lst_parent, finale in lst_failure:
|
||
label = status or t("no answer")
|
||
lines.append(f" [{label}] {url}")
|
||
# L'URL du sitemap n'est pas celle qui a échoué quand une
|
||
# redirection s'est interposée. Ne montrer que la première
|
||
# envoyait vérifier une page saine.
|
||
if finale and finale != url:
|
||
lines.append(f" → {t('failed at')} {finale}")
|
||
if lst_parent:
|
||
lines.append(
|
||
f" {t('parent view(s) in cause')} :"
|
||
f" {', '.join(lst_parent)}"
|
||
)
|
||
if lst_key:
|
||
lines.append(
|
||
f" {t('Those parents are copies frozen on an older version;')}"
|
||
f" {t('resetting them onto the module view is the fix')} :"
|
||
)
|
||
for key in lst_key:
|
||
lines.append(f" {key}")
|
||
lines.append(
|
||
f" {t('A page listed for search engines that does not answer is')}"
|
||
f" {t('a page your visitors do not reach either.')}"
|
||
)
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def apply_reset(database, lst_key):
|
||
"""Réinitialiser ces copies sur leur vue module. ÉCRIT en base.
|
||
|
||
On délègue à reset_stale_cow_views : il sauvegarde l'arch précédente
|
||
avant d'écrire, et c'est déjà lui qu'on documente partout ailleurs. En
|
||
refaire une seconde version ici, c'est se donner deux comportements à
|
||
tenir d'accord.
|
||
"""
|
||
cmd = [
|
||
sys.executable,
|
||
os.path.join(
|
||
"script", "odoo", "migration", "reset_stale_cow_views.py"
|
||
),
|
||
"-d",
|
||
database,
|
||
]
|
||
for key in lst_key:
|
||
cmd += ["--reset", key]
|
||
cmd.append("--apply")
|
||
done = subprocess.run(cmd, capture_output=True, text=True)
|
||
return done.returncode, done.stdout + done.stderr
|
||
|
||
|
||
DEFAULT_ANSWER = "a"
|
||
|
||
|
||
def prompt(database, lst_failure, lst_key, ask=None):
|
||
"""Proposer de corriger, puis dire ce qu'il reste. Rend les clés traitées.
|
||
|
||
Détecter sans offrir le geste, c'est laisser relever des identifiants
|
||
dans une trace pour les traduire en clés à la main — au moment précis où
|
||
l'on veut juste que la page réponde.
|
||
"""
|
||
if not lst_key:
|
||
print(f"ℹ -> {t('No parent view named: nothing to offer.')}")
|
||
return []
|
||
if ask is None:
|
||
# Lancé à part par la migration : sans lecteur temporisé, cette
|
||
# question arrêtait net une exécution automatique.
|
||
ask = (
|
||
auto_ask.make_ask(DEFAULT_ANSWER)
|
||
if auto_ask
|
||
else (lambda prompt="": input(prompt) or DEFAULT_ANSWER)
|
||
)
|
||
print(f"\n✨ {t('Copies to reset onto their module view')} :")
|
||
for index, key in enumerate(lst_key, start=1):
|
||
print(f" [{index}] {key}")
|
||
print(f" [a] {t('All of the list above')}")
|
||
answer = (
|
||
ask(
|
||
f"💬 {t('Which one(s) to reset?')}"
|
||
f" ({t('numbers separated by commas, Enter = all, n =')}"
|
||
f" {t('nothing')}) : "
|
||
)
|
||
.strip()
|
||
.lower()
|
||
)
|
||
# « n », et non plus le vide : Entrée vaut « toutes » maintenant, et une
|
||
# sortie sans mot pour dire non serait une sortie sans issue.
|
||
if not answer or answer == "n":
|
||
print(f"ℹ -> {t('Kept. Nothing was reset.')}")
|
||
return []
|
||
if answer == "a":
|
||
lst_chosen = list(lst_key)
|
||
else:
|
||
lst_chosen = []
|
||
for part in answer.replace(" ", "").split(","):
|
||
if part.isdigit() and 1 <= int(part) <= len(lst_key):
|
||
lst_chosen.append(lst_key[int(part) - 1])
|
||
if not lst_chosen:
|
||
print(f"⚠️ {t('Unknown choice, nothing was reset.')}")
|
||
return []
|
||
status, output = apply_reset(database, lst_chosen)
|
||
print(output.strip()[-2000:])
|
||
if status == 2:
|
||
print(f"❌ {t('Reset failed, nothing was changed.')}")
|
||
return []
|
||
return lst_chosen
|
||
|
||
|
||
def render_internal(internal):
|
||
"""Afficher le rapport du back-office. Rend True s'il a échoué.
|
||
|
||
Ne rien afficher quand la base n'a pas été neutralisée serait laisser
|
||
croire que le back-office a été testé et qu'il va bien.
|
||
"""
|
||
if internal is None:
|
||
return False
|
||
reprise = f" ({t('after the reset')})" if internal.get("retried") else ""
|
||
if "skipped" in internal:
|
||
if internal.get("loud"):
|
||
# Un saut ATTENDU se dit à voix basse ; un saut qui trahit une
|
||
# panne doit compter comme un échec, sinon le code de sortie
|
||
# annonce que tout va bien.
|
||
print(
|
||
f"\n⚠️ {t('Back office NOT browsed')}{reprise} :"
|
||
f" {internal['skipped']}"
|
||
)
|
||
return True
|
||
print(
|
||
f"\nℹ️ {t('Back office not browsed')}{reprise} :"
|
||
f" {internal['skipped']}"
|
||
)
|
||
return False
|
||
import smoke_internal_ui
|
||
|
||
print(smoke_internal_ui.render(internal["results"], internal["failures"]))
|
||
return bool(internal["failures"])
|
||
|
||
|
||
def attach_internal_log(internal_report, log_path):
|
||
"""Donner à la passe back-office la trace que sa page ne montrait pas.
|
||
|
||
APRÈS l'arrêt du serveur, et pas avant : un serveur qui écrit dans un
|
||
fichier bufferise et ne vide qu'en s'arrêtant. La page d'erreur d'Odoo
|
||
en production, elle, se contente de « 500: Internal Server Error » —
|
||
de quoi constater, pas de quoi réparer.
|
||
"""
|
||
if not internal_report or not internal_report.get("failures"):
|
||
return
|
||
try:
|
||
import smoke_internal_ui
|
||
except ImportError:
|
||
return
|
||
smoke_internal_ui.attach_log_reason(
|
||
internal_report["failures"], read_log(log_path)
|
||
)
|
||
|
||
|
||
def internal_needs_retry(internal_report):
|
||
"""Le back-office mérite-t-il un second essai après la réparation ?
|
||
|
||
Oui s'il a échoué, et oui aussi s'il a été SAUTÉ : « connexion
|
||
impossible » est justement le symptôme d'un site cassé, et c'est ce
|
||
que la réinitialisation vient de réparer.
|
||
"""
|
||
if not internal_report:
|
||
return False
|
||
return bool(
|
||
internal_report.get("failures") or internal_report.get("skipped")
|
||
)
|
||
|
||
|
||
def internal_phase(
|
||
base_url,
|
||
database,
|
||
enabled=True,
|
||
login="test",
|
||
password="test",
|
||
limit=20,
|
||
every_menu=False,
|
||
lst_portal=None,
|
||
required=False,
|
||
):
|
||
"""Le back-office, si la base a été neutralisée. Rend None sinon.
|
||
|
||
La condition n'est pas un drapeau mais l'utilisateur lui-même : la
|
||
neutralisation pose un compte `test` avec les groupes du
|
||
superutilisateur, et c'est le seul moyen d'entrer sans connaître le mot
|
||
de passe de quelqu'un. Une migration reprise a pu sauter l'étape, et le
|
||
fichier de progression dirait quand même « fait ».
|
||
|
||
Un échec ICI ne doit pas emporter le test public : il est mesuré, il
|
||
est rapporté, mais le rapport des URL publiques a sa propre valeur.
|
||
"""
|
||
if not enabled:
|
||
return None
|
||
try:
|
||
import smoke_internal_ui
|
||
except ImportError:
|
||
# Se taire ici ferait disparaître la passe ENTIÈRE sans un mot, et
|
||
# l'on croirait le back-office testé. Un outil absent est une
|
||
# panne d'installation, pas une base saine.
|
||
return {"skipped": t("smoke_internal_ui.py is missing"), "loud": True}
|
||
etat = smoke_internal_ui.user_state(database, login, run_psql=run_psql)
|
||
if etat == "absent":
|
||
# `required` dit que la migration a NEUTRALISÉ cette base : le
|
||
# compte devrait donc y être. Mesuré — il survit jusqu'au palier
|
||
# 15 puis disparaît, et la passe s'arrêtait sans bruit exactement
|
||
# là où une migration fait le plus de dégâts.
|
||
if required:
|
||
return {
|
||
"skipped": t(
|
||
"the test user is gone from a neutralized"
|
||
" database: the back office was NOT checked"
|
||
),
|
||
"loud": True,
|
||
}
|
||
return {"skipped": t("no test user: the database was not neutralized")}
|
||
if etat != "present":
|
||
# « je ne sais pas » n'est pas « tout va bien » : le dire autrement
|
||
# ferait passer un back-office jamais ouvert pour un back-office sain.
|
||
return {
|
||
"skipped": t("could not tell whether the test user exists"),
|
||
"loud": True,
|
||
}
|
||
try:
|
||
lst_result, lst_failure = smoke_internal_ui.run(
|
||
base_url,
|
||
database,
|
||
login,
|
||
password,
|
||
limit,
|
||
every_menu=every_menu,
|
||
lst_portal=(
|
||
smoke_internal_ui.DEFAULT_PORTAL_PATHS
|
||
if lst_portal is None
|
||
else lst_portal
|
||
),
|
||
)
|
||
except RuntimeError as exc:
|
||
return {"skipped": str(exc)}
|
||
return {"results": lst_result, "failures": lst_failure}
|
||
|
||
|
||
def run(
|
||
database,
|
||
port,
|
||
config_path,
|
||
limit=None,
|
||
timeout=30,
|
||
boot=180,
|
||
interactive=False,
|
||
auto_apply=False,
|
||
ask=None,
|
||
internal=True,
|
||
internal_login="test",
|
||
internal_password="test",
|
||
internal_limit=20,
|
||
every_menu=False,
|
||
portal=None,
|
||
internal_required=False,
|
||
):
|
||
"""Démarrer, interroger, arrêter, LIRE, éventuellement corriger, revérifier.
|
||
|
||
L'ordre porte tout le correctif : un serveur qui écrit dans un fichier
|
||
bufferise et ne vide qu'en s'arrêtant. Lire avant l'arrêt donnait
|
||
vingt-quatre lignes de démarrage et zéro trace — donc « aucune vue en
|
||
cause » sur des pages qui en nommaient une. Mesuré deux fois avant d'être
|
||
compris.
|
||
"""
|
||
import tempfile
|
||
|
||
base_url = f"http://127.0.0.1:{port}"
|
||
log_path = os.path.join(
|
||
tempfile.gettempdir(), f"erplibre_smoke_{database}_{port}.log"
|
||
)
|
||
mismatch = require_matching_version(database)
|
||
if mismatch:
|
||
raise RuntimeError(mismatch)
|
||
if port_is_taken(port):
|
||
raise RuntimeError(
|
||
f"{t('Something already listens on port')} {port} :"
|
||
f" {t('it would be tested instead of this database.')}"
|
||
)
|
||
server = start_server(database, port, config_path, log_path=log_path)
|
||
try:
|
||
if not wait_ready(base_url, timeout=boot):
|
||
raise RuntimeError(
|
||
f"{t('The server never answered on')} {base_url}"
|
||
)
|
||
lst_url, status = sitemap_urls(base_url)
|
||
if not status:
|
||
raise RuntimeError(f"{t('Could not read')} {base_url}/sitemap.xml")
|
||
if limit:
|
||
lst_url = lst_url[:limit]
|
||
lst_failure = check_urls(lst_url, timeout=timeout)
|
||
# ICI, pendant que le serveur tourne : le démarrage d'Odoo est ce
|
||
# qui coûte des minutes, pas les requêtes. Un deuxième outil avec
|
||
# son propre serveur doublerait l'attente pour rien.
|
||
internal_report = internal_phase(
|
||
base_url,
|
||
database,
|
||
enabled=internal,
|
||
login=internal_login,
|
||
password=internal_password,
|
||
limit=internal_limit,
|
||
every_menu=every_menu,
|
||
lst_portal=portal,
|
||
required=internal_required,
|
||
)
|
||
finally:
|
||
stop_server(server)
|
||
|
||
attach_internal_log(internal_report, log_path)
|
||
lst_key = []
|
||
if lst_failure:
|
||
lst_log = read_log(log_path)
|
||
lst_failure = attach_missing_parents(lst_failure, lst_log)
|
||
lst_key = culprit_keys(database, lst_failure)
|
||
# Les deux sources : le contexte d'héritage quand il existe, le nom
|
||
# du gabarit quand l'échec vient du rendu.
|
||
for key in template_keys(lst_log, database):
|
||
if key not in lst_key:
|
||
lst_key.append(key)
|
||
if not lst_failure or not (interactive or auto_apply):
|
||
return lst_url, lst_failure, lst_key, None, internal_report
|
||
|
||
print(render(lst_url, lst_failure, lst_key))
|
||
if auto_apply:
|
||
lst_done = lst_key
|
||
if lst_done:
|
||
code, output = apply_reset(database, lst_done)
|
||
print(output.strip()[-2000:])
|
||
if code == 2:
|
||
lst_done = []
|
||
else:
|
||
# `ask=None` et non `input` : c'est `prompt` qui sait quel défaut
|
||
# lui appartient, et qui pose alors la question par le lecteur
|
||
# temporisé. Forcer `input` ici court-circuitait le mode auto —
|
||
# mesuré, l'invite attendait indéfiniment une frappe pendant une
|
||
# migration lancée en auto-exécution.
|
||
lst_done = prompt(database, lst_failure, lst_key, ask=ask)
|
||
if not lst_done:
|
||
return lst_url, lst_failure, lst_key, None, internal_report
|
||
|
||
lst_again, internal_report = recheck_after_reset(
|
||
database,
|
||
port,
|
||
config_path,
|
||
base_url,
|
||
log_path,
|
||
lst_failure,
|
||
internal_report,
|
||
timeout=timeout,
|
||
boot=boot,
|
||
internal=internal,
|
||
internal_login=internal_login,
|
||
internal_password=internal_password,
|
||
internal_limit=internal_limit,
|
||
every_menu=every_menu,
|
||
portal=portal,
|
||
internal_required=internal_required,
|
||
)
|
||
return lst_url, lst_failure, lst_key, lst_again, internal_report
|
||
|
||
|
||
def recheck_after_reset(
|
||
database,
|
||
port,
|
||
config_path,
|
||
base_url,
|
||
log_path,
|
||
lst_failure,
|
||
internal_report,
|
||
timeout=30,
|
||
boot=180,
|
||
internal=True,
|
||
internal_login="test",
|
||
internal_password="test",
|
||
internal_limit=20,
|
||
every_menu=False,
|
||
portal=None,
|
||
internal_required=False,
|
||
):
|
||
"""Redémarrer, et revoir ce qui avait échoué. Rend (URL, back-office).
|
||
|
||
Le back-office est REJUGÉ lui aussi, et ce n'est pas une symétrie
|
||
gratuite : la copie COW qui casse le site casse AUSSI /web/login — les
|
||
deux passent par le même gabarit. La passe interne tournait donc avant
|
||
la réparation et rapportait « connexion impossible » sur une base que
|
||
la réinitialisation remettait d'aplomb quelques secondes plus tard.
|
||
"""
|
||
server = start_server(database, port, config_path, log_path=log_path)
|
||
try:
|
||
if not wait_ready(base_url, timeout=boot):
|
||
raise RuntimeError(
|
||
f"{t('The server never answered on')} {base_url}"
|
||
)
|
||
lst_again = check_urls(
|
||
[url for url, _s, _p in lst_failure], timeout=timeout
|
||
)
|
||
if internal_needs_retry(internal_report):
|
||
reprise = internal_phase(
|
||
base_url,
|
||
database,
|
||
enabled=internal,
|
||
login=internal_login,
|
||
password=internal_password,
|
||
limit=internal_limit,
|
||
every_menu=every_menu,
|
||
lst_portal=portal,
|
||
required=internal_required,
|
||
)
|
||
if reprise is not None:
|
||
internal_report = reprise
|
||
internal_report["retried"] = True
|
||
finally:
|
||
stop_server(server)
|
||
return lst_again, internal_report
|
||
|
||
|
||
def stop_server(server):
|
||
"""Arrêter tout le GROUPE : sinon odoo-bin survit à son script."""
|
||
try:
|
||
group = os.getpgid(server.pid)
|
||
except OSError:
|
||
group = None
|
||
if group is not None:
|
||
try:
|
||
os.killpg(group, signal.SIGTERM)
|
||
except OSError:
|
||
pass
|
||
try:
|
||
server.wait(timeout=30)
|
||
except subprocess.TimeoutExpired:
|
||
if group is not None:
|
||
try:
|
||
os.killpg(group, signal.SIGKILL)
|
||
except OSError:
|
||
pass
|
||
server.kill()
|
||
handle = getattr(server, "erplibre_log", None)
|
||
if handle:
|
||
handle.close()
|
||
|
||
|
||
def port_is_taken(port, host="127.0.0.1"):
|
||
"""Quelqu'un écoute-t-il déjà là ?
|
||
|
||
Sans cette question, un serveur resté d'un essai précédent répond à
|
||
« le serveur est-il prêt ? », et l'on teste sa base à lui en croyant
|
||
tester la sienne. C'est exactement ce qui est arrivé ici.
|
||
"""
|
||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||
sock.settimeout(1)
|
||
return sock.connect_ex((host, port)) == 0
|
||
|
||
|
||
def main(argv=None):
|
||
parser = argparse.ArgumentParser(
|
||
description=(
|
||
"Start Odoo on a database, request every URL of its sitemap, and"
|
||
" report those that fail."
|
||
)
|
||
)
|
||
parser.add_argument("-d", "--database", required=True)
|
||
parser.add_argument("-c", "--config", default="./config.conf")
|
||
parser.add_argument("-p", "--port", type=int, default=DEFAULT_PORT)
|
||
parser.add_argument(
|
||
"--limit", type=int, default=None, help="test only the first N URLs"
|
||
)
|
||
parser.add_argument("--timeout", type=int, default=30)
|
||
parser.add_argument(
|
||
"--apply",
|
||
action="store_true",
|
||
help="reset the views in cause without asking (WRITES)",
|
||
)
|
||
parser.add_argument(
|
||
"--report-only",
|
||
action="store_true",
|
||
help="never ask anything, even in front of a terminal",
|
||
)
|
||
parser.add_argument(
|
||
"--boot-timeout",
|
||
type=int,
|
||
default=180,
|
||
help="how long to wait for the server to answer",
|
||
)
|
||
parser.add_argument(
|
||
"--no-internal",
|
||
action="store_true",
|
||
help="skip the back-office pass done as the neutralization test user",
|
||
)
|
||
parser.add_argument("--login", default="test")
|
||
parser.add_argument("--password", default="test")
|
||
parser.add_argument(
|
||
"--record-limit",
|
||
type=int,
|
||
default=20,
|
||
help="how many records each app loads on its first page",
|
||
)
|
||
parser.add_argument(
|
||
"--all-menus",
|
||
action="store_true",
|
||
help="open every menu with an action, not just each app's first page",
|
||
)
|
||
parser.add_argument(
|
||
"--internal-required",
|
||
action="store_true",
|
||
help="the database WAS neutralized: a missing test user is a failure",
|
||
)
|
||
parser.add_argument(
|
||
"--portal",
|
||
default="/my",
|
||
help="portal paths opened while signed in (comma separated, empty"
|
||
" to skip)",
|
||
)
|
||
config = parser.parse_args(argv)
|
||
|
||
print(
|
||
f"⧖ {t('Starting Odoo on')} '{config.database}'"
|
||
f" ({t('port')} {config.port})…"
|
||
)
|
||
interactive = not config.report_only and can_ask()
|
||
try:
|
||
lst_url, lst_failure, lst_key, lst_again, internal = run(
|
||
config.database,
|
||
config.port,
|
||
config.config,
|
||
limit=config.limit,
|
||
timeout=config.timeout,
|
||
boot=config.boot_timeout,
|
||
interactive=interactive,
|
||
auto_apply=config.apply,
|
||
internal=not config.no_internal,
|
||
internal_login=config.login,
|
||
internal_password=config.password,
|
||
internal_limit=config.record_limit,
|
||
every_menu=config.all_menus,
|
||
internal_required=config.internal_required,
|
||
portal=[
|
||
path.strip()
|
||
for path in (config.portal or "").split(",")
|
||
if path.strip()
|
||
],
|
||
)
|
||
except RuntimeError as exc:
|
||
print(f"❌ {exc}")
|
||
return 2
|
||
internal_failed = render_internal(internal)
|
||
if lst_again is None:
|
||
print(render(lst_url, lst_failure, lst_key))
|
||
return 1 if (lst_failure or internal_failed) else 0
|
||
# Après correction on ne redit pas le diagnostic : on dit ce qu'il RESTE.
|
||
print(
|
||
f"\n↻ {t('Re-checked the')} {len(lst_failure)}"
|
||
f" {t('failing URL(s) after the reset')} :"
|
||
)
|
||
print(render([url for url, _s, _p in lst_failure], lst_again, None))
|
||
return 1 if (lst_again or internal_failed) else 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|