erplibre/script/odoo/migration/smoke_public_url.py
Mathieu Benoit c65db269ed [ADD] migration: request every public URL before calling it done
A migration can load every module, log nothing, and still serve 500s on
pages nobody thought to open. Measured on the real one: 2 of 33 public
URLs failed — a blog post and /contactus — after a bump the log called
successful.

The list is the sitemap, what Odoo publishes for search engines. 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 on its own port,
asked, and always stopped — a forgotten one holds the port and fails
the next bump.

Offered before the Selenium prompt, default no: it boots a server and
can take minutes.

--- FR ---

[ADD] migration : interroger chaque URL publique avant de conclure

Une migration peut charger tous ses modules, ne rien écrire au journal,
et servir quand même des 500 sur des pages que personne n'ouvre.
Mesuré : 2 URL publiques sur 33 échouaient — un billet de blogue et
/contactus — après un palier que le journal disait réussi.

La liste est le sitemap, celle qu'Odoo publie pour les moteurs. Elle ne
se lit pas depuis odoo-bin shell : enumerate_pages() réclame
http.root.get_db_router(request.db) et lève « object unbound » sans
requête réelle. Le serveur est donc démarré sur son propre port,
interrogé, et toujours arrêté — un serveur oublié tient le port et fait
échouer le palier suivant.

Proposé avant l'invite Selenium, par défaut non : cela démarre un
serveur et peut durer.

Assisted-by: Claude Opus 5
(cherry picked from commit 277e85b3d06173beeb92be792467751b31ac0696)
2026-08-22 07:23:59 -04:00

239 lines
7.5 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 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
RE_LOC = re.compile(r"<loc>\s*([^<\s]+)\s*</loc>", re.I)
# 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 start_server(database, port, config_path="./config.conf"):
"""Démarrer Odoo sur la base, sans écrire dans le terminal appelant."""
return subprocess.Popen(
[
"./run.sh",
"-c",
config_path,
"-d",
database,
"--http-port",
str(port),
"--log-level=warn",
],
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
)
def fetch(url, timeout=30):
"""(statut, corps). Statut 0 quand la connexion elle-même échoue."""
try:
with urllib.request.urlopen(url, timeout=timeout) as answer:
return answer.getcode(), answer.read().decode(
"utf-8", errors="replace"
)
except urllib.error.HTTPError as exc:
return exc.code, exc.read().decode("utf-8", errors="replace")
except Exception:
return 0, ""
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 = 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 = 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 = 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
def check_urls(lst_url, timeout=30):
"""[(url, statut)] pour celles qui ont échoué."""
lst_failure = []
for url in lst_url:
status, _body = fetch(url, timeout=timeout)
if status == 0 or status >= 400:
lst_failure.append((url, status))
return lst_failure
def render(lst_url, lst_failure):
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 in lst_failure:
label = status or t("no answer")
lines.append(f" [{label}] {url}")
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 run(database, port, config_path, limit=None, timeout=30, boot=180):
"""Démarrer, interroger, arrêter. Rend (urls, échecs)."""
base_url = f"http://127.0.0.1:{port}"
server = start_server(database, port, config_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]
return lst_url, check_urls(lst_url, timeout=timeout)
finally:
server.terminate()
try:
server.wait(timeout=30)
except subprocess.TimeoutExpired:
server.kill()
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(
"--boot-timeout",
type=int,
default=180,
help="how long to wait for the server to answer",
)
config = parser.parse_args(argv)
print(
f"⧖ {t('Starting Odoo on')} '{config.database}'"
f" ({t('port')} {config.port})…"
)
try:
lst_url, lst_failure = run(
config.database,
config.port,
config.config,
limit=config.limit,
timeout=config.timeout,
boot=config.boot_timeout,
)
except RuntimeError as exc:
print(f"❌ {exc}")
return 2
print(render(lst_url, lst_failure))
return 1 if lst_failure else 0
if __name__ == "__main__":
sys.exit(main())