From cb57a79da00ab6dca29d5d4c8b79b1e5a1651300 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:17 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20test=20long=20cache=20:=20encha=C3=AEne?= =?UTF-8?q?r=20une=20campagne=20par=20syst=C3=A8me?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each campaign covered one system, so proving the cache beyond Ubuntu meant relaunching by hand and comparing reports one by one. --distro now accepts « tous » or a comma list: each system runs its full campaign with the catalogue version, its VMs are destroyed before the next one — three VMs per system would not fit together on the host disk — and a table compares verdict, durations and upstream bytes. A failure does not stop the series; an unknown name is refused before any VM is created. Checked: tests and mutations. --- FR --- Chaque campagne couvrait un système : prouver le cache au-delà d'Ubuntu voulait dire relancer à la main et comparer les rapports un à un. --distro accepte désormais « tous » ou une liste : chaque système fait sa campagne complète avec la version du catalogue, ses VM sont défaites avant le suivant — trois VM par système ne tiendraient pas ensemble sur le disque de l'hôte — et un tableau compare verdict, durées et octets d'amont. Un échec n'arrête pas la série ; un nom inconnu est refusé avant qu'aucune VM ne soit créée. Vérifié : tests et mutations. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 4 ++ CHANGELOG.fr.md | 2 + CHANGELOG.md | 2 + long_test/qemu_cache.py | 117 ++++++++++++++++++++++++++++++--- test/test_qemu_cache_charge.py | 84 +++++++++++++++++++++++ 5 files changed, 200 insertions(+), 9 deletions(-) diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index d9156ad..66420c3 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -70,6 +70,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline - F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache - The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give +- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series @@ -112,6 +113,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne - F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache - Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera +- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série ## Changed @@ -129,6 +131,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The QEMU cache binary speaks English or French: service journal, `--status`, `--age`, option help and the error served to a VM. The language comes from `--lang`, then `EL_LANG`, then French; the installer writes `EL_LANG` to the service settings and the TODO menu passes its own. Rules, verdict codes and JSON keys are never translated - A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated` - A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone +- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit @@ -142,6 +145,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le binaire du cache QEMU parle anglais ou français : journal du service, `--status`, `--age`, aide des options et erreur servie à une VM. La langue vient de `--lang`, puis d'`EL_LANG`, puis du français ; l'installateur écrit `EL_LANG` dans les réglages du service et le menu TODO passe la sienne. Règles, codes de verdict et clés JSON ne se traduisent jamais - Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated` - Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL +- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit ## Fixed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 0e0723b..4aabbe3 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -50,6 +50,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne - F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache - Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera +- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série ## Modifié @@ -63,6 +64,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le binaire du cache QEMU parle anglais ou français : journal du service, `--status`, `--age`, aide des options et erreur servie à une VM. La langue vient de `--lang`, puis d'`EL_LANG`, puis du français ; l'installateur écrit `EL_LANG` dans les réglages du service et le menu TODO passe la sienne. Règles, codes de verdict et clés JSON ne se traduisent jamais - Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated` - Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL +- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit ## Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index d4247ff..4a9460d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -50,6 +50,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline - F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache - The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give +- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series ## Changed @@ -63,6 +64,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The QEMU cache binary speaks English or French: service journal, `--status`, `--age`, option help and the error served to a VM. The language comes from `--lang`, then `EL_LANG`, then French; the installer writes `EL_LANG` to the service settings and the TODO menu passes its own. Rules, verdict codes and JSON keys are never translated - A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated` - A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone +- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit ## Fixed diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index b128060..119b7ff 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -39,6 +39,7 @@ stocké, et le journal doit dire sur quel instantané elle se bâtit. """ import argparse +import copy import json import os import re @@ -1088,8 +1089,9 @@ def main(argv=None): parseur.add_argument( "--distro", default=DISTRO, - choices=sorted(systemes_mesurables()), - help=f"système des VM du test (défaut : {DISTRO})", + help=f"système des VM du test (défaut : {DISTRO}) ; « tous », ou une" + " liste séparée par des virgules, enchaîne une campagne par système :" + f" {', '.join(sorted(systemes_mesurables()))}", ) parseur.add_argument( "--version", @@ -1106,21 +1108,115 @@ def main(argv=None): " et d'Odoo 18, qui se compte en heures", ) args = parseur.parse_args(argv) - # Vide veut dire « celle du catalogue » : la recopier ici la figerait, et - # le test installerait une version que le déploiement ne propose plus. - if not args.version: - args.version = DISTROS[args.distro][1] if args.rapport: return rapport_comparatif() if args.detruire: return detruire(args.dry_run) + try: + systemes = systemes_demandes(args.distro) + except ValueError as err: + parseur.error(str(err)) + if len(systemes) > 1: + if args.version: + parseur.error( + "--version ne vaut que pour un seul système : chacun prend" + " celle du catalogue" + ) + return campagne_par_systemes(args, systemes) + args.distro = systemes[0] + # Vide veut dire « celle du catalogue » : la recopier ici la figerait, et + # le test installerait une version que le déploiement ne propose plus. + if not args.version: + args.version = DISTROS[args.distro][1] + return une_campagne(args)[0] + + +def systemes_demandes(valeur): + """Les systèmes que --distro désigne, dans l'ordre donné, sans doublon. + + « tous » désigne chaque système mesurable, dans l'ordre alphabétique ; une + liste séparée par des virgules, ceux qu'elle nomme. Lève ValueError sur un + nom inconnu : le refus tombe avant qu'aucune machine ne soit créée, et non + au milieu d'une série de plusieurs heures. + """ + connus = systemes_mesurables() + if (valeur or "").strip().lower() == "tous": + return sorted(connus) + noms = [n.strip() for n in (valeur or "").split(",") if n.strip()] + inconnus = [n for n in noms if n not in connus] + if not noms or inconnus: + raise ValueError( + f"système inconnu : {', '.join(inconnus) or repr(valeur)} ;" + f" connus : {', '.join(sorted(connus))}, ou « tous »" + ) + return list(dict.fromkeys(noms)) + + +def campagne_par_systemes(args, systemes): + """Une campagne par système, l'une après l'autre, puis leur tableau. + + Les machines d'un système sont défaites avant le suivant : trois VM par + système ne tiendraient pas toutes ensemble sur le disque de l'hôte. Un + échec n'arrête pas la série — le tableau doit montrer chaque système, et + c'est justement ce qu'on vient chercher quand l'un d'eux échoue. + """ + resultats = [] + for distro in systemes: + un = copy.copy(args) + un.distro, un.version = distro, DISTROS[distro][1] + print(f"\n ══ {distro} {un.version} ══") + code, fichier = une_campagne(un) + resultats.append((distro, un.version, code, fichier)) + if not args.dry_run: + detruire() + return resume_par_systemes(resultats) + + +def resume_par_systemes(resultats): + """Le tableau d'une série : verdict, durées et octets d'amont de la seconde + VM par système. Rend 0 si chaque système a réussi, 1 sinon.""" + print("\n ── Par système ──\n") + print( + f" {'système':<22}{'verdict':<10}{'VM 1':>8}{'VM 2':>8}" + f"{'amont VM 2':>14} étape en échec" + ) + tous_reussis = True + for distro, version, code, fichier in resultats: + rapport = {} + if fichier and os.path.exists(fichier): + try: + with open(fichier, encoding="utf-8") as fh: + rapport = json.load(fh) + except (OSError, ValueError): + rapport = {} + durees = list((rapport.get("durees") or {}).values()) + octets = list((rapport.get("octets") or {}).values()) + rendu = rapport.get("verdict") or ("ok" if code == 0 else "échec") + tous_reussis = tous_reussis and code == 0 and rendu == "ok" + vm1 = f"{durees[0]:.0f}s" if durees else "—" + vm2 = f"{durees[1]:.0f}s" if len(durees) > 1 else "—" + amont = humain(octets[1].get("amont", 0)) if len(octets) > 1 else "—" + print( + f" {distro + ' ' + str(version):<22}{rendu:<10}{vm1:>8}{vm2:>8}" + f"{amont:>14} {rapport.get('etape_en_echec') or ''}" + ) + print() + return 0 if tous_reussis else 1 + + +def une_campagne(args): + """Une campagne sur UN système : préalables, VM, mesure, contre-épreuve. + + Rend (code, chemin du rapport) ; le chemin est vide quand la campagne + s'arrête avant d'avoir écrit un rapport. + """ base = base_des_noms(args) journal = journal_neuf() dire(f" journal : {journal}", journal) if not args.dry_run and not prealables(journal, base): - return 1 + return 1, "" acces = journal_du_cache() if not acces: @@ -1130,7 +1226,7 @@ def main(argv=None): journal, ) if not args.dry_run: - return 1 + return 1, "" rapport = { "_fichier": chemin_rapport(), @@ -1157,7 +1253,10 @@ def main(argv=None): if acces and os.path.exists(acces): decalage = os.path.getsize(acces) - return _boucle(args, rapport, journal, acces, decalage) + return ( + _boucle(args, rapport, journal, acces, decalage), + rapport["_fichier"], + ) def _boucle(args, rapport, journal, acces, decalage): diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 5a007b2..a68f18b 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -282,5 +282,89 @@ class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase): self.assertIn("hors_ligne=True", source) +class TestPlusieursSystemes(unittest.TestCase): + """Une série de campagnes, un système après l'autre.""" + + def test_tous_designe_chaque_systeme_mesurable(self): + self.assertEqual( + QC.systemes_demandes("tous"), sorted(QC.systemes_mesurables()) + ) + + def test_une_liste_garde_son_ordre_sans_doublon(self): + self.assertEqual( + QC.systemes_demandes("fedora, debian,fedora"), ["fedora", "debian"] + ) + + def test_un_nom_inconnu_est_refuse_avant_toute_machine(self): + with self.assertRaises(ValueError): + QC.systemes_demandes("fedora,haiku") + + def lancer(self, argv, codes=None): + from unittest import mock + + appels = [] + codes = list(codes or []) + + def campagne(a): + appels.append((a.distro, a.version)) + return (codes.pop(0) if codes else 0), "" + + with mock.patch.object( + QC, "une_campagne", side_effect=campagne + ), mock.patch.object( + QC, + "detruire", + side_effect=lambda *x, **k: appels.append("détruire"), + ), mock.patch( + "builtins.print" + ): + code = QC.main(argv) + return code, appels + + def test_la_serie_defait_les_machines_entre_deux_systemes(self): + from script.qemu.deploy_qemu import DISTROS + + code, appels = self.lancer( + [ + "--distro", + "fedora,debian", + "--charge", + "erplibre", + "--hors-ligne", + ] + ) + self.assertEqual( + appels, + [ + ("fedora", DISTROS["fedora"][1]), + "détruire", + ("debian", DISTROS["debian"][1]), + "détruire", + ], + ) + self.assertEqual(code, 0) + + def test_un_echec_n_arrete_pas_la_serie(self): + code, appels = self.lancer(["--distro", "fedora,debian"], codes=[1, 0]) + self.assertEqual(len([a for a in appels if a != "détruire"]), 2) + self.assertEqual(code, 1) + + def test_a_blanc_rien_n_est_defait(self): + _, appels = self.lancer(["--distro", "fedora,debian", "--dry-run"]) + self.assertNotIn("détruire", appels) + + def test_la_version_est_refusee_pour_plusieurs_systemes(self): + from unittest import mock + + with mock.patch("sys.stderr"), self.assertRaises(SystemExit): + QC.main(["--distro", "tous", "--version", "12"]) + + def test_un_seul_systeme_suit_le_chemin_d_avant(self): + from script.qemu.deploy_qemu import DISTROS + + _, appels = self.lancer(["--distro", "ubuntu"]) + self.assertEqual(appels, [("ubuntu", DISTROS["ubuntu"][1])]) + + if __name__ == "__main__": unittest.main()