diff --git a/run.sh b/run.sh index 119c695..8c2a35f 100755 --- a/run.sh +++ b/run.sh @@ -17,6 +17,7 @@ fi # main : Odoo ne les connaît pas et mourrait sur « no such option ». # --auto-erplibre arme le choix de la base à démarrer # --no-cli-erplibre interdit le menu, sans interdire le choix +# --erplibre-disable-warmup-http n'envoie pas la requête de réveil # Sans AUCUN argument, le choix s'arme de lui-même : c'est « make run », # quelqu'un devant son terminal. Ce défaut-là reste timide — il exige un # terminal des deux côtés — parce que systemd lance lui aussi run.sh sans @@ -25,6 +26,7 @@ EL_ARGS=() EL_AUTO=0 EL_AUTO_EXPLICITE=0 EL_NO_CLI=0 +EL_WARMUP=1 [ $# -eq 0 ] && EL_AUTO=1 while [ $# -gt 0 ]; do case "$1" in @@ -33,6 +35,13 @@ while [ $# -gt 0 ]; do EL_AUTO_EXPLICITE=1 ;; --no-cli-erplibre) EL_NO_CLI=1 ;; + --erplibre-disable-warmup-http) EL_WARMUP=0 ;; + # Rien à réveiller si personne n'écoute. On les laisse passer à Odoo, + # on se contente de ne pas sonder. + --no-http|--stop-after-init) + EL_WARMUP=0 + EL_ARGS+=("$1") + ;; # Tout le reste passe tel quel, y compris les arguments vides et ceux # qui portent des espaces : un tableau, jamais une chaîne reconstruite. *) EL_ARGS+=("$1") ;; @@ -58,6 +67,26 @@ if [ "${EL_AUTO}" = "1" ] && [ -f "${EL_LIB}" ]; then fi fi +# Le réveil, EN PARALLÈLE. Odoo ne charge le registre d'une base qu'à la +# première requête qui la concerne ; sur une base migrée cela prend des +# dizaines de secondes, et c'est la personne qui ouvre la page qui les +# attend. La sonde prend ce temps à sa place, se tait, et meurt avec nous. +EL_WARMUP_PID="" +EL_WARMUP_BIN="./script/odoo/warmup_http.py" +if [ "${EL_WARMUP}" = "1" ] && [ "$ODOO_MODE_TEST" != "true" ] \ + && [ -f "${EL_WARMUP_BIN}" ]; then + EL_WARMUP_DB=() + [ -n "${EL_DB_NAME}" ] && EL_WARMUP_DB=(-d "${EL_DB_NAME}") + # `--` sépare NOS options de celles d'Odoo : sans lui, un « -c » destiné + # à Odoo serait lu comme le nôtre. + python3 "${EL_WARMUP_BIN}" -c "${CONFIG_PATH}" "${EL_WARMUP_DB[@]}" \ + -- "${EL_ARGS[@]}" 2>/dev/null & + EL_WARMUP_PID=$! + # Elle ne doit pas survivre au serveur qu'elle réveille. Un Ctrl-C sur + # run.sh doit tout emporter. + trap '[ -n "${EL_WARMUP_PID}" ] && kill "${EL_WARMUP_PID}" 2>/dev/null' EXIT +fi + if [ "$ODOO_MODE_TEST" = "true" ]; then ./odoo_bin.sh -c "${CONFIG_PATH}" --limit-time-real 99999 --limit-time-cpu 99999 --limit-memory-hard=0 --log-level=test --test-enable --no-http --stop-after-init "${EL_DB[@]}" "${EL_ARGS[@]}" else diff --git a/script/odoo/warmup_http.py b/script/odoo/warmup_http.py new file mode 100755 index 0000000..0dd3dca --- /dev/null +++ b/script/odoo/warmup_http.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Réveiller l'instance dès qu'elle écoute, puis disparaître. + +Odoo ne charge le registre d'une base qu'à la PREMIÈRE requête qui la +concerne. Sur une base migrée, ce premier chargement prend des dizaines de +secondes — et c'est la personne qui ouvre la page qui les attend. Lancée +en parallèle de `run.sh`, cette sonde prend ce temps à sa place. + +Trois règles, et elles tiennent au fait qu'elle tourne À CÔTÉ du serveur +-------------------------------------------------------------------- +1. Elle se TAIT. Une seule ligne, au démarrage, pour dire quelle adresse + elle a choisie — c'est ce qui permet de comprendre après coup un + réveil qui n'a pas eu lieu. Ensuite plus rien : ni échec, ni trace. + Un serveur qu'on arrête au bout de dix secondes est un cas ordinaire, + pas une erreur, et un message d'erreur dans le journal de `run.sh` + ferait chercher une panne qui n'existe pas. +2. Elle s'arrête à la PREMIÈRE réponse, quelle qu'elle soit. Un 303 vers + /web/login, un 404, un 500 : tous prouvent que le registre est chargé, + ce qui est l'objet. Seul le refus de connexion ne compte pas. +3. Elle meurt au bout de deux minutes. Le réveil est un confort ; une + sonde qui survit à son serveur en est un autre, mauvais. + +Où elle trouve le port +---------------------- +D'abord la ligne de commande — `-p 8090` l'emporte sur tout le reste, +c'est ce que la personne vient de taper. Puis `config.conf`, que `run.sh` +a déjà résolu et qu'elle reçoit tel quel. Le journal d'exécution ne sert +qu'en dernier recours : Odoo y écrit « HTTP service (werkzeug) running on +127.0.0.1:8069 » une fois qu'il écoute, ce qui arrive parfois APRÈS le +premier essai — mais c'est aussi la seule source exacte quand le port +demandé était déjà pris. +""" + +from __future__ import annotations + +import os +import re +import sys +import time + +sys.path.append( + os.path.normpath(os.path.join(os.path.dirname(__file__), "..", "..")) +) + +from script.analyse import lib_analyse # noqa: E402 + +DELAI_TOTAL = 120.0 +DELAI_ENTRE_ESSAIS = 1.0 +DELAI_REQUETE = 10.0 +PORT_PAR_DEFAUT = 8069 +HOTE_PAR_DEFAUT = "127.0.0.1" + +# « HTTP service (werkzeug) running on 127.0.0.1:8069 » +MOTIF_JOURNAL = re.compile( + r"HTTP service \(werkzeug\) running on ([^:\s]+):(\d+)" +) + + +def port_de_la_ligne(argv): + """Le port demandé sur la ligne de commande, ou None. + + `-p 8090`, `--http-port 8090` et `--http-port=8090` : les trois + formes existent, et Odoo les accepte toutes. + """ + for index, argument in enumerate(argv): + if argument in ("-p", "--http-port") and index + 1 < len(argv): + valeur = argv[index + 1] + if valeur.isdigit(): + return int(valeur) + if argument.startswith("--http-port="): + valeur = argument.split("=", 1)[1] + if valeur.isdigit(): + return int(valeur) + return None + + +def adresse_du_journal(chemin, taille=8192): + """(hôte, port) lus dans la fin du journal, ou (None, None). + + On ne lit que la queue : un journal de migration pèse des mégaoctets, + et la ligne qu'on cherche est la dernière écrite. + """ + if not chemin or not os.path.isfile(chemin): + return None, None + try: + with open(chemin, "rb") as handle: + handle.seek(0, os.SEEK_END) + debut = max(0, handle.tell() - taille) + handle.seek(debut) + queue = handle.read().decode("utf-8", "replace") + except OSError: + return None, None + trouve = MOTIF_JOURNAL.findall(queue) + if not trouve: + return None, None + hote, port = trouve[-1] + return hote, int(port) + + +def adresse(argv=(), config_path=None, journal=None): + """(hôte, port) à réveiller, dans l'ordre de ce qui fait autorité.""" + config = lib_analyse.read_config(config_path) + + port = port_de_la_ligne(argv) + hote = None + if port is None: + hote, port = adresse_du_journal(journal) + if port is None: + brut = str(config.get("http_port", "")).strip() + port = int(brut) if brut.isdigit() else PORT_PAR_DEFAUT + + if not hote: + interface = str(config.get("http_interface", "")).strip() + # Une interface vide veut dire « toutes » : on se parle à soi-même. + # 0.0.0.0 n'est pas une adresse de destination. + if interface.lower() in ("", "false", "none", "0.0.0.0", "::"): + hote = HOTE_PAR_DEFAUT + else: + hote = interface + return hote, port + + +def base_a_reveiller(argv=(), config_path=None): + """La base dont on veut le registre, ou None. + + `-d` d'abord : c'est celle que `run.sh` vient de choisir. Sinon + `db_name` de la configuration, qu'Odoo utiliserait de toute façon. + """ + for index, argument in enumerate(argv): + if argument in ("-d", "--database") and index + 1 < len(argv): + return argv[index + 1] + if argument.startswith("--database="): + return argument.split("=", 1)[1] + valeur = str( + lib_analyse.read_config(config_path).get("db_name", "") + ).strip() + return valeur if valeur.lower() not in ("", "false", "none") else None + + +def url(hote, port, database=None): + """L'adresse à demander. + + `/web/login` plutôt que `/` : elle ne redirige pas vers un tableau de + bord, ne crée pas de session, et suffit à faire charger le registre. + Le paramètre `db` dit LAQUELLE quand plusieurs bases répondent. + """ + base = f"http://{hote}:{port}/web/login" + if database: + from urllib.parse import quote + + return f"{base}?db={quote(database)}" + return base + + +def sonder( + cible, + delai_total=DELAI_TOTAL, + entre_essais=DELAI_ENTRE_ESSAIS, + requete=DELAI_REQUETE, + horloge=time.monotonic, + dormir=time.sleep, +): + """Demander jusqu'à obtenir une réponse. Rendre True si elle est venue. + + N'importe quel code HTTP est une réussite : 303, 404 ou 500 prouvent + tous que le serveur a répondu, donc que le registre est chargé. Seul + le refus de connexion fait recommencer. + """ + import urllib.error + import urllib.request + + fin = horloge() + delai_total + while horloge() < fin: + try: + with urllib.request.urlopen(cible, timeout=requete): + return True + except urllib.error.HTTPError: + # Le serveur a répondu, et c'est tout ce qu'on demandait. + return True + except Exception: # noqa: BLE001 - un serveur pas encore prêt + pass + dormir(entre_essais) + return False + + +def main(argv=None): + """Toujours rendre 0 : personne ne doit lire ce code de retour. + + La sonde tourne en arrière-plan d'un `run.sh` qui, lui, rapporte + l'état d'Odoo. Un code non nul finirait dans un journal et ferait + croire à une panne du serveur. + """ + import argparse + + parser = argparse.ArgumentParser(description="Warm up the HTTP registry.") + parser.add_argument("-c", "--config") + parser.add_argument("-d", "--database") + parser.add_argument("--log") + parser.add_argument("--timeout", type=float, default=DELAI_TOTAL) + parser.add_argument("--quiet", action="store_true") + parser.add_argument("odoo_args", nargs="*") + # `parse_known_args` et non `parse_args` : les arguments d'Odoo nous + # traversent, et l'un d'eux ressemblera un jour à une option qu'on ne + # connaît pas. `parse_args` sortirait alors par SystemExit — ce qui + # romprait la seule promesse qui compte ici : ne jamais gêner. + args, restes = parser.parse_known_args(argv) + args.odoo_args = list(args.odoo_args) + list(restes) + + try: + hote, port = adresse(args.odoo_args, args.config, args.log) + database = args.database or base_a_reveiller( + args.odoo_args, args.config + ) + cible = url(hote, port, database) + if not args.quiet: + print(f"🔥 warmup: {cible}", flush=True) + sonder(cible, delai_total=args.timeout) + except Exception: # noqa: BLE001 - le réveil est un confort, pas un dû + pass + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/test/test_warmup_http.py b/test/test_warmup_http.py new file mode 100644 index 0000000..faf1aaf --- /dev/null +++ b/test/test_warmup_http.py @@ -0,0 +1,331 @@ +#!/usr/bin/env python3 +# © 2021-2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""Le réveil HTTP : il doit se taire, et ne jamais gêner. + +Cette sonde tourne à côté du serveur, pas devant lui. Tout ce qu'elle +peut faire de mal, elle le ferait en parlant : un message d'erreur dans +le journal de `run.sh` ferait chercher une panne d'Odoo qui n'existe pas, +et un code de retour non nul ferait échouer un démarrage réussi. + +D'où les deux propriétés que ce fichier épingle avant les autres : elle +rend TOUJOURS 0, et elle n'écrit qu'une ligne — l'adresse choisie, parce +que c'est la seule chose qui permette de comprendre après coup un réveil +qui n'a pas eu lieu. + +Le troisième sujet est l'option elle-même. `--erplibre-disable-warmup-http` +doit être RETIRÉE avant `odoo_bin.sh` : Odoo meurt sur « no such option », +et le démarrage entier tombe pour un confort. +""" + +import io +import unittest +from pathlib import Path + +from script.odoo import warmup_http as warmup + +RUN_SH = Path(__file__).resolve().parent.parent / "run.sh" + + +class TestWhereItKnocks(unittest.TestCase): + """Le port, dans l'ordre de ce qui fait autorité.""" + + def test_the_command_line_wins_over_everything(self): + for argv in ( + ["-p", "8090"], + ["--http-port", "8090"], + ["--http-port=8090"], + ): + self.assertEqual(warmup.port_de_la_ligne(argv), 8090, argv) + + def test_a_port_that_is_not_a_number_is_ignored(self): + self.assertIsNone(warmup.port_de_la_ligne(["-p", "huit-mille"])) + self.assertIsNone(warmup.port_de_la_ligne(["-p"])) + + def test_without_anything_it_falls_back_on_the_default(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {} + try: + self.assertEqual( + warmup.adresse(), + (warmup.HOTE_PAR_DEFAUT, warmup.PORT_PAR_DEFAUT), + ) + finally: + warmup.lib_analyse.read_config = vrai + + def test_an_empty_interface_means_ourselves(self): + """`http_interface =` veut dire « toutes » ; 0.0.0.0 n'est pas une + adresse de destination.""" + vrai = warmup.lib_analyse.read_config + for valeur in ("", "False", "0.0.0.0", "::"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "http_interface": v + } + try: + self.assertEqual(warmup.adresse()[0], "127.0.0.1", valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_a_real_interface_is_used_as_is(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: { + "http_interface": "10.0.0.5", + "http_port": "8071", + } + try: + self.assertEqual(warmup.adresse(), ("10.0.0.5", 8071)) + finally: + warmup.lib_analyse.read_config = vrai + + +class TestReadingTheExecutionLog(unittest.TestCase): + """La seule source exacte quand le port demandé était déjà pris.""" + + def _journal(self, contenu): + import tempfile + + handle = tempfile.NamedTemporaryFile( + mode="w", suffix=".log", delete=False, encoding="utf-8" + ) + handle.write(contenu) + handle.close() + self.addCleanup(lambda: Path(handle.name).unlink(missing_ok=True)) + return handle.name + + def test_it_reads_the_address_odoo_announced(self): + chemin = self._journal( + "blah\nHTTP service (werkzeug) running on 127.0.0.1:8075\nblah\n" + ) + self.assertEqual( + warmup.adresse_du_journal(chemin), ("127.0.0.1", 8075) + ) + + def test_the_last_line_wins(self): + """Un redémarrage laisse les deux ; la dernière est la vraie.""" + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8069\n" + "HTTP service (werkzeug) running on 127.0.0.1:8070\n" + ) + self.assertEqual(warmup.adresse_du_journal(chemin)[1], 8070) + + def test_a_missing_or_silent_log_says_nothing(self): + self.assertEqual(warmup.adresse_du_journal(None), (None, None)) + self.assertEqual(warmup.adresse_du_journal("/pas/la"), (None, None)) + self.assertEqual( + warmup.adresse_du_journal(self._journal("rien\n")), (None, None) + ) + + def test_the_command_line_still_wins_over_the_log(self): + chemin = self._journal( + "HTTP service (werkzeug) running on 127.0.0.1:8075\n" + ) + self.assertEqual( + warmup.adresse(["-p", "8090"], journal=chemin)[1], 8090 + ) + + +class TestWhichDatabase(unittest.TestCase): + def test_the_one_run_sh_just_chose(self): + self.assertEqual(warmup.base_a_reveiller(["-d", "ma_base"]), "ma_base") + self.assertEqual( + warmup.base_a_reveiller(["--database=ma_base"]), "ma_base" + ) + + def test_otherwise_the_one_odoo_would_use(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: {"db_name": "prod"} + try: + self.assertEqual(warmup.base_a_reveiller([]), "prod") + finally: + warmup.lib_analyse.read_config = vrai + + def test_db_name_false_is_not_a_database(self): + vrai = warmup.lib_analyse.read_config + for valeur in ("False", "", "None"): + warmup.lib_analyse.read_config = lambda *a, v=valeur, **k: { + "db_name": v + } + try: + self.assertIsNone(warmup.base_a_reveiller([]), valeur) + finally: + warmup.lib_analyse.read_config = vrai + + def test_the_name_is_escaped_in_the_url(self): + self.assertIn("db=ma%20base", warmup.url("127.0.0.1", 8069, "ma base")) + + def test_without_a_database_the_url_carries_no_parameter(self): + self.assertEqual( + warmup.url("127.0.0.1", 8069), "http://127.0.0.1:8069/web/login" + ) + + +class TestWhenItStops(unittest.TestCase): + """N'importe quelle réponse suffit ; seul le refus fait recommencer.""" + + def _sonder(self, reponses, delai=10.0): + import urllib.error + import urllib.request + + essais = [] + temps = [0.0] + + def faux_urlopen(cible, timeout=None): + essais.append(cible) + resultat = ( + reponses.pop(0) if reponses else ConnectionRefusedError() + ) + if isinstance(resultat, Exception): + raise resultat + return io.BytesIO(b"") + + vrai = urllib.request.urlopen + urllib.request.urlopen = faux_urlopen + try: + venu = warmup.sonder( + "http://x", + delai_total=delai, + entre_essais=0, + horloge=lambda: temps[0], + dormir=lambda s: temps.__setitem__(0, temps[0] + 1), + ) + finally: + urllib.request.urlopen = vrai + return venu, essais + + def test_a_first_answer_ends_it(self): + venu, essais = self._sonder([None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_an_http_error_is_an_answer(self): + """303, 404, 500 : tous prouvent que le registre est chargé.""" + import urllib.error + + erreur = urllib.error.HTTPError("u", 500, "boom", {}, None) + venu, essais = self._sonder([erreur]) + self.assertTrue(venu) + self.assertEqual(len(essais), 1) + + def test_a_refused_connection_makes_it_try_again(self): + venu, essais = self._sonder([ConnectionRefusedError(), None]) + self.assertTrue(venu) + self.assertEqual(len(essais), 2) + + def test_it_gives_up_at_the_deadline(self): + venu, essais = self._sonder([], delai=5.0) + self.assertFalse(venu) + self.assertEqual(len(essais), 5) + + def test_the_default_deadline_is_two_minutes(self): + self.assertEqual(warmup.DELAI_TOTAL, 120.0) + + +class TestItNeverGetsInTheWay(unittest.TestCase): + """La propriété qui compte le plus : elle ne peut pas nuire.""" + + def test_it_always_returns_zero(self): + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: (_ for _ in ()).throw( + RuntimeError("x") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.sonder = vrai + + def test_a_broken_config_does_not_stop_it(self): + vrai = warmup.lib_analyse.read_config + warmup.lib_analyse.read_config = lambda *a, **k: (_ for _ in ()).throw( + OSError("illisible") + ) + try: + self.assertEqual(warmup.main(["--quiet"]), 0) + finally: + warmup.lib_analyse.read_config = vrai + + def test_it_announces_the_address_it_chose(self): + """La seule ligne qu'elle écrit, et celle qui rend le silence + compréhensible plus tard.""" + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["-d", "ma_base", "--", "-p", "8090"]) + finally: + warmup.sonder = vrai + texte = sortie.getvalue() + self.assertEqual(len(texte.strip().splitlines()), 1) + self.assertIn("ma_base", texte) + + def test_an_option_it_does_not_know_cannot_kill_it(self): + """Les arguments d'Odoo nous traversent : l'un d'eux ressemblera un + jour à une option inconnue, et `parse_args` sortirait par SystemExit. + """ + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + try: + self.assertEqual(warmup.main(["--quiet", "--option-a-odoo"]), 0) + finally: + warmup.sonder = vrai + + def test_quiet_says_nothing_at_all(self): + import contextlib + + vrai = warmup.sonder + warmup.sonder = lambda *a, **k: True + sortie = io.StringIO() + try: + with contextlib.redirect_stdout(sortie): + warmup.main(["--quiet"]) + finally: + warmup.sonder = vrai + self.assertEqual(sortie.getvalue(), "") + + +class TestHowRunShWiresIt(unittest.TestCase): + """Une option qu'Odoo ne connaît pas le tue au démarrage.""" + + @classmethod + def setUpClass(cls): + cls.source = RUN_SH.read_text(encoding="utf-8") + + def test_the_flag_is_stripped_and_never_forwarded(self): + bloc = self.source[ + self.source.index("--erplibre-disable-warmup-http)") : + ][:120] + self.assertNotIn("EL_ARGS+=", bloc) + + def test_no_http_disables_it_but_still_reaches_odoo(self): + """Rien à réveiller, mais Odoo doit garder son option.""" + bloc = self.source[ + self.source.index("--no-http|--stop-after-init)") : + ][:200] + self.assertIn("EL_WARMUP=0", bloc) + self.assertIn('EL_ARGS+=("$1")', bloc) + + def test_it_runs_in_parallel(self): + lancement = [ + texte + for texte in self.source.splitlines() + if texte.strip().endswith("&") and "2>/dev/null" in texte + ] + self.assertTrue(lancement, "le réveil ne part pas en arrière-plan") + + def test_it_does_not_survive_the_server(self): + self.assertIn("trap", self.source) + self.assertIn("EL_WARMUP_PID", self.source) + + def test_its_own_errors_are_swallowed(self): + self.assertIn("2>/dev/null &", self.source) + + def test_odoo_arguments_are_separated_by_a_double_dash(self): + """Sans `--`, un « -c » destiné à Odoo serait lu comme le nôtre.""" + self.assertIn('-- "${EL_ARGS[@]}" 2>/dev/null &', self.source) + + +if __name__ == "__main__": + unittest.main()