#!/usr/bin/env python3 # © 2026 TechnoLibre (http://www.technolibre.ca) # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) """Le cache de téléchargement sert-il vraiment la seconde VM ? Deux machines sœurs, la même distribution, les mêmes paquets. La première remplit le cache, la seconde doit être servie par lui. Ce script crée de VRAIES VM et dure des dizaines de minutes : il vit dans « long_test/ » et non dans « test/ », que le lanceur unitaire balaie en secondes. ## Ce qui est mesuré, et pourquoi pas ce qu'on croirait « Zéro octet tiré de l'amont » est la manchette, pas le critère. Arch est une publication continue : entre les deux déploiements, un miroir peut publier une version neuve, que la seconde VM tirera légitimement — l'index n'est jamais servi du cache tant que l'amont répond, donc elle la VERRA. Un critère fondé sur le seul volume déclarerait alors le cache en panne alors qu'il fonctionne. Le critère est donc : **aucune URL demandée par les DEUX VM n'est retirée de l'amont une seconde fois.** Un paquet que la première a tiré et que la seconde redemande doit venir du disque, sans exception. Ce que la seconde découvre seule est compté, montré, et n'échoue pas. ## La contre-épreuve, qui fait la valeur du test Un cache qui accélère ne prouve pas qu'il permet de travailler sans réseau. « --hors-ligne » coupe l'accès de l'amont AU SEUL service du cache — par son compte système, pas par une règle générale qui emporterait la session ssh de l'opérateur — et déploie une troisième VM. Elle doit réussir sur l'index stocké, et le journal doit dire sur quel instantané elle se bâtit. ``` ./long_test/qemu_cache.py # deux VM ./long_test/qemu_cache.py --dry-run # le plan, rien de créé ./long_test/qemu_cache.py --hors-ligne # + la troisième VM, amont coupé ./long_test/qemu_cache.py --detruire # défaire ``` """ import argparse import copy import json import os import re import shlex import subprocess import sys import time RACINE = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) sys.path.insert(0, os.path.join(RACINE, "long_test")) sys.path.insert(0, RACINE) from descente import ( # noqa: E402 CACHE_CA, Descente, cle_publique, detruire_etage1, dire, ) # Le catalogue des systèmes vient du DÉPLOIEMENT et n'est pas recopié ici : # distributions, versions par défaut, gestionnaire de paquets et libellés y # sont déjà tenus à jour, et une seconde table dériverait en silence — le test # proposerait alors un système que le déploiement ne sait pas installer. from script.qemu import cache_offline # noqa: E402 from script.qemu.deploy_qemu import ( # noqa: E402 DISTRO_PKG, DISTROS, attente_cloud_final, cache_env_reload, distro_label, ) OUTIL = "qemu_cache" # Le nom d'une VM du test porte TOUT ce qui la distingue : le mode, le système # et la charge. Trois champs séparés par des tirets, chacun pouvant grouper ses # mots par des soulignés — « el-cache-ubuntu_2404-erplibre_odoo_18-1 ». # # Il y a deux raisons, et la seconde est la vraie. Lire « virsh list » doit # suffire à savoir d'où vient chaque machine. Et surtout, deux essais qui ne # portent pas sur la même chose ne se disputent plus les mêmes noms : mesurer # Ubuntu alors qu'un essai Arch survit ne bute plus sur « machine(s) d'un essai # précédent encore là », alors que ces machines n'avaient rien à voir. NOM_BASE = "el-cache" NOM_BASE_SANS_CACHE = "el-no-cache" NOM_BASE_HORS_LIGNE = "el-offline" # Ce que la charge met dans le nom. Une table plutôt qu'une déduction : le # nom doit dire quelle version d'Odoo a été installée, et personne ne devine # « erplibre_odoo_18 » à partir de « erplibre ». NOM_DE_CHARGE = {"minimum": "minimum", "erplibre": "erplibre_odoo_18"} DISTRO = "arch" VERSION = "latest" CLI = os.path.join(RACINE, "script/qemu/deploy_qemu.py") CA = CACHE_CA CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" SERVICE = "erplibre-go-qemu-cache.service" CONF = "/etc/erplibre_go_qemu_cache/env" # La CHARGE : ce que les deux VM téléchargent, et donc ce que la mesure # regarde. Elle doit être identique d'une VM à l'autre, sans quoi la # comparaison ne compare rien. # # « minimum » est un lot volumineux mais court : un compilateur, rust et cmake # pèsent quelques centaines de mégaoctets, ce qui suffit à faire apparaître le # gain en quelques minutes. « erplibre » installe ce que l'on déploie vraiment, # et coûte des heures : c'est la mesure du cas réel, pas celle qu'on lance # pour vérifier que le cache fonctionne. # # Les noms de paquets changent par famille, et se tromper de nom fait échouer # l'installation loin de sa cause. Chaque entrée est (rafraîchir, installer). PAQUETS_MINIMUM = { "pacman": ( "sudo pacman -Syu --noconfirm", "sudo pacman -S --needed --noconfirm base-devel git python rust cmake", ), "apt": ( # « apt-get update » rend ZÉRO même quand un index n'a pas pu être # récupéré : il n'émet qu'un avertissement, que « -qq » cachait. Le lot # suivant échouait alors sur « Unable to locate package # build-essential », très loin de sa cause. « Error-Mode=any » fait de # tout index manquant une erreur, donc un arrêt qui se lit. "sudo apt-get update -o APT::Update::Error-Mode=any", "sudo DEBIAN_FRONTEND=noninteractive apt-get install -y" " build-essential git python3 rustc cargo cmake", ), "dnf": ( "sudo dnf -y makecache", "sudo dnf -y install gcc gcc-c++ make git python3 rust cargo cmake", ), "zypper": ( "sudo zypper -n refresh", "sudo zypper -n install gcc gcc-c++ make git python3 rust cargo cmake", ), } # Ce qui doit être fini AVANT de toucher au gestionnaire de paquets. # # cloud-init réécrit la liste des dépôts à son premier démarrage — il y # substitue un miroir géographique. Une mise à jour lancée pendant ce # remplacement récupère une partie des index et s'arrête là, sans échouer : # l'installation qui suit ne trouve alors plus les paquets de « main », et le # message accuse le paquet plutôt que le moment. sshd répond bien avant que # cloud-init ait fini, si bien que rien n'empêche d'arriver trop tôt. # # Le code de sortie est ignoré à dessein : cloud-init sort en erreur pour un # module accessoire — un fuseau que l'invité ne connaît pas, par exemple — et # ce n'est pas une raison de renoncer à la mesure. # Un travail de fond tient le verrou du gestionnaire de paquets juste après le # démarrage — sur Ubuntu, « apt-daily » se déclenche au boot et cloud-init ne # l'attend pas. « DPkg::Lock::Timeout » ne couvre pas le verrou des LISTES : # deux mises à jour concurrentes échouent toutes les deux en moins d'une # seconde, que l'option soit posée ou non. # # La reprise vaut donc pour toutes les familles sans connaître leur mécanisme # de verrou. Elle ne masque rien : une source réellement en panne épuise les # tentatives et rend le même code d'erreur, message visible, cinq minutes plus # tard. REPRISES = 20 PAUSE_REPRISE = 15 def avec_reprises(commande): """La commande, réessayée tant qu'un verrou la refuse.""" return ( f"n=0; until {commande}; do n=$((n+1));" f" [ $n -ge {REPRISES} ] && exit 1;" ' echo " reprise $n : le gestionnaire de paquets est occupé";' f" sleep {PAUSE_REPRISE}; done" ) ATTENDRE_CLOUD_INIT = ( "if command -v cloud-init >/dev/null 2>&1; then" " sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 || true; fi" # « status --wait » rend la main dès que cloud-init se déclare en erreur, # alors que son étape finale écrit encore : l'unité, elle, dit la vérité. f"; {attente_cloud_final()}" # La session s'ouvre avant que cloud-init n'écrive les variables du cache : # sans les relire, un npm lancé sans sudo rejette l'autorité du cache. f"; {cache_env_reload()}" ) # La charge réelle : le dépôt cloné dans la VM, puis la cible qui l'installe. # La même paire que le déploiement emploie — clone puis « make » — pour que ce # qui est mesuré ici soit ce qui se passe vraiment. DEPOT = "https://github.com/erplibre/erplibre" BRANCHE = "master" CIBLE_ERPLIBRE = "make install_os && make install_odoo_18" DELAI_CREATION = 1800 DELAI_SSH = 600 # La charge minimale se compte en minutes, ERPLibre en heures : un délai # unique ferait échouer l'une ou laisserait l'autre pendre indéfiniment. DELAI_CHARGE = {"minimum": 2400, "erplibre": 14400} # Le gabarit des VM, par charge : (vCPU, Mo, disque). # # Le lot minimal se contente de peu — il télécharge, il ne bâtit pas. ERPLibre # compile son interpréteur et pose ses dépendances : deux cœurs y passeraient # des heures de plus, et ERPLibre seul occupe plusieurs gigaoctets, ce qui ne # laisse rien à Odoo sur un disque de vingt. GABARIT = { "minimum": (2, 4096, "20G"), "erplibre": (4, 8192, "40G"), } def journal_neuf(): chemin = os.path.expanduser( f"~/.erplibre/longtest/{OUTIL}-{time.strftime('%Y%m%d-%H%M%S')}.log" ) os.makedirs(os.path.dirname(chemin), exist_ok=True) return chemin def chemin_rapport(horodatage=None): horodatage = horodatage or time.strftime("%Y%m%d-%H%M%S") return os.path.expanduser( f"~/.erplibre/longtest/{OUTIL}-{horodatage}.json" ) def machines_a_defaire(limite=20): """Tout ce que les rapports récents nomment, avec l'UUID quand il existe. Le rapport le plus récent ne suffit pas. Une exécution qui échoue à la création écrit un rapport qui NOMME une machine sans la connaître : le nom y est noté avant la création, justement pour qu'une création interrompue à mi-chemin laisse une trace. Ce rapport-là masquerait celui d'une exécution antérieure qui, elle, détient l'UUID — et la destruction retomberait sur le nom, ce que ce dépôt a appris à ne plus faire. Les rapports sont donc parcourus du plus ANCIEN au plus récent, l'UUID d'un rapport qui en a un l'emportant sur l'absence d'un autre. Rend {nom: uuid ou ""} et la liste des fichiers lus. """ machines, lus = {}, [] rep = os.path.expanduser("~/.erplibre/longtest") fichiers = ( sorted( f for f in os.listdir(rep) if f.startswith(OUTIL) and f.endswith(".json") )[-limite:] if os.path.isdir(rep) else [] ) for nom in fichiers: chemin = os.path.join(rep, nom) try: with open(chemin, encoding="utf-8") as fh: data = json.load(fh) except (OSError, ValueError): continue if not data.get("vms"): continue lus.append(chemin) uuids = data.get("uuids") or {} for vm in data["vms"]: # Un UUID connu ne se perd jamais au profit d'un rapport muet. if uuids.get(vm) or vm not in machines: machines[vm] = uuids.get(vm, machines.get(vm, "")) # Les rapports ne suffisent pas, et le balayage qui suit se fait DANS TOUS # LES CAS — dossier de rapports absent compris, qui est justement l'état # où une machine vivante n'est nommée nulle part. Ils sont bornés à # `limite` : une machine nommée par un rapport plus ancien que cette # fenêtre ne serait jamais défaite et bloquerait tous les essais suivants # sans qu'aucune commande sache la retirer. Ce qui VIT tranche. for prefixe in (NOM_BASE, NOM_BASE_SANS_CACHE, NOM_BASE_HORS_LIGNE): for vm in machines_vivantes(prefixe): machines.setdefault(vm, "") return machines, lus def rapports_recents(limite=12): """Les rapports d'exécution, du plus récent au plus ancien.""" rep = os.path.expanduser("~/.erplibre/longtest") if not os.path.isdir(rep): return [] out = [] for nom in sorted( ( f for f in os.listdir(rep) if f.startswith(OUTIL) and f.endswith(".json") ), reverse=True, )[:limite]: try: with open(os.path.join(rep, nom), encoding="utf-8") as fh: d = json.load(fh) except (OSError, ValueError): continue if not mesure_reelle(d): continue d["_fichier"] = nom out.append(d) return out def mesure_reelle(rapport): """Ce rapport porte-t-il une mesure, ou seulement un plan ? La marque « dry_run » tranche pour les rapports récents. Les plus anciens ne la portent pas : une durée nulle partout les trahit, car une installation de paquets qui prend zéro seconde n'a pas eu lieu. """ if rapport.get("dry_run"): return False durees = rapport.get("durees") or {} return any(d > 0 for d in durees.values()) def rapport_comparatif(): """Ce que le cache fait gagner, mesuré et non annoncé. Deux exécutions suffisent : une avec cache, une sans. Sans le témoin, un temps ne dit rien — une installation rapide peut l'être parce que le miroir est proche, pas parce qu'un cache a servi. """ rapports = rapports_recents() if not rapports: print("\n Aucune exécution mesurée. Lancer le test, puis le témoin :") print(" ./long_test/qemu_cache.py") print(" ./long_test/qemu_cache.py --sans-cache\n") return 1 print("\n ── Rapport de performance ──\n") print( f" {'exécution':<18}{'cache':<7}{'VM':<40}" f"{'durée':>7}{'amont':>12}{'du cache':>12}" ) print(" " + "─" * 98) # Ni colonne « système » ni colonne « charge » : le NOM de la machine les # porte désormais tous les deux, et les répéter à côté volerait la largeur # dont ce nom a besoin. Les rapports d'avant ce nommage montrent un nom # court — c'est exactement ce qu'ils savaient de leur propre essai. for r in rapports[:6]: etiquette = r["debut"][:16].replace("T", " ") for nom, duree in (r.get("durees") or {}).items(): o = (r.get("octets") or {}).get(nom, {}) print( f" {etiquette:<18}" f"{'oui' if r.get('cache') else 'non':<7}" f"{nom:<40}{duree:>6.0f}s" f"{humain(o.get('amont', 0)):>12}" f"{humain(o.get('cache', 0)):>12}" ) etiquette = "" # Le gain se calcule PAR CONDITION, jamais sur l'ensemble : une même # moyenne mêlerait une installation d'ERPLibre sur Ubuntu, qui dure des # minutes, à un lot de paquets sur Arch, qui dure vingt secondes. Le # rapport annoncerait alors un gain qui ne mesure que le mélange. conditions = {} for r in rapports: cle = (r.get("distro") or "?", r.get("charge") or "minimum") conditions.setdefault(cle, {"avec": [], "sans": []})[ "avec" if r.get("cache") else "sans" ].append(r) compare = False for (distro, charge), groupe in sorted(conditions.items()): da = moyenne_seconde_vm(groupe["avec"]) ds = moyenne_seconde_vm(groupe["sans"]) if not (da and ds): continue compare = True print(f"\n {distro} · {charge} — seconde VM") print(f" avec cache : {da:.0f} s") print(f" sans cache : {ds:.0f} s") if ds > da: print( f" gain : {ds - da:.0f} s," f" soit {100 * (ds - da) / ds:.0f} %" ) else: # Un gain nul est un RÉSULTAT, pas une erreur : sur un lien # rapide, le temps est dominé par l'installation et non par le # téléchargement. print( " aucun gain de TEMPS : sur ce lien, le téléchargement ne" " domine pas.\n Le gain porte alors sur les octets, colonne" " « amont »." ) if not compare: print("\n Aucune condition ne réunit une exécution avec cache et son") print(" témoin. Lancer les deux sur le MÊME système et la même") print(" charge, par exemple :") print( " ./long_test/qemu_cache.py --distro ubuntu --charge erplibre" ) print( " ./long_test/qemu_cache.py --distro ubuntu --charge erplibre" " --sans-cache" ) print() return 0 def moyenne_seconde_vm(rapports): """La durée de la SECONDE VM, celle que le cache doit servir.""" valeurs = [] for r in rapports: for nom, d in (r.get("durees") or {}).items(): if nom.endswith("-2"): valeurs.append(d) return sum(valeurs) / len(valeurs) if valeurs else 0 def humain(n): for unite in ("o", "Kio", "Mio", "Gio"): if n < 1024 or unite == "Gio": return f"{n:.0f} {unite}" if unite == "o" else f"{n:.1f} {unite}" n /= 1024 return f"{n:.1f} Tio" def executer(cmd, delai, journal=None, montrer=False): """Une commande locale. Rend (code, sortie).""" if journal: dire(f" $ {cmd}", journal) try: p = subprocess.run( cmd, shell=True, capture_output=not montrer, text=True, timeout=delai, ) except subprocess.TimeoutExpired: return 124, f"délai dépassé après {delai} s" return p.returncode, (p.stdout or "") + (p.stderr or "") # -------------------------------------------------------------------------- # Contrôles préalables # -------------------------------------------------------------------------- def prealables(journal, base=NOM_BASE): """Ce qui doit être vrai AVANT de créer la moindre machine. Chaque manque est dit avec son remède : découvrir au bout de vingt minutes que le cache n'écoutait pas est le genre d'échec qui ne se pardonne pas. """ manques = [] if not os.path.isfile(CA): manques.append( f"autorité du cache absente ({CA}) — TODO › Déploiement › Cache QEMU" ) code, _ = executer(f"systemctl is-active --quiet {SERVICE}", 15) if code: manques.append( f"le service {SERVICE} ne tourne pas — systemctl start {SERVICE}" ) if not cle_publique(): manques.append("aucune clé publique ssh dans ~/.ssh") code, _ = executer("virsh -c qemu:///system list --all", 30) if code: manques.append("libvirt injoignable — virsh -c qemu:///system list") if not os.path.isfile(CLI): manques.append(f"deploy_qemu.py absent ({CLI})") ecart = desaccord_de_reseau() if ecart: manques.append(ecart) restes = machines_vivantes(base) if restes: # Sans cette garde, la création bute sur le disque de la machine # restante et rend un « existe déjà » qui ne dit pas quoi faire. manques.append( f"machine(s) d'un essai précédent encore là : {', '.join(restes)}" f" — les défaire d'abord : {sys.argv[0]} --detruire" ) for m in manques: dire(f" ✗ {m}", journal) return not manques def prefixe_du_mode(args): """Le champ « mode » du nom, seul. C'est lui que le démontage balaie.""" if getattr(args, "sans_cache", False): return NOM_BASE_SANS_CACHE if getattr(args, "hors_ligne", False): return NOM_BASE_HORS_LIGNE return NOM_BASE def segment_systeme(distro, version): """« ubuntu_2404 », « arch », « opensuse_160 ». Le point saute, comme dans les noms de VM du parc. « latest » saute aussi : une distribution en publication continue n'en a qu'une, si bien que le segment ne distinguerait aucune machine d'une autre. """ if not version or version == "latest": return distro return f"{distro}_{version.replace('.', '')}" def nom_de_base(prefixe, distro, version, charge): """Le début du nom, commun aux VM d'un même essai. Le rang s'ajoute après. Fonction PURE, sans arguments de ligne de commande : le menu s'en sert pour annoncer les machines qui vont naître, et annoncer un nom qui ne serait pas celui qui naît vaut moins que de ne rien annoncer. """ return ( f"{prefixe}-{segment_systeme(distro, version)}" f"-{NOM_DE_CHARGE.get(charge, charge)}" ) def base_des_noms(args): """Le début du nom des VM de CET essai, mode, système et charge compris.""" return nom_de_base( prefixe_du_mode(args), getattr(args, "distro", DISTRO), getattr(args, "version", "") or VERSION, getattr(args, "charge", "minimum"), ) def machines_vivantes(base=NOM_BASE): """Les machines de CE mode qui existent encore, par leur nom. De ce mode SEULEMENT : une machine laissée par une autre expérience n'entre en conflit avec rien, et refuser de partir à cause d'elle obligerait à tout défaire pour lancer une mesure indépendante. """ code, sortie = executer("virsh -c qemu:///system list --all --name", 30) if code: return [] return [ l.strip() for l in (sortie or "").split("\n") if l.strip().startswith(base) ] def desaccord_de_reseau(): """Les règles visent-elles le sous-réseau que libvirt sert VRAIMENT ? Le réseau « default » ne sert pas toujours 192.168.122.0/24 : il est déplacé sur un /24 libre dès que ce préfixe entre en collision, ce qui est le cas de tout orchestrateur qui est lui-même une VM. Des règles posées sur l'autre préfixe existent, l'installation réussit, et aucune VM ne traverse le cache. Rend un message, ou "" si l'accord est fait. """ code, xml = executer("virsh -c qemu:///system net-dumpxml default", 30) if code: return "" m = re.search(r" 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, "" acces = journal_du_cache() if not acces: dire( " ✗ le service ne tient pas de journal d'accès : la mesure est" f" impossible. Poser EL_ACCESS_LOG dans {CONF}.", journal, ) if not args.dry_run: return 1, "" rapport = { "_fichier": chemin_rapport(), "outil": OUTIL, "debut": time.strftime("%Y-%m-%dT%H:%M:%S"), "vms": [], "cache": not args.sans_cache, # Le système et la charge SONT la mesure : deux exécutions qui ne les # partagent pas ne se comparent pas, et le tableau doit pouvoir le # dire plutôt que d'aligner des durées sans rapport. "distro": args.distro, "version": args.version, "charge": args.charge, # Un plan à blanc ne crée rien et ne mesure rien. Il écrit pourtant un # rapport, et sans cette marque le comparatif compte ses durées de # zéro comme des mesures : il montre alors des exécutions qui n'ont # jamais eu lieu et tire la moyenne vers le bas. "dry_run": bool(args.dry_run), "journal": journal, } ecrire_rapport(rapport) decalage = 0 if acces and os.path.exists(acces): decalage = os.path.getsize(acces) return ( _boucle(args, rapport, journal, acces, decalage), rapport["_fichier"], ) def _boucle(args, rapport, journal, acces, decalage): """Les deux VM, la mesure, et ce qui suit.""" base = base_des_noms(args) fenetres = [] for rang in (1, 2): dire("", journal) dire(f" ── VM {rang} ──", journal) nom = f"{base}-{rang}" # NOTÉ AVANT la création : une création échouée à mi-chemin laisserait # sinon une machine que le rapport ne nomme nulle part. rapport["vms"].append(nom) ecrire_rapport(rapport) adresse = deployer( nom, journal, args.dry_run, avec_cache=not args.sans_cache, distro=args.distro, version=args.version, charge=args.charge, ) if not adresse: return _clore( args, rapport, journal, False, f"{nom} : déploiement" ) noter_uuid(rapport, nom, args.dry_run) if not args.dry_run and not attendre_ssh(adresse, journal): return _clore(args, rapport, journal, False, f"{nom} : ssh") debut = time.time() if not poser_les_paquets( adresse, journal, args.dry_run, args.distro, args.charge ): return _clore(args, rapport, journal, False, f"{nom} : paquets") duree = time.time() - debut lignes, decalage = ([], decalage) if acces: lignes, decalage = lignes_depuis(acces, decalage) fenetres.append(lignes) # La durée est la seule matière d'une comparaison avec le témoin : # gardée dans le rapport, elle survit à la session. rapport.setdefault("durees", {})[nom] = round(duree, 1) rapport.setdefault("octets", {})[nom] = { "amont": sum( l.get("bytes", 0) for l in lignes if l.get("upstream") ), "cache": sum( l.get("bytes", 0) for l in lignes if not l.get("upstream") ), } ecrire_rapport(rapport) dire(f" VM {rang} : paquets posés en {duree:.0f} s", journal) # Sa mesure est dans le rapport : allumée, elle ne sert plus à rien et # tient sa mémoire pendant que la suivante s'installe. eteindre(nom, journal, args.dry_run) ok = True if args.dry_run: dire("", journal) dire(" [à blanc] la mesure comparerait les deux fenêtres.", journal) elif args.sans_cache: # Le témoin ne prouve rien sur le cache : il MESURE ce que coûte son # absence. Lui appliquer le critère n'aurait aucun sens. dire("", journal) dire(" ── Témoin : sans cache ──", journal) for nom, d in (rapport.get("durees") or {}).items(): dire(f" {nom} : {d:.0f} s", journal) dire(" Comparer avec « --rapport ».", journal) else: ok = verdict(fenetres[0], fenetres[1], journal) if args.hors_ligne: ok = ( contre_epreuve( journal, rapport, args.dry_run, base_des_noms(args), args.distro, args.version, args.charge, ) and ok ) return _clore( args, rapport, journal, ok, rapport.pop("etape_en_echec", "") ) def _clore(args, rapport, journal, ok, etape=""): """Écrit la fin et le verdict du rapport, et rend le code de sortie. Toute sortie de la boucle passe par ici, échec d'une étape compris : un rapport sans « fin » ni « verdict » ne dit pas si l'exécution a échoué ou tourne encore. « etape » nomme la VM et l'étape qui ont arrêté la boucle. """ rapport["fin"] = time.strftime("%Y-%m-%dT%H:%M:%S") rapport["cache"] = not args.sans_cache rapport["verdict"] = "ok" if ok else "échec" if etape: rapport["etape_en_echec"] = etape dire(f" ✗ arrêt sur {etape}", journal) ecrire_rapport(rapport) dire("", journal) dire(f" rapport : {rapport['_fichier']}", journal) dire(f" défaire : {sys.argv[0]} --detruire", journal) return 0 if ok else 1 if __name__ == "__main__": sys.exit(main())