#!/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 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 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, 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 = "/var/lib/erplibre_go_qemu_cache/ca.crt" 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" # 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"