From fb2f71477ec07baf5222fa48254cf01064104af7 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 02:59:01 -0400 Subject: [PATCH 01/13] =?UTF-8?q?[ADD]=20cache=20qemu=20:=20messages=20du?= =?UTF-8?q?=20binaire=20en=20anglais=20ou=20en=20fran=C3=A7ais?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le binaire ne parlait que français alors que le menu suit EL_LANG : un opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message humain passe par T(), dont la clé EST le texte français, et un catalogue anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses motifs ne connaissant que cette langue. Un test refuse un message sans traduction, une traduction orpheline et un verbe de format qui diverge. --- EN --- The binary spoke French only while the menu follows EL_LANG: an English reader got a status he could not read. Every human message goes through T(), whose key IS the French text, and an English catalogue translates it; the language comes from --lang, then EL_LANG, French remaining the default. The menu asks for French for what it READS BACK, its patterns knowing that language alone. A test refuses a message without translation, an orphan translation, and a format verb that drifts. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/install/install_qemu_cache.sh | 3 + script/qemu_cache/age.go | 28 ++-- script/qemu_cache/boucle.go | 6 +- script/qemu_cache/bypass.go | 10 +- script/qemu_cache/catalogue_en.go | 155 +++++++++++++++++ script/qemu_cache/gitmirror.go | 18 +- script/qemu_cache/joignable.go | 6 +- script/qemu_cache/langue.go | 95 +++++++++++ script/qemu_cache/langue_test.go | 238 +++++++++++++++++++++++++++ script/qemu_cache/main.go | 180 ++++++++++---------- script/qemu_cache/mitm.go | 22 +-- script/qemu_cache/origdst_linux.go | 4 +- script/qemu_cache/origdst_other.go | 2 +- script/qemu_cache/proxy.go | 12 +- script/qemu_cache/store.go | 8 +- script/todo/qemu_cache_menu.py | 57 +++++-- script/todo/todo.py | 1 + test/test_i18n_duplicates.py | 83 ---------- test/test_qemu_cache_langue.py | 103 ++++++++++++ test/test_todo_i18n.py | 17 ++ 23 files changed, 813 insertions(+), 239 deletions(-) create mode 100644 script/qemu_cache/catalogue_en.go create mode 100644 script/qemu_cache/langue.go create mode 100644 script/qemu_cache/langue_test.go delete mode 100644 test/test_i18n_duplicates.py create mode 100644 test/test_qemu_cache_langue.py diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 8f48eb6..c3e224f 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -126,6 +126,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Mirror prefetch runs under the cache's service account, never as root - The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing - Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool +- 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 @@ -136,6 +137,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le pré-remplissage des miroirs tourne sous le compte du service du cache, jamais en root - Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien - Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil +- 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 ## Fixed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 62bb155..8d6706b 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -60,6 +60,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le pré-remplissage des miroirs tourne sous le compte du service du cache, jamais en root - Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien - Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil +- 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 ## Corrigé diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ef5258..c007051 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Mirror prefetch runs under the cache's service account, never as root - The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing - Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool +- 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 ## Fixed diff --git a/script/install/install_qemu_cache.sh b/script/install/install_qemu_cache.sh index 7d471bd..d34572d 100755 --- a/script/install/install_qemu_cache.sh +++ b/script/install/install_qemu_cache.sh @@ -26,6 +26,8 @@ EL_TLS_PORT="${EL_TLS_PORT:-8899}" # arrive dès que 192.168.122 entre en collision, voit ses VM échapper au # détournement sans que rien ne le signale. EL_BRIDGE="${EL_BRIDGE:-}" +# Langue des messages du service : « fr » ou « en », comme EL_LANG de todo.py. +EL_LANG="${EL_LANG:-fr}" EL_SUBNET="${EL_SUBNET:-}" EL_NET="${EL_NET:-default}" EL_CACHE_DIR="${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}" @@ -264,6 +266,7 @@ EL_ACCESS_LOG=${EL_ACCESS_LOG} EL_EXCLUDE=${EL_EXCLUDE} EL_BYPASS_FILE=${EL_BYPASS_FILE} EL_GIT_MIRROR_DIR=${EL_GIT_MIRROR_DIR} +EL_LANG=${EL_LANG} CONF chmod 0644 "${CONF_DIR}/env" } diff --git a/script/qemu_cache/age.go b/script/qemu_cache/age.go index eacf5c5..28a15fc 100644 --- a/script/qemu_cache/age.go +++ b/script/qemu_cache/age.go @@ -51,7 +51,7 @@ func LireGranularite(s string) (Granularite, error) { case "mois", "month", "m": return ParMois, nil } - return "", fmt.Errorf("granularité inconnue %q : jour, semaine ou mois", s) + return "", fmt.Errorf(T("granularité inconnue %q : jour, semaine ou mois"), s) } // debutDeTranche ramène un instant au premier de sa tranche. @@ -205,19 +205,19 @@ func (g *GitMirror) PurgerMiroirs(avant time.Time) (int, int64, error) { func LireDuree(s string) (time.Duration, error) { s = strings.ToLower(strings.TrimSpace(s)) if s == "" { - return 0, fmt.Errorf("durée vide") + return 0, fmt.Errorf("%s", T("durée vide")) } if n, unite := s[:len(s)-1], s[len(s)-1]; unite == 'j' || unite == 'd' { var jours float64 if _, err := fmt.Sscanf(n, "%g", &jours); err != nil || jours < 0 { - return 0, fmt.Errorf("durée illisible %q", s) + return 0, fmt.Errorf(T("durée illisible %q"), s) } return time.Duration(jours * 24 * float64(time.Hour)), nil } d, err := time.ParseDuration(s) if err != nil || d < 0 { return 0, fmt.Errorf( - "durée illisible %q : essayer 30j, 12h, 90m", s) + T("durée illisible %q : essayer 30j, 12h, 90m"), s) } return d, nil } @@ -228,16 +228,16 @@ func LireDuree(s string) (time.Duration, error) { // un fichier, un dépôt est un arbre — et un total commun cacherait qu'un seul // dépôt pèse plus que mille objets. func printAge(store *Store, miroir *GitMirror, gran Granularite) { - fmt.Printf("Âge du dernier usage, par %s.\n", gran) + fmt.Printf(T("Âge du dernier usage, par %s.\n"), T(string(gran))) fmt.Printf( - "\nUn objet servi voit sa date remise à jour : « vieux » veut donc " + - "dire\n« n'a plus servi », et non « est entré il y a " + - "longtemps ».\n") + "%s", T("\nUn objet servi voit sa date remise à jour : « vieux » veut donc "+ + "dire\n« n'a plus servi », et non « est entré il y a "+ + "longtemps ».\n")) ecrire := func(titre string, tranches []Tranche, quoi string) { fmt.Printf("\n%s\n", titre) if len(tranches) == 0 { - fmt.Println(" rien") + fmt.Println(T(" rien")) return } var n int @@ -251,9 +251,9 @@ func printAge(store *Store, miroir *GitMirror, gran Granularite) { fmt.Printf(" %-12s %6d %-8s %10s\n", "total", n, quoi, HumanBytes(octets)) } - ecrire("Objets du cache :", store.Tranches(gran), "objets") + ecrire(T("Objets du cache :"), store.Tranches(gran), T("objets")) if miroir.Actif() { - ecrire("Dépôts en miroir :", miroir.Tranches(gran), "dépôts") + ecrire(T("Dépôts en miroir :"), miroir.Tranches(gran), T("dépôts")) } } @@ -270,7 +270,7 @@ func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) { octets += tr.Octets } } - fmt.Printf("[à blanc] objets qui partiraient : %d, %s\n", + fmt.Printf(T("[à blanc] objets qui partiraient : %d, %s\n"), n, HumanBytes(octets)) var nd int @@ -281,7 +281,7 @@ func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) { octd += d.Octets } } - fmt.Printf("[à blanc] dépôts qui partiraient : %d, %s\n", + fmt.Printf(T("[à blanc] dépôts qui partiraient : %d, %s\n"), nd, HumanBytes(octd)) - fmt.Println("[à blanc] rien n'a été effacé.") + fmt.Println(T("[à blanc] rien n'a été effacé.")) } diff --git a/script/qemu_cache/boucle.go b/script/qemu_cache/boucle.go index da283f3..0a5d99c 100644 --- a/script/qemu_cache/boucle.go +++ b/script/qemu_cache/boucle.go @@ -109,9 +109,9 @@ func (p *Proxy) boucle( w http.ResponseWriter, u *url.URL, class Class, method, client string, ) { msg := enCommentaire(fmt.Sprintf( - "erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+ + T("erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+ " demandé : %s\n"+ - "La relayer la renverrait vers cette même écoute, sans fin.\n", + "La relayer la renverrait vers cette même écoute, sans fin.\n"), u)) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-ERPLibre-Cache", OutcomeError) @@ -123,7 +123,7 @@ func (p *Proxy) boucle( Outcome: OutcomeError, Status: http.StatusLoopDetected, Client: client, }) - log.Printf("boucle refusée : %s vise le cache lui-même", u) + log.Printf(T("boucle refusée : %s vise le cache lui-même"), u) } // memeAdresse compare deux « hôte:port » par leur VALEUR : « ::ffff:a.b.c.d » diff --git a/script/qemu_cache/bypass.go b/script/qemu_cache/bypass.go index fb297c4..83f5322 100644 --- a/script/qemu_cache/bypass.go +++ b/script/qemu_cache/bypass.go @@ -55,7 +55,7 @@ type BypassEntry struct { func NormalizeMAC(s string) (string, error) { brut := strings.TrimSpace(s) if brut == "" { - return "", fmt.Errorf("adresse MAC vide") + return "", fmt.Errorf("%s", T("adresse MAC vide")) } // Douze caractères hexadécimaux sans séparateur : la forme que rendent // plusieurs outils, que net.ParseMAC ne lit pas. @@ -68,14 +68,14 @@ func NormalizeMAC(s string) (string, error) { } adr, err := net.ParseMAC(brut) if err != nil { - return "", fmt.Errorf("adresse MAC illisible %q", s) + return "", fmt.Errorf(T("adresse MAC illisible %q"), s) } if len(adr) != 6 { - return "", fmt.Errorf("adresse MAC de %d octets, six attendus", len(adr)) + return "", fmt.Errorf(T("adresse MAC de %d octets, six attendus"), len(adr)) } if adr[0]&1 == 1 { return "", fmt.Errorf( - "%s est une adresse de groupe : jamais une adresse source", adr) + T("%s est une adresse de groupe : jamais une adresse source"), adr) } nulle := true for _, o := range adr { @@ -85,7 +85,7 @@ func NormalizeMAC(s string) (string, error) { } } if nulle { - return "", fmt.Errorf("l'adresse nulle n'identifie aucune machine") + return "", fmt.Errorf("%s", T("l'adresse nulle n'identifie aucune machine")) } return strings.ToLower(adr.String()), nil } diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go new file mode 100644 index 0000000..9092632 --- /dev/null +++ b/script/qemu_cache/catalogue_en.go @@ -0,0 +1,155 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +// anglais traduit les messages humains du binaire. La clé est le texte +// français mot pour mot, tel que T le reçoit ; une clé absente sort en +// français. langue_test.go refuse un T(...) sans entrée, une entrée +// qu'aucun T(...) n'emploie, et une traduction qui ne garde pas les mêmes +// verbes de format que son original. +var anglais = map[string]string{ + "langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG", + "granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month", + "durée vide": "empty duration", + "durée illisible %q": "unreadable duration %q", + "durée illisible %q : essayer 30j, 12h, 90m": "unreadable duration %q: try 30d, 12h, 90m", + "Âge du dernier usage, par %s.\n": "Age of last use, by %s.\n", + "\nUn objet servi voit sa date remise à jour : « vieux » veut donc dire\n« n'a plus servi », et non « est entré il y a longtemps ».\n": "\nA served object has its date renewed: « old » therefore means\n« no longer served », not « stored long ago ».\n", + " rien": " nothing", + "[à blanc] objets qui partiraient : %d, %s\n": "[dry run] objects that would go: %d, %s\n", + "[à blanc] dépôts qui partiraient : %d, %s\n": "[dry run] repositories that would go: %d, %s\n", + "[à blanc] rien n'a été effacé.": "[dry run] nothing was erased.", + "erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n demandé : %s\nLa relayer la renverrait vers cette même écoute, sans fin.\n": "erplibre_go_qemu_cache: request addressed to the cache itself, refused.\n requested: %s\nRelaying it would send it back to this same listener, endlessly.\n", + "boucle refusée : %s vise le cache lui-même": "loop refused: %s targets the cache itself", + "adresse MAC vide": "empty MAC address", + "adresse MAC illisible %q": "unreadable MAC address %q", + "adresse MAC de %d octets, six attendus": "MAC address of %d bytes, six expected", + "%s est une adresse de groupe : jamais une adresse source": "%s is a group address: never a source address", + "l'adresse nulle n'identifie aucune machine": "the null address identifies no machine", + "dépôt illisible %q": "unreadable repository %q", + "dépôt sans chemin %q": "repository without a path %q", + "chemin de dépôt refusé %q": "repository path refused %q", + "miroir refusé pour %s : moins de %s libres sur le disque": "mirror refused for %s: less than %s free on disk", + "miroir git : sonde de %s en échec (%v) ; ses miroirs sont servis sans rafraîchissement jusqu'à ce qu'il réponde": "git mirror: probe of %s failed (%v); its mirrors are served without refresh until it answers", + "git %s : %v : %s": "git %s: %v: %s", + "miroir éteint": "mirror disabled", + "hors des miroirs : %s": "outside the mirrors: %s", + "amont connu muet : l'établissement vient d'échouer, il n'est pas retenté": "upstream known mute: connecting just failed, it is not retried", + "répertoire des objets gardés": "directory of kept objects", + "répertoire de l'autorité de certification": "certificate authority directory", + "écoute pour le 80 détourné": "listener for redirected port 80", + "écoute pour le 443 détourné": "listener for redirected port 443", + "pont libvirt des VM": "libvirt bridge of the VMs", + "sous-réseau des VM": "subnet of the VMs", + "journal d'accès JSON par ligne": "access log, one JSON object per line", + "hôtes à ne jamais déchiffrer, séparés par des virgules": "hosts never to decrypt, comma-separated", + "dire chaque requête": "log every request", + "dire ce que le cache occupe, puis sortir": "print what the cache occupies, then exit", + "montrer les gestes privilégiés sans en faire un": "show the privileged steps without performing any", + "créer l'autorité si elle manque, puis sortir": "create the authority if missing, then exit", + "écrire les règles nft seules, à passer à « nft -f - »": "print the nft rules alone, to feed to « nft -f - »", + "écrire les commandes iptables seules, une par ligne": "print the iptables commands alone, one per line", + "liste des VM soustraites au détournement, une MAC par ligne": "list of VMs excluded from redirection, one MAC per line", + "soustraire cette adresse MAC au détournement": "exclude this MAC address from redirection", + "nom de la VM, écrit à côté de la MAC ajoutée": "VM name, written next to the added MAC", + "rendre cette adresse MAC au détournement": "return this MAC address to redirection", + "racine des dépôts git tenus en miroir ; vide, git est simplement relayé vers l'amont": "root of the mirrored git repositories; empty, git is simply relayed upstream", + "délai en deçà duquel un dépôt n'est pas re-interrogé": "delay under which a repository is not queried again", + "fichier de dépôts, un par ligne, à tenir en miroir d'avance": "file of repositories, one per line, to mirror ahead", + "dépôts clonés en parallèle par le pré-remplissage": "repositories cloned in parallel by the prefill", + "dire ce que le cache occupe, groupé par âge du dernier usage": "print what the cache occupies, grouped by age of last use", + "découpage du relevé par âge : jour, semaine ou mois": "split of the age report: day, week or month", + "effacer TOUT le cache : objets et dépôts en miroir": "erase the WHOLE cache: objects and mirrored repositories", + "n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)": "erase only what has not served for this delay (e.g. 30d, 12h)", + "dire les dépôts tenus en miroir, du plus lourd au plus léger": "list the mirrored repositories, heaviest first", + "effacer le miroir de ce dépôt ; il se refera au prochain besoin": "erase this repository's mirror; it is rebuilt when next needed", + "dire les exceptions en place, une « MAC nom » par ligne": "list the exceptions in place, one « MAC name » per line", + "dire la version, puis sortir": "print the version, then exit", + "lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched", + "entrée illisible : %v\n": "unreadable input: %v\n", + "exceptions illisibles : %v\n": "unreadable exceptions: %v\n", + "exception refusée : %v\n": "exception refused: %v\n", + "exception posée : %s %s\n": "exception added: %s %s\n", + "aucune exception pour %s\n": "no exception for %s\n", + "efface ce qui n'a pas servi depuis %s (avant %s)\n": "erasing what has not served for %s (before %s)\n", + "efface TOUT le cache": "erasing the WHOLE cache", + "purge des objets : %v\n": "object purge: %v\n", + "objets effacés : %d, %s rendus\n": "objects erased: %d, %s freed\n", + "purge des miroirs : %v\n": "mirror purge: %v\n", + "dépôts effacés : %d, %s rendus\n": "repositories erased: %d, %s freed\n", + "aucun dépôt en miroir": "no mirrored repository", + "%10s %d dépôts\n": "%10s %d repositories\n", + "effacement : %v\n": "erasure: %v\n", + "miroir effacé : %s\n": "mirror erased: %s\n", + "miroir git éteint : passer --git-mirror-dir": "git mirror disabled: pass --git-mirror-dir", + "liste illisible : %v\n": "unreadable list: %v\n", + "%d dépôts à tenir en miroir sous %s\n": "%d repositories to mirror under %s\n", + "%d en miroir, %d en échec, %s occupés\n": "%d mirrored, %d failed, %s used\n", + "état illisible : %v\n": "unreadable state: %v\n", + "autorité : %v\n": "authority: %v\n", + "autorité : %s\n": "authority: %s\n", + "empreinte : %s\n": "fingerprint: %s\n", + "miroir git demandé mais « git-http-backend » est introuvable : git sera relayé vers l'amont (%s)": "git mirror requested but « git-http-backend » is missing: git will be relayed upstream (%s)", + "le cache s'arrête : %v": "the cache stops: %v", + "%d écriture(s) interrompue(s) retirée(s)": "%d interrupted write(s) removed", + "écoute HTTP : %w": "HTTP listener: %w", + "écoute TLS : %w": "TLS listener: %w", + "cache : %s": "cache: %s", + "autorité : %s (%s)": "authority: %s (%s)", + "écoutes : http %d, tls %d": "listeners: http %d, tls %d", + "répertoire : %s\n": "directory : %s\n", + "objets : %d\n": "objects : %d\n", + "occupation : %s\n": "occupied : %s\n", + "plus ancien : aucun objet\n": "oldest : no object\n", + "plus ancien : %s\n": "oldest : %s\n", + "autorité : %s\n": "authority : %s\n", + "empreinte : %s\n": "fingerprint : %s\n", + "empreinte : autorité absente\n": "fingerprint : authority missing\n", + "dépôts git : %d en miroir, %s\n": "git repos : %d mirrored, %s\n", + "exceptions : aucune\n": "exceptions : none\n", + "exceptions : %d VM soustraite(s) au détournement\n": "exceptions : %d VM(s) excluded from redirection\n", + "\nAucune éviction n'est écrite : ce cache ne diminue jamais\n": "\nNo eviction is written: this cache never shrinks\n", + "de lui-même, et il vit sur le disque de l'orchestrateur.\n": "by itself, and it lives on the orchestrator's disk.\n", + "À blanc — rien n'est écrit, rien n'est posé.\n\n": "Dry run — nothing is written, nothing is set.\n\n", + "Répertoire du cache, créé au démarrage :\n %s\n\n": "Cache directory, created at startup:\n %s\n\n", + "Autorité, créée si elle manque :\n %s (clé en 0600)\n\n": "Authority, created if missing:\n %s (key in 0600)\n\n", + "Règles nft à poser sur l'hôte :\n": "nft rules to set on the host:\n", + "\nÀ défaut de nft :\n": "\nWithout nft:\n", + "\nRetrait :\n %s\n": "\nRemoval:\n %s\n", + "\nDans chaque VM qui utilise le cache :\n": "\nIn each VM that uses the cache:\n", + " %-7s %s/erplibre-cache.crt puis %s\n": " %-7s %s/erplibre-cache.crt then %s\n", + "clé de l'autorité illisible : %w": "unreadable authority key: %w", + "autorité illisible : PEM invalide": "unreadable authority: invalid PEM", + "tunnel opaque retenu pour %s (%d échec(s)) : %v": "opaque tunnel kept for %s (%d failure(s)): %v", + "poignée de main interrompue pour %s : %v": "handshake interrupted for %s: %v", + "tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)", + "tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener", + "tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead", + "tunnel vers %s : %v": "tunnel to %s: %v", + "ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)", + "longueur d'enregistrement invraisemblable : %d": "implausible record length: %d", + "la connexion n'est pas une socket TCP": "the connection is not a TCP socket", + "aucune destination d'origine : la connexion n'est pas redirigée": "no original destination: the connection is not redirected", + "interception transparente : Linux seulement": "transparent interception: Linux only", + "requête sans hôte : ni ligne absolue ni en-tête Host": "request without host: neither absolute line nor Host header", + "cache : écriture impossible pour %s : %v": "cache: cannot write %s: %v", + "cache : %s non gardé : %v": "cache: %s not kept: %v", + "erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n demandé : %s\n classe : %s\n cause : %v\n%sCe fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\ndéployer une VM identique à celle qui a rempli le cache.\n": "erplibre_go_qemu_cache: upstream unreachable and nothing in store.\n requested: %s\n class : %s\n cause : %v\n%sThis file never crossed this cache. Restore the network, or\ndeploy a VM identical to the one that filled the cache.\n", + "hors ligne, absent du cache : %s": "offline, missing from the cache: %s", + "corps tronqué : %d octets sur %d": "truncated body: %d bytes of %d", + "%d o": "%d B", + "%.1f Pio": "%.1f PiB", + "Kio": "KiB", + "Mio": "MiB", + "Gio": "GiB", + "Tio": "TiB", + "Objets du cache :": "Cache objects:", + "Dépôts en miroir :": "Mirrored repositories:", + "objets": "objects", + "dépôts": "repos", + "jour": "day", + "semaine": "week", + "mois": "month", + "L'établissement vers cet amont vient d'échouer : aucune connexion\nn'a été tentée pour cette requête, l'amont sera retenté sous peu.\n": "Connecting to this upstream just failed: no connection\nwas attempted for this request, upstream will be retried shortly.\n", +} diff --git a/script/qemu_cache/gitmirror.go b/script/qemu_cache/gitmirror.go index d03dc9b..096dc65 100644 --- a/script/qemu_cache/gitmirror.go +++ b/script/qemu_cache/gitmirror.go @@ -165,17 +165,17 @@ func DepotDeURL(u *url.URL) (string, string, bool) { func (g *GitMirror) CheminMiroir(depot string) (string, error) { u, err := url.Parse(depot) if err != nil || u.Host == "" { - return "", fmt.Errorf("dépôt illisible %q", depot) + return "", fmt.Errorf(T("dépôt illisible %q"), depot) } chemin := strings.Trim(u.Path, "/") chemin = strings.TrimSuffix(chemin, ".git") if chemin == "" { - return "", fmt.Errorf("dépôt sans chemin %q", depot) + return "", fmt.Errorf(T("dépôt sans chemin %q"), depot) } // Un « .. » dans le chemin ferait écrire hors de la racine. for _, seg := range strings.Split(chemin, "/") { if seg == "." || seg == ".." || seg == "" { - return "", fmt.Errorf("chemin de dépôt refusé %q", depot) + return "", fmt.Errorf(T("chemin de dépôt refusé %q"), depot) } } return filepath.Join(g.Dir, u.Host, chemin+".git"), nil @@ -218,7 +218,7 @@ func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) { if !existe { if !g.placeSuffisante() { log.Printf( - "miroir refusé pour %s : moins de %s libres sur le disque", + T("miroir refusé pour %s : moins de %s libres sur le disque"), depot, HumanBytes(g.PlancherLibre)) return "", false } @@ -342,8 +342,8 @@ func (g *GitMirror) sonderAmont( g.figes = map[string]bool{} } g.figes[adresse] = true - log.Printf("miroir git : sonde de %s en échec (%v) ; ses miroirs"+ - " sont servis sans rafraîchissement jusqu'à ce qu'il réponde", + log.Printf(T("miroir git : sonde de %s en échec (%v) ; ses miroirs"+ + " sont servis sans rafraîchissement jusqu'à ce qu'il réponde"), adresse, err) } return true @@ -420,7 +420,7 @@ func (g *GitMirror) gitBorne( ) sortie, err := cmd.CombinedOutput() if err != nil { - return fmt.Errorf("git %s : %v : %s", + return fmt.Errorf(T("git %s : %v : %s"), strings.Join(args, " "), err, court(string(sortie))) } return nil @@ -684,7 +684,7 @@ func (g *GitMirror) Depots() []Depot { // mal formé ne doit pas pouvoir effacer autre chose. func (g *GitMirror) Retirer(chemin string) error { if g == nil || g.Dir == "" { - return fmt.Errorf("miroir éteint") + return fmt.Errorf("%s", T("miroir éteint")) } abs, err := filepath.Abs(chemin) if err != nil { @@ -696,7 +696,7 @@ func (g *GitMirror) Retirer(chemin string) error { } if !strings.HasPrefix(abs, racine+string(os.PathSeparator)) || !strings.HasSuffix(abs, ".git") { - return fmt.Errorf("hors des miroirs : %s", chemin) + return fmt.Errorf(T("hors des miroirs : %s"), chemin) } v := g.verrou(abs) v.Lock() diff --git a/script/qemu_cache/joignable.go b/script/qemu_cache/joignable.go index e31cf57..cc9e98d 100644 --- a/script/qemu_cache/joignable.go +++ b/script/qemu_cache/joignable.go @@ -40,7 +40,7 @@ const SentinelleAmonts = ".amonts-oublies" // errAmontConnuMuet dit qu'aucune connexion n'a été tentée : l'établissement // vers cet amont a échoué il y a moins d'une fenêtre. var errAmontConnuMuet = errors.New( - "amont connu muet : l'établissement vient d'échouer, il n'est pas retenté") + T("amont connu muet : l'établissement vient d'échouer, il n'est pas retenté")) // Joignabilite retient, par « hôte:port », l'instant du dernier // établissement de connexion manqué. @@ -216,6 +216,6 @@ func decrireMuet(cause error) string { if !errors.Is(cause, errAmontConnuMuet) { return "" } - return "L'établissement vers cet amont vient d'échouer : aucune connexion\n" + - "n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n" + return T("L'établissement vers cet amont vient d'échouer : aucune connexion\n" + + "n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n") } diff --git a/script/qemu_cache/langue.go b/script/qemu_cache/langue.go new file mode 100644 index 0000000..4a74334 --- /dev/null +++ b/script/qemu_cache/langue.go @@ -0,0 +1,95 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "os" + "strings" + "sync" +) + +// Langues servies. Le français est la langue du code source : chaque message y +// est écrit tel quel et sert de clé au catalogue anglais (catalogue_en.go). +const ( + LangueFr = "fr" + LangueEn = "en" +) + +var ( + langueUne sync.Once + langue string +) + +// T rend un message humain dans la langue du binaire. +// +// La clé EST le texte français : le message reste lisible à l'endroit où il +// est écrit, et un texte absent du catalogue sort en français plutôt que vide. +// Seuls les textes destinés à une personne passent par ici — journaux, +// sorties affichées, aide des options, erreurs montrées, corps d'une réponse +// d'erreur. Une règle nft, une ligne de protocole, un code de verdict ou une +// clé JSON sont lus par des programmes et ne se traduisent jamais. +func T(fr string) string { + if Langue() == LangueEn { + if en, ok := anglais[fr]; ok { + return en + } + } + return fr +} + +// Langue rend la langue retenue, résolue une seule fois à la première demande. +// +// Paresseuse, et non posée par main après l'analyse des options : des erreurs +// sont construites à l'initialisation du paquet, et l'aide des options est +// écrite avant flag.Parse. L'option --lang passe avant EL_LANG, parce que sudo +// retire l'environnement : un binaire lancé par « sudo » ne verrait jamais la +// langue de qui l'appelle. +func Langue() string { + langueUne.Do(func() { + langue = langueDemandee(os.Args[1:], os.Getenv) + }) + return langue +} + +// definirLangue impose une langue, en court-circuitant la résolution. +func definirLangue(l string) { + langueUne.Do(func() {}) + langue = normaliserLangue(l) +} + +// langueDemandee lit « --lang X », « --lang=X », « -lang X » ou « -lang=X » +// dans les arguments, puis EL_LANG. Rend « fr » par défaut, et pour toute +// valeur qui n'est pas de l'anglais. La lecture s'arrête à « -- », au-delà +// duquel un argument n'est plus une option. +func langueDemandee(args []string, env func(string) string) string { + for i, a := range args { + if a == "--" { + break + } + if !strings.HasPrefix(a, "-") { + continue + } + nom, valeur, egal := strings.Cut(strings.TrimLeft(a, "-"), "=") + if nom != "lang" { + continue + } + if !egal { + if i+1 >= len(args) { + break + } + valeur = args[i+1] + } + return normaliserLangue(valeur) + } + return normaliserLangue(env("EL_LANG")) +} + +// normaliserLangue ramène une valeur libre à une langue servie : « en », +// « EN », « en_CA.UTF-8 » donnent l'anglais, tout le reste le français. +func normaliserLangue(s string) string { + if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), LangueEn) { + return LangueEn + } + return LangueFr +} diff --git a/script/qemu_cache/langue_test.go b/script/qemu_cache/langue_test.go new file mode 100644 index 0000000..a59359b --- /dev/null +++ b/script/qemu_cache/langue_test.go @@ -0,0 +1,238 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "os/exec" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "testing" +) + +// Les tests lisent les messages en français, langue du code source : une +// langue héritée de l'environnement de qui les lance les ferait échouer sur +// une chaîne traduite. +func TestMain(m *testing.M) { + definirLangue(LangueFr) + os.Exit(m.Run()) +} + +// clesT rend le texte de chaque appel T("…") des fichiers du paquet, tests +// exclus, avec sa position. Un argument qui n'est pas un littéral est une +// erreur : le catalogue ne pourrait pas le traduire. +func clesT(t *testing.T) map[string]string { + t.Helper() + fichiers, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + fs := token.NewFileSet() + cles := map[string]string{} + for _, f := range fichiers { + if strings.HasSuffix(f, "_test.go") { + continue + } + af, err := parser.ParseFile(fs, f, nil, 0) + if err != nil { + t.Fatal(err) + } + ast.Inspect(af, func(n ast.Node) bool { + c, ok := n.(*ast.CallExpr) + if !ok { + return true + } + id, ok := c.Fun.(*ast.Ident) + if !ok || id.Name != "T" || len(c.Args) != 1 { + return true + } + texte, ok := litteralT(c.Args[0]) + pos := fs.Position(c.Pos()).String() + if !ok { + // Une conversion de valeur connue — T(string(gran)) — se + // vérifie par son propre test ; tout autre argument non + // littéral est refusé. + if conv, estConv := c.Args[0].(*ast.CallExpr); estConv { + if fn, ok := conv.Fun.(*ast.Ident); ok && fn.Name == "string" { + return true + } + } + t.Errorf("%s : T() sur un texte non littéral", pos) + return true + } + cles[texte] = pos + return true + }) + } + return cles +} + +func litteralT(e ast.Expr) (string, bool) { + switch v := e.(type) { + case *ast.BasicLit: + if v.Kind != token.STRING { + return "", false + } + s, err := strconv.Unquote(v.Value) + return s, err == nil + case *ast.BinaryExpr: + if v.Op != token.ADD { + return "", false + } + a, ok1 := litteralT(v.X) + b, ok2 := litteralT(v.Y) + return a + b, ok1 && ok2 + case *ast.ParenExpr: + return litteralT(v.X) + } + return "", false +} + +// Un message enveloppé sans traduction sortirait en français dans un binaire +// réglé en anglais, sans que rien ne le signale. +func TestChaqueMessageATraductionAnglaise(t *testing.T) { + cles := clesT(t) + if len(cles) < 100 { + t.Fatalf("%d appels T() trouvés : le contrôle ne lit pas le code", len(cles)) + } + var manquants []string + for texte, pos := range cles { + if _, ok := anglais[texte]; !ok { + manquants = append(manquants, pos+" "+strconv.Quote(texte)) + } + } + sort.Strings(manquants) + for _, m := range manquants { + t.Errorf("sans traduction : %s", m) + } +} + +// Une entrée qu'aucun appel n'emploie est un message retiré, ou une clé qui +// ne correspond plus au texte réécrit : sa traduction ne sortirait jamais. +func TestAucuneTraductionOrpheline(t *testing.T) { + cles := clesT(t) + // Clés employées par conversion : les granularités affichées. + for _, g := range []Granularite{ParJour, ParSemaine, ParMois} { + cles[string(g)] = "granularité" + } + var orphelines []string + for texte := range anglais { + if _, ok := cles[texte]; !ok { + orphelines = append(orphelines, strconv.Quote(texte)) + } + } + sort.Strings(orphelines) + for _, o := range orphelines { + t.Errorf("traduction sans appel : %s", o) + } +} + +var verbeFormat = regexp.MustCompile(`%[-+# 0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[a-zA-Z%]`) + +// Une traduction qui perd, ajoute ou déplace un verbe de format décale les +// valeurs : « %d VM » rendu « %s VM » écrit « %!s(int=3) ». +func TestLesTraductionsGardentLeursVerbes(t *testing.T) { + for fr, en := range anglais { + a, b := verbeFormat.FindAllString(fr, -1), verbeFormat.FindAllString(en, -1) + if strings.Join(a, " ") != strings.Join(b, " ") { + t.Errorf("verbes %v ≠ %v pour %q", a, b, fr) + } + } +} + +func TestLaLangueSeResoutDansLOrdre(t *testing.T) { + env := func(v string) func(string) string { + return func(k string) string { + if k == "EL_LANG" { + return v + } + return "" + } + } + cas := []struct { + nom string + args []string + elLang string + veut string + }{ + {"défaut", nil, "", LangueFr}, + {"EL_LANG anglais", nil, "en", LangueEn}, + {"EL_LANG régional", nil, "en_CA.UTF-8", LangueEn}, + {"langue inconnue", nil, "de", LangueFr}, + {"--lang espacé", []string{"--status", "--lang", "en"}, "", LangueEn}, + {"-lang=", []string{"-lang=en"}, "", LangueEn}, + {"--lang= l'emporte sur EL_LANG", []string{"--lang=fr"}, "en", LangueFr}, + {"--lang sans valeur", []string{"--lang"}, "en", LangueEn}, + {"après --, ce n'est plus une option", []string{"--", "--lang", "en"}, "", LangueFr}, + {"une valeur n'est pas une option", []string{"--age-by", "lang"}, "", LangueFr}, + } + for _, c := range cas { + if got := langueDemandee(c.args, env(c.elLang)); got != c.veut { + t.Errorf("%s : %q, attendu %q", c.nom, got, c.veut) + } + } +} + +func TestUnMessageSortEnAnglais(t *testing.T) { + defer definirLangue(LangueFr) + definirLangue(LangueEn) + if got := T("écoutes : http %d, tls %d"); got != "listeners: http %d, tls %d" { + t.Errorf("anglais : %q", got) + } + if got := T("texte que le catalogue ignore"); got != "texte que le catalogue ignore" { + t.Errorf("un texte inconnu doit sortir tel quel, pas %q", got) + } + definirLangue(LangueFr) + if got := T("écoutes : http %d, tls %d"); got != "écoutes : http %d, tls %d" { + t.Errorf("français : %q", got) + } +} + +// Le Python du menu lit ces libellés de --status et cette phrase du journal +// dans les DEUX langues : une traduction qui s'en écarte le rend aveugle. +func TestLesLibellesLusParLeMenuRestentReconnaissables(t *testing.T) { + attendus := map[string]string{ + "autorité : %s\n": "authority", + "empreinte : %s\n": "fingerprint", + "exceptions : aucune\n": "exceptions", + "dépôts git : %d en miroir, %s\n": "git repos", + "tunnel opaque retenu pour %s (%d échec(s)) : %v": "opaque tunnel kept for %s (", + } + for fr, prefixe := range attendus { + if !strings.HasPrefix(anglais[fr], prefixe) { + t.Errorf("%q traduit en %q, qui ne commence plus par %q", + fr, anglais[fr], prefixe) + } + } +} + +// L'option doit être DÉCLARÉE : flag.Parse refuse une option inconnue, et +// chaque appel du menu qui la passe échouerait alors avant de rien dire. +// Le binaire est construit et lancé pour de vrai, sur un cache vide. +func TestLeBinaireAccepteLangEtParleAnglais(t *testing.T) { + if _, err := exec.LookPath("go"); err != nil { + t.Skip("go absent du PATH") + } + dir := t.TempDir() + bin := filepath.Join(dir, "cache") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("construction : %v\n%s", err, out) + } + cmd := exec.Command(bin, "--lang", "en", "--status", "--cache-dir", filepath.Join(dir, "vide"), + "--ca-dir", filepath.Join(dir, "ca")) + cmd.Env = append(os.Environ(), "EL_LANG=fr") + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("--lang en --status : %v\n%s", err, out) + } + if !strings.Contains(string(out), "directory :") { + t.Errorf("sortie non anglaise malgré --lang en :\n%s", out) + } +} diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index f4e3d49..af711c6 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -30,70 +30,76 @@ const version = "0.2.4" func main() { var ( cacheDir = flag.String("cache-dir", "/var/cache/erplibre_go_qemu_cache", - "répertoire des objets gardés") + T("répertoire des objets gardés")) caDir = flag.String("ca-dir", "/var/lib/erplibre_go_qemu_cache", - "répertoire de l'autorité de certification") - httpPort = flag.Int("http-port", 8898, "écoute pour le 80 détourné") - tlsPort = flag.Int("tls-port", 8899, "écoute pour le 443 détourné") - bridge = flag.String("bridge", "virbr0", "pont libvirt des VM") - subnet = flag.String("subnet", "192.168.122.0/24", "sous-réseau des VM") - logPath = flag.String("access-log", "", "journal d'accès JSON par ligne") + T("répertoire de l'autorité de certification")) + httpPort = flag.Int("http-port", 8898, T("écoute pour le 80 détourné")) + tlsPort = flag.Int("tls-port", 8899, T("écoute pour le 443 détourné")) + bridge = flag.String("bridge", "virbr0", T("pont libvirt des VM")) + subnet = flag.String("subnet", "192.168.122.0/24", T("sous-réseau des VM")) + logPath = flag.String("access-log", "", T("journal d'accès JSON par ligne")) exclude = flag.String("exclude", "", - "hôtes à ne jamais déchiffrer, séparés par des virgules") - verbose = flag.Bool("verbose", false, "dire chaque requête") + T("hôtes à ne jamais déchiffrer, séparés par des virgules")) + verbose = flag.Bool("verbose", false, T("dire chaque requête")) status = flag.Bool("status", false, - "dire ce que le cache occupe, puis sortir") + T("dire ce que le cache occupe, puis sortir")) dryRun = flag.Bool("dry-run", false, - "montrer les gestes privilégiés sans en faire un") + T("montrer les gestes privilégiés sans en faire un")) initCA = flag.Bool("init-ca", false, - "créer l'autorité si elle manque, puis sortir") + T("créer l'autorité si elle manque, puis sortir")) printNft = flag.Bool("print-nft", false, - "écrire les règles nft seules, à passer à « nft -f - »") + T("écrire les règles nft seules, à passer à « nft -f - »")) printIptables = flag.Bool("print-iptables", false, - "écrire les commandes iptables seules, une par ligne") + T("écrire les commandes iptables seules, une par ligne")) bypassFile = flag.String("bypass-file", "/etc/erplibre_go_qemu_cache/bypass", - "liste des VM soustraites au détournement, une MAC par ligne") + T("liste des VM soustraites au détournement, une MAC par ligne")) bypassAdd = flag.String("bypass-add", "", - "soustraire cette adresse MAC au détournement") + T("soustraire cette adresse MAC au détournement")) bypassName = flag.String("bypass-name", "", - "nom de la VM, écrit à côté de la MAC ajoutée") + T("nom de la VM, écrit à côté de la MAC ajoutée")) bypassDel = flag.String("bypass-del", "", - "rendre cette adresse MAC au détournement") + T("rendre cette adresse MAC au détournement")) gitMirrorDir = flag.String("git-mirror-dir", "", - "racine des dépôts git tenus en miroir ; vide, git est"+ - " simplement relayé vers l'amont") + T("racine des dépôts git tenus en miroir ; vide, git est"+ + " simplement relayé vers l'amont")) gitMirrorFresh = flag.Duration("git-mirror-fresh", 60*time.Second, - "délai en deçà duquel un dépôt n'est pas re-interrogé") + T("délai en deçà duquel un dépôt n'est pas re-interrogé")) gitPrefetch = flag.String("git-mirror-prefetch", "", - "fichier de dépôts, un par ligne, à tenir en miroir d'avance") + T("fichier de dépôts, un par ligne, à tenir en miroir d'avance")) gitPrefetchJobs = flag.Int("git-mirror-jobs", 4, - "dépôts clonés en parallèle par le pré-remplissage") + T("dépôts clonés en parallèle par le pré-remplissage")) ageReport = flag.Bool("age-report", false, - "dire ce que le cache occupe, groupé par âge du dernier usage") + T("dire ce que le cache occupe, groupé par âge du dernier usage")) agePar = flag.String("age-par", "semaine", - "découpage du relevé par âge : jour, semaine ou mois") + T("découpage du relevé par âge : jour, semaine ou mois")) purgeTout = flag.Bool("purge", false, - "effacer TOUT le cache : objets et dépôts en miroir") + T("effacer TOUT le cache : objets et dépôts en miroir")) purgeAvant = flag.String("purge-older-than", "", - "n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)") + T("n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)")) gitList = flag.Bool("git-mirror-list", false, - "dire les dépôts tenus en miroir, du plus lourd au plus léger") + T("dire les dépôts tenus en miroir, du plus lourd au plus léger")) gitRemove = flag.String("git-mirror-remove", "", - "effacer le miroir de ce dépôt ; il se refera au prochain besoin") + T("effacer le miroir de ce dépôt ; il se refera au prochain besoin")) bypassList = flag.Bool("bypass-list", false, - "dire les exceptions en place, une « MAC nom » par ligne") - showVersion = flag.Bool("version", false, "dire la version, puis sortir") + T("dire les exceptions en place, une « MAC nom » par ligne")) + showVersion = flag.Bool("version", false, T("dire la version, puis sortir")) + lang = flag.String("lang", "", T("langue des messages : fr ou en ; à défaut, EL_LANG")) detient = flag.Bool("detient", false, - "lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+ + T("lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+ " pour chacune, ce que le magasin tient : une ligne séparée"+ " par des tabulations « verdict statut stored_at classe"+ " méthode url », verdict garde (corps 200), statut (statut"+ " seul, sans corps), absent ou non-cachable. Lecture seule :"+ " --cache-dir suffit, sans privilège, et l'âge des objets"+ - " n'est pas touché") + " n'est pas touché")) ) flag.Parse() + // Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi + // pour que la valeur retenue soit celle que flag a comprise. + if *lang != "" { + definirLangue(*lang) + } if *showVersion { fmt.Printf("erplibre_go_qemu_cache %s\n", version) @@ -105,7 +111,7 @@ func main() { if *detient { store := &Store{Dir: *cacheDir} if err := EcrireDetentions(store, os.Stdin, os.Stdout); err != nil { - fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err) + fmt.Fprintf(os.Stderr, T("entrée illisible : %v\n"), err) } return } @@ -116,7 +122,7 @@ func main() { // donc pas au seul noyau. exceptions, err := bypass.Load() if err != nil { - fmt.Fprintf(os.Stderr, "exceptions illisibles : %v\n", err) + fmt.Fprintf(os.Stderr, T("exceptions illisibles : %v\n"), err) os.Exit(1) } rules := RuleSet{ @@ -149,21 +155,21 @@ func main() { if *bypassAdd != "" { mac, err := bypass.Add(*bypassAdd, *bypassName) if err != nil { - fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err) + fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err) os.Exit(1) } - fmt.Fprintf(os.Stderr, "exception posée : %s %s\n", mac, *bypassName) + fmt.Fprintf(os.Stderr, T("exception posée : %s %s\n"), mac, *bypassName) fmt.Println(BypassAddElement(mac)) return } if *bypassDel != "" { mac, avait, err := bypass.Del(*bypassDel) if err != nil { - fmt.Fprintf(os.Stderr, "exception refusée : %v\n", err) + fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err) os.Exit(1) } if !avait { - fmt.Fprintf(os.Stderr, "aucune exception pour %s\n", mac) + fmt.Fprintf(os.Stderr, T("aucune exception pour %s\n"), mac) } // Le geste à chaud est rendu même si le fichier ne l'avait pas : // l'ensemble du noyau peut porter ce que le fichier a perdu, et le @@ -207,10 +213,10 @@ func main() { os.Exit(1) } avant = time.Now().Add(-d) - fmt.Printf("efface ce qui n'a pas servi depuis %s (avant %s)\n", + fmt.Printf(T("efface ce qui n'a pas servi depuis %s (avant %s)\n"), *purgeAvant, avant.Format("2006-01-02 15:04")) } else { - fmt.Println("efface TOUT le cache") + fmt.Println(T("efface TOUT le cache")) } if *dryRun { printPurgeABlanc(store, miroir, avant) @@ -218,20 +224,20 @@ func main() { } n, oct, err := store.Purger(avant) if err != nil { - fmt.Fprintf(os.Stderr, "purge des objets : %v\n", err) + fmt.Fprintf(os.Stderr, T("purge des objets : %v\n"), err) } - fmt.Printf("objets effacés : %d, %s rendus\n", n, HumanBytes(oct)) + fmt.Printf(T("objets effacés : %d, %s rendus\n"), n, HumanBytes(oct)) nd, octd, err := miroir.PurgerMiroirs(avant) if err != nil { - fmt.Fprintf(os.Stderr, "purge des miroirs : %v\n", err) + fmt.Fprintf(os.Stderr, T("purge des miroirs : %v\n"), err) } - fmt.Printf("dépôts effacés : %d, %s rendus\n", nd, HumanBytes(octd)) + fmt.Printf(T("dépôts effacés : %d, %s rendus\n"), nd, HumanBytes(octd)) return } if *gitList { depots := miroir.Depots() if len(depots) == 0 { - fmt.Println("aucun dépôt en miroir") + fmt.Println(T("aucun dépôt en miroir")) return } var total int64 @@ -240,7 +246,7 @@ func main() { fmt.Printf("%10s %s %s\n", HumanBytes(d.Octets), d.Maj.Format("2006-01-02"), d.Nom) } - fmt.Printf("%10s %d dépôts\n", HumanBytes(total), len(depots)) + fmt.Printf(T("%10s %d dépôts\n"), HumanBytes(total), len(depots)) return } if *gitRemove != "" { @@ -256,31 +262,31 @@ func main() { } } if err := miroir.Retirer(cible); err != nil { - fmt.Fprintf(os.Stderr, "effacement : %v\n", err) + fmt.Fprintf(os.Stderr, T("effacement : %v\n"), err) os.Exit(1) } - fmt.Printf("miroir effacé : %s\n", cible) + fmt.Printf(T("miroir effacé : %s\n"), cible) return } if *gitPrefetch != "" { if !miroir.Actif() { fmt.Fprintln(os.Stderr, - "miroir git éteint : passer --git-mirror-dir") + T("miroir git éteint : passer --git-mirror-dir")) os.Exit(1) } depots, err := DepotsDuFichier(*gitPrefetch) if err != nil { - fmt.Fprintf(os.Stderr, "liste illisible : %v\n", err) + fmt.Fprintf(os.Stderr, T("liste illisible : %v\n"), err) os.Exit(1) } - fmt.Printf("%d dépôts à tenir en miroir sous %s\n", + fmt.Printf(T("%d dépôts à tenir en miroir sous %s\n"), len(depots), miroir.Dir) reussis, echoues := miroir.Prefetch( context.Background(), depots, *gitPrefetchJobs, func(l string) { fmt.Println(l) }, ) _, octets := miroir.Occupation() - fmt.Printf("%d en miroir, %d en échec, %s occupés\n", + fmt.Printf(T("%d en miroir, %d en échec, %s occupés\n"), reussis, echoues, HumanBytes(octets)) // Un dépôt mort ne fait pas échouer l'opération : sur une liste de // trois cents, il y en a toujours un — privé, déplacé, retiré — et @@ -294,7 +300,7 @@ func main() { if *status { if err := printStatus(store, *caDir, rules, miroir); err != nil { - fmt.Fprintf(os.Stderr, "état illisible : %v\n", err) + fmt.Fprintf(os.Stderr, T("état illisible : %v\n"), err) os.Exit(1) } return @@ -308,11 +314,11 @@ func main() { if *initCA { ca, err := LoadOrCreateCA(*caDir) if err != nil { - fmt.Fprintf(os.Stderr, "autorité : %v\n", err) + fmt.Fprintf(os.Stderr, T("autorité : %v\n"), err) os.Exit(1) } - fmt.Printf("autorité : %s\n", CertPath(*caDir)) - fmt.Printf("empreinte : %s\n", ca.Fingerprint()) + fmt.Printf(T("autorité : %s\n"), CertPath(*caDir)) + fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint()) return } @@ -321,13 +327,13 @@ func main() { // programme de git, le service marche mais git repart à l'amont à // chaque VM, ce qui est exactement ce que le miroir devait éviter. log.Printf( - "miroir git demandé mais « git-http-backend » est introuvable :"+ - " git sera relayé vers l'amont (%s)", *gitMirrorDir) + T("miroir git demandé mais « git-http-backend » est introuvable :"+ + " git sera relayé vers l'amont (%s)"), *gitMirrorDir) } if err := serve( store, *caDir, rules, *logPath, *exclude, *verbose, miroir, muets, ); err != nil { - log.Fatalf("le cache s'arrête : %v", err) + log.Fatalf(T("le cache s'arrête : %v"), err) } } @@ -342,7 +348,7 @@ func serve( // Un objet à moitié écrit ne vaut rien et occupe : le démarrage est le // seul moment où un « .part » n'appartient à aucune écriture vivante. if n := store.SweepPartials(); n > 0 { - log.Printf("%d écriture(s) interrompue(s) retirée(s)", n) + log.Printf(T("%d écriture(s) interrompue(s) retirée(s)"), n) } ca, err := LoadOrCreateCA(caDir) @@ -367,16 +373,16 @@ func serve( httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.HTTPPort)) if err != nil { - return fmt.Errorf("écoute HTTP : %w", err) + return fmt.Errorf(T("écoute HTTP : %w"), err) } tlsLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.TLSPort)) if err != nil { - return fmt.Errorf("écoute TLS : %w", err) + return fmt.Errorf(T("écoute TLS : %w"), err) } - log.Printf("cache : %s", store.Dir) - log.Printf("autorité : %s (%s)", CertPath(caDir), ca.Fingerprint()) - log.Printf("écoutes : http %d, tls %d", rules.HTTPPort, rules.TLSPort) + log.Printf(T("cache : %s"), store.Dir) + log.Printf(T("autorité : %s (%s)"), CertPath(caDir), ca.Fingerprint()) + log.Printf(T("écoutes : http %d, tls %d"), rules.HTTPPort, rules.TLSPort) errc := make(chan error, 2) go func() { @@ -397,56 +403,56 @@ func printStatus( if err != nil { return err } - fmt.Printf("répertoire : %s\n", store.Dir) - fmt.Printf("objets : %d\n", st.Objects) - fmt.Printf("occupation : %s\n", HumanBytes(st.Bytes)) + fmt.Printf(T("répertoire : %s\n"), store.Dir) + fmt.Printf(T("objets : %d\n"), st.Objects) + fmt.Printf(T("occupation : %s\n"), HumanBytes(st.Bytes)) if st.Oldest.IsZero() { - fmt.Printf("plus ancien : aucun objet\n") + fmt.Printf("%s", T("plus ancien : aucun objet\n")) } else { - fmt.Printf("plus ancien : %s\n", st.Oldest.Format(time.RFC3339)) + fmt.Printf(T("plus ancien : %s\n"), st.Oldest.Format(time.RFC3339)) } - fmt.Printf("autorité : %s\n", CertPath(caDir)) + fmt.Printf(T("autorité : %s\n"), CertPath(caDir)) if ca, err := LoadOrCreateCA(caDir); err == nil { - fmt.Printf("empreinte : %s\n", ca.Fingerprint()) + fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint()) } else { - fmt.Printf("empreinte : autorité absente\n") + fmt.Printf("%s", T("empreinte : autorité absente\n")) } if depots, octets := miroirStatut.Occupation(); depots > 0 { - fmt.Printf("dépôts git : %d en miroir, %s\n", + fmt.Printf(T("dépôts git : %d en miroir, %s\n"), depots, HumanBytes(octets)) } if len(rules.Bypass) == 0 { - fmt.Printf("exceptions : aucune\n") + fmt.Printf("%s", T("exceptions : aucune\n")) } else { - fmt.Printf("exceptions : %d VM soustraite(s) au détournement\n", + fmt.Printf(T("exceptions : %d VM soustraite(s) au détournement\n"), len(rules.Bypass)) for _, m := range rules.Bypass { fmt.Printf(" %s\n", m) } } - fmt.Printf("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n") - fmt.Printf("de lui-même, et il vit sur le disque de l'orchestrateur.\n") + fmt.Printf("%s", T("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n")) + fmt.Printf("%s", T("de lui-même, et il vit sur le disque de l'orchestrateur.\n")) return nil } func printDryRun(store *Store, caDir string, rules RuleSet) { - fmt.Printf("À blanc — rien n'est écrit, rien n'est posé.\n\n") - fmt.Printf("Répertoire du cache, créé au démarrage :\n %s\n\n", store.Dir) - fmt.Printf("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n", + fmt.Printf("%s", T("À blanc — rien n'est écrit, rien n'est posé.\n\n")) + fmt.Printf(T("Répertoire du cache, créé au démarrage :\n %s\n\n"), store.Dir) + fmt.Printf(T("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n"), CertPath(caDir)) - fmt.Printf("Règles nft à poser sur l'hôte :\n") + fmt.Printf("%s", T("Règles nft à poser sur l'hôte :\n")) for _, l := range rules.NftLines() { fmt.Printf(" %s\n", l) } - fmt.Printf("\nÀ défaut de nft :\n") + fmt.Printf("%s", T("\nÀ défaut de nft :\n")) for _, l := range rules.IptablesLines() { fmt.Printf(" %s\n", l) } - fmt.Printf("\nRetrait :\n %s\n", rules.NftDeleteLine()) - fmt.Printf("\nDans chaque VM qui utilise le cache :\n") + fmt.Printf(T("\nRetrait :\n %s\n"), rules.NftDeleteLine()) + fmt.Printf("%s", T("\nDans chaque VM qui utilise le cache :\n")) for _, f := range []string{"pacman", "apt", "dnf", "zypper"} { dir, cmd, bundle, _ := GuestTrustCommand(f) - fmt.Printf(" %-7s %s/erplibre-cache.crt puis %s\n", f, dir, cmd) + fmt.Printf(T(" %-7s %s/erplibre-cache.crt puis %s\n"), f, dir, cmd) for _, l := range GuestEnvLines(bundle) { fmt.Printf(" %s\n", l) } diff --git a/script/qemu_cache/mitm.go b/script/qemu_cache/mitm.go index 3bba239..92be2de 100644 --- a/script/qemu_cache/mitm.go +++ b/script/qemu_cache/mitm.go @@ -58,12 +58,12 @@ func LoadOrCreateCA(dir string) (*CA, error) { if certPEM, err := os.ReadFile(certPath); err == nil { keyPEM, err := os.ReadFile(keyPath) if err != nil { - return nil, fmt.Errorf("clé de l'autorité illisible : %w", err) + return nil, fmt.Errorf(T("clé de l'autorité illisible : %w"), err) } cb, _ := pem.Decode(certPEM) kb, _ := pem.Decode(keyPEM) if cb == nil || kb == nil { - return nil, errors.New("autorité illisible : PEM invalide") + return nil, errors.New(T("autorité illisible : PEM invalide")) } cert, err := x509.ParseCertificate(cb.Bytes) if err != nil { @@ -348,7 +348,7 @@ func (r *Refusals) Echec(host string, raison error) bool { return false } if _, deja := r.apprises[host]; !deja { - log.Printf("tunnel opaque retenu pour %s (%d échec(s)) : %v", + log.Printf(T("tunnel opaque retenu pour %s (%d échec(s)) : %v"), host, r.echecs[host], raison) } // La NATURE est retenue avec l'instant : elle décide si ce refus se @@ -476,7 +476,7 @@ func (t *TLSFront) handle(c net.Conn) { // Les traiter pareil condamnait un miroir de distribution au tunnel // sur une seule coupure, et tout son trafic repartait à l'amont. if !t.Refusals.Echec(host, err) { - log.Printf("poignée de main interrompue pour %s : %v", host, err) + log.Printf(T("poignée de main interrompue pour %s : %v"), host, err) } return } @@ -560,7 +560,7 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool { } dst, err := origine(c) if err != nil { - log.Printf("tunnel impossible pour %q : destination inconnue (%v)", host, err) + log.Printf(T("tunnel impossible pour %q : destination inconnue (%v)"), host, err) return false } // Une connexion NON détournée — ouverte directement sur l'écoute — a pour @@ -568,8 +568,8 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool { // où elle serait relayée de nouveau, sans fin : chaque tour ouvre une // connexion, jusqu'à épuiser les descripteurs et arrêter le service. if memeAdresse(dst, c.LocalAddr().String()) { - log.Printf("tunnel refusé pour %q : connexion non détournée, sa"+ - " destination %s est cette écoute même", host, dst) + log.Printf(T("tunnel refusé pour %q : connexion non détournée, sa"+ + " destination %s est cette écoute même"), host, dst) t.Proxy.record(accessLine{ Method: "CONNECT", URL: "tcp://" + dst, Class: "tunnel", Outcome: OutcomeError, Status: http.StatusLoopDetected, @@ -581,10 +581,10 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool { if err != nil { injoignable := estEchecDEtablissement(err) if injoignable && host != "" { - log.Printf("tunnel vers %s : %v ; %s est déchiffré à la place", + log.Printf(T("tunnel vers %s : %v ; %s est déchiffré à la place"), dst, err, host) } else { - log.Printf("tunnel vers %s : %v", dst, err) + log.Printf(T("tunnel vers %s : %v"), dst, err) } return injoignable } @@ -612,11 +612,11 @@ func readFirstRecord(c net.Conn) ([]byte, error) { return nil, err } if head[0] != 0x16 { // handshake - return nil, fmt.Errorf("ce n'est pas une poignée de main TLS (type %d)", head[0]) + return nil, fmt.Errorf(T("ce n'est pas une poignée de main TLS (type %d)"), head[0]) } n := int(head[3])<<8 | int(head[4]) if n <= 0 || n > 1<<16 { - return nil, fmt.Errorf("longueur d'enregistrement invraisemblable : %d", n) + return nil, fmt.Errorf(T("longueur d'enregistrement invraisemblable : %d"), n) } body := make([]byte, n) if _, err := io.ReadFull(c, body); err != nil { diff --git a/script/qemu_cache/origdst_linux.go b/script/qemu_cache/origdst_linux.go index b3ef54e..73df2f1 100644 --- a/script/qemu_cache/origdst_linux.go +++ b/script/qemu_cache/origdst_linux.go @@ -34,7 +34,7 @@ func originalDst(c net.Conn) (string, error) { } tcp, ok := c.(*net.TCPConn) if !ok { - return "", errors.New("la connexion n'est pas une socket TCP") + return "", errors.New(T("la connexion n'est pas une socket TCP")) } raw, err := tcp.SyscallConn() if err != nil { @@ -66,7 +66,7 @@ func originalDst(c net.Conn) (string, error) { return "", inner } if addr == "" || addr == "0.0.0.0:0" { - return "", errors.New("aucune destination d'origine : la connexion n'est pas redirigée") + return "", errors.New(T("aucune destination d'origine : la connexion n'est pas redirigée")) } return addr, nil } diff --git a/script/qemu_cache/origdst_other.go b/script/qemu_cache/origdst_other.go index 3c3af4e..8e0c54e 100644 --- a/script/qemu_cache/origdst_other.go +++ b/script/qemu_cache/origdst_other.go @@ -14,5 +14,5 @@ import ( // connexion redirigée. Ailleurs, le tunnel opaque est impossible et le dire // vaut mieux que de deviner une adresse. func originalDst(net.Conn) (string, error) { - return "", errors.New("interception transparente : Linux seulement") + return "", errors.New(T("interception transparente : Linux seulement")) } diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go index d1e3824..2a79e07 100644 --- a/script/qemu_cache/proxy.go +++ b/script/qemu_cache/proxy.go @@ -201,7 +201,7 @@ func absoluteURL(r *http.Request, scheme string) (*url.URL, error) { } host := r.Host if host == "" { - return nil, fmt.Errorf("requête sans hôte : ni ligne absolue ni en-tête Host") + return nil, fmt.Errorf("%s", T("requête sans hôte : ni ligne absolue ni en-tête Host")) } u := *r.URL u.Scheme = scheme @@ -405,7 +405,7 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { m.Header.Del("Set-Cookie") } if cw, err = p.Store.NewWriter(cle, m); err != nil { - log.Printf("cache : écriture impossible pour %s : %v", u, err) + log.Printf(T("cache : écriture impossible pour %s : %v"), u, err) cw = nil } } @@ -444,7 +444,7 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { attendu = 0 } if cerr := cw.Commit(attendu); cerr != nil { - log.Printf("cache : %s non gardé : %v", u, cerr) + log.Printf(T("cache : %s non gardé : %v"), u, cerr) outcome = OutcomeFetched } else if statutSeul { outcome = OutcomeStoredStatus @@ -582,13 +582,13 @@ func (p *Proxy) offlineMiss( cause error, ) { msg := enCommentaire(fmt.Sprintf( - "erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n"+ + T("erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n"+ " demandé : %s\n"+ " classe : %s\n"+ " cause : %v\n"+ "%s"+ "Ce fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\n"+ - "déployer une VM identique à celle qui a rempli le cache.\n", + "déployer une VM identique à celle qui a rempli le cache.\n"), u, class, cause, decrireMuet(cause))) w.Header().Set("Content-Type", "text/plain; charset=utf-8") w.Header().Set("X-ERPLibre-Cache", OutcomeOfflineMiss) @@ -600,7 +600,7 @@ func (p *Proxy) offlineMiss( Outcome: OutcomeOfflineMiss, Status: http.StatusGatewayTimeout, Client: client, }) - log.Printf("hors ligne, absent du cache : %s", u) + log.Printf(T("hors ligne, absent du cache : %s"), u) } // maxRedirections borne la chaîne : une boucle de redirections tournerait diff --git a/script/qemu_cache/store.go b/script/qemu_cache/store.go index d5222c0..d3507a2 100644 --- a/script/qemu_cache/store.go +++ b/script/qemu_cache/store.go @@ -272,7 +272,7 @@ func (w *Writer) Write(p []byte) (int, error) { func (w *Writer) Commit(expected int64) error { defer w.cleanup() if expected >= 0 && w.written != expected { - return fmt.Errorf("corps tronqué : %d octets sur %d", w.written, expected) + return fmt.Errorf(T("corps tronqué : %d octets sur %d"), w.written, expected) } if err := w.tmp.Sync(); err != nil { return err @@ -406,9 +406,9 @@ func containsPart(name string) bool { func HumanBytes(n int64) string { const unit = 1024 if n < unit { - return fmt.Sprintf("%d o", n) + return fmt.Sprintf(T("%d o"), n) } - units := []string{"Kio", "Mio", "Gio", "Tio"} + units := []string{T("Kio"), T("Mio"), T("Gio"), T("Tio")} v := float64(n) for _, u := range units { v /= unit @@ -416,7 +416,7 @@ func HumanBytes(n int64) string { return fmt.Sprintf("%.1f %s", v, u) } } - return fmt.Sprintf("%.1f Pio", v/unit) + return fmt.Sprintf(T("%.1f Pio"), v/unit) } // copyTee écrit dans le cache ET vers le client en une seule lecture de diff --git a/script/todo/qemu_cache_menu.py b/script/todo/qemu_cache_menu.py index daf4a23..2b368b5 100644 --- a/script/todo/qemu_cache_menu.py +++ b/script/todo/qemu_cache_menu.py @@ -34,11 +34,36 @@ from urllib.parse import urljoin, urlsplit import click from script.qemu import cache_offline -from script.todo.todo_i18n import t +from script.todo.todo_i18n import get_lang, t # Ce que l'installateur pose. Ces chemins sont comparés à ceux du script par # un test : le menu qui chercherait ailleurs annoncerait un cache absent. CACHE_BIN = "/usr/local/bin/erplibre_go_qemu_cache" + +# Ce que le menu LIT dans les sorties du binaire, dans ses deux langues. Le +# binaire traduit ses messages humains (catalogue_en.go) : une lecture qui ne +# connaîtrait que le français deviendrait aveugle sur un service réglé en +# anglais. test_qemu_cache_langue.py vérifie que le catalogue les porte. +REFUS_APPRIS = ("tunnel opaque retenu pour", "opaque tunnel kept for") +LIBELLES_TUS = ( + "autorité", + "empreinte", + "exceptions", + "authority", + "fingerprint", +) + + +def option_langue(lue=False): + """L'option --lang à donner au binaire. + + Une sortie AFFICHÉE suit la langue de todo.py. Une sortie LUE par ce + fichier est demandée en français, la langue dont il connaît les + libellés. L'option et non EL_LANG : sudo retire l'environnement. + """ + return f"--lang {'fr' if lue else get_lang()}" + + CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" CACHE_SERVICE = "erplibre-go-qemu-cache.service" CACHE_CONF = "/etc/erplibre_go_qemu_cache/env" @@ -437,14 +462,14 @@ class QemuCacheMenuMixin: # ne mesure que les objets, et les dépôts git — qui pèsent bien plus — # disparaissent du seul endroit où l'on surveille la place. releve = self._cache_lire( - f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}", + f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}" + f" {option_langue()}", delai=60, ) for ligne in [ l for l in releve.split("\n") - if l.strip() - and not l.startswith(("autorité", "empreinte", "exceptions")) + if l.strip() and not l.startswith(LIBELLES_TUS) ][:5]: if ligne.strip(): print(f" · {ligne.strip()}") @@ -1338,7 +1363,7 @@ class QemuCacheMenuMixin: surveille la place, et quelques dépôts font l'essentiel du total.""" cmd = ( f"{CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}" - f" --git-mirror-list" + f" --git-mirror-list {option_langue()}" ) print(f"{t('Will execute:')} {cmd}\n") self.execute.exec_command_live(cmd, source_erplibre=False) @@ -1352,7 +1377,7 @@ class QemuCacheMenuMixin: return cmd = ( f"sudo {CACHE_BIN} --git-mirror-dir {CACHE_MIROIR_GIT}" - f" --git-mirror-remove {shlex.quote(nom)}" + f" --git-mirror-remove {shlex.quote(nom)} {option_langue()}" ) print(f"\n{t('Will execute:')} {cmd}") print(f" {t('It will be mirrored again when a VM needs it.')}") @@ -1364,7 +1389,8 @@ class QemuCacheMenuMixin: def _cache_miroir_occupation(cls): """(nombre de dépôts, taille lisible) du miroir, lus du binaire.""" for ligne in cls._cache_lire( - f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}", + f"{CACHE_BIN} --status --git-mirror-dir {CACHE_MIROIR_GIT}" + f" {option_langue(lue=True)}", delai=120, ).split("\n"): if ligne.startswith("dépôts git"): @@ -1439,7 +1465,7 @@ class QemuCacheMenuMixin: cmd = ( f"{'sudo ' if sudo else ''}{CACHE_BIN}" f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}" - f" {options}" + f" {options} {option_langue()}" ) print(f"{t('Will execute:')} {cmd}\n") self.execute.exec_command_live(cmd, source_erplibre=False) @@ -1816,11 +1842,19 @@ class QemuCacheMenuMixin: """ lire = ( f"journalctl -u {CACHE_SERVICE} -o cat --no-pager" - " | grep -F 'tunnel opaque retenu pour'" + " | grep -F" + + "".join(f" -e {shlex.quote(r)}" for r in REFUS_APPRIS) ) vu = cls._cache_lire(lire) + cls._cache_lire(f"sudo -n {lire}") return sorted( - set(re.findall(r"tunnel opaque retenu pour (\S+) \(", vu)) + set( + re.findall( + "(?:" + + "|".join(map(re.escape, REFUS_APPRIS)) + + r") (\S+) \(", + vu, + ) + ) ) def _cache_exclus(self): @@ -2199,6 +2233,7 @@ def miroir_prefetch_cmd(fichier, environnement): CACHE_BIN, "--git-mirror-dir", CACHE_MIROIR_GIT, + option_langue(), "--git-mirror-prefetch", "/dev/stdin", "<", @@ -2217,7 +2252,7 @@ def bypass_retrait_cmd(mac): """ return ( f"sudo {CACHE_BIN} --bypass-del {shlex.quote(mac)}" - f" --bypass-file {CACHE_BYPASS} | sudo nft -f -" + f" --bypass-file {CACHE_BYPASS} {option_langue()} | sudo nft -f -" ) diff --git a/script/todo/todo.py b/script/todo/todo.py index 1c2a81b..3ee5bb8 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -1828,6 +1828,7 @@ class TODO( f"sudo EL_HTTP_PORT={http_port}" f" EL_TLS_PORT={tls_port}" f" EL_CACHE_DIR={cache_dir}" + f" EL_LANG={get_lang()}" f" bash {script_path}" ) print(f"\n{t('Will execute:')} {cmd}\n") diff --git a/test/test_i18n_duplicates.py b/test/test_i18n_duplicates.py deleted file mode 100644 index 8439f17..0000000 --- a/test/test_i18n_duplicates.py +++ /dev/null @@ -1,83 +0,0 @@ -#!/usr/bin/env python3 -# © 2026 TechnoLibre (http://www.technolibre.ca) -# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) - -"""Une clé de traduction en double écrase la précédente, sans un mot. - -Python construit un dictionnaire littéral de haut en bas : la dernière -occurrence gagne. Une clé posée deux fois avec des valeurs différentes fait -donc qu'un menu affiche la traduction d'un autre, et rien ne le signale — ni -au chargement, ni à l'exécution, ni au test qui exerce le premier menu. - -C'est arrivé : « running » et « stopped » existaient déjà, et des clés -homonymes ajoutées pour un service ont été avalées par elles. Le symptôme -était un « Service : en cours » là où le fichier disait « actif ». - -Le contrôle lit le fichier par son ARBRE et non par une expression -régulière : la mienne n'attrapait que la forme multiligne, et la collision -était écrite sur une seule ligne. -""" - -import ast -import collections -import sys -import unittest -from pathlib import Path - -RACINE = Path(__file__).resolve().parent.parent -I18N = RACINE / "script" / "todo" / "todo_i18n.py" -sys.path.insert(0, str(RACINE / "script" / "todo")) - - -def cles_litterales(): - """Les clés du dictionnaire TRANSLATIONS, dans l'ordre du fichier. - - Par l'arbre syntaxique : une clé écrite sur une ligne et une clé écrite - sur plusieurs sont le même nœud, alors qu'elles n'ont pas la même forme - dans le texte. - """ - arbre = ast.parse(I18N.read_text(encoding="utf-8")) - for noeud in ast.walk(arbre): - if not isinstance(noeud, ast.Assign): - continue - cibles = [c.id for c in noeud.targets if isinstance(c, ast.Name)] - if "TRANSLATIONS" not in cibles: - continue - if not isinstance(noeud.value, ast.Dict): - continue - return [ - k.value - for k in noeud.value.keys - if isinstance(k, ast.Constant) and isinstance(k.value, str) - ] - raise AssertionError("TRANSLATIONS introuvable") - - -class TestClesUniques(unittest.TestCase): - def test_aucune_cle_nest_ecrite_deux_fois(self): - compte = collections.Counter(cles_litterales()) - doubles = sorted(k for k, n in compte.items() if n > 1) - self.assertEqual( - doubles, - [], - "clés en double — la dernière écrase les précédentes, sans un " - f"mot : {doubles}", - ) - - def test_le_controle_lit_bien_quelque_chose(self): - """Un test qui ne trouve aucune clé passerait toujours.""" - self.assertGreater(len(cles_litterales()), 1000) - - def test_chaque_cle_porte_les_deux_langues(self): - import todo_i18n - - manques = [ - k - for k, v in todo_i18n.TRANSLATIONS.items() - if not isinstance(v, dict) or "fr" not in v or "en" not in v - ] - self.assertEqual(manques, [], f"traductions incomplètes : {manques}") - - -if __name__ == "__main__": - unittest.main() diff --git a/test/test_qemu_cache_langue.py b/test/test_qemu_cache_langue.py new file mode 100644 index 0000000..cc08cd9 --- /dev/null +++ b/test/test_qemu_cache_langue.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +# © 2026 TechnoLibre (http://www.technolibre.ca) +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +"""La langue du binaire du cache, vue du menu. + +Le binaire traduit ses messages humains selon --lang. Le menu doit donc dire +laquelle il veut, et la bonne : la langue de todo.py pour ce qu'il AFFICHE, +le français pour ce qu'il LIT — il ne connaît les libellés que dans cette +langue. Et ce qu'il lit dans un journal écrit par le service, dont la langue +est fixée à l'installation, il doit le reconnaître dans les deux. +""" + +import sys +import unittest +from pathlib import Path +from unittest import mock + +RACINE = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(RACINE)) + +from script.todo import qemu_cache_menu as menu # noqa: E402 + +CATALOGUE = RACINE / "script" / "qemu_cache" / "catalogue_en.go" + + +class TestLOptionDeLangue(unittest.TestCase): + def test_une_sortie_lue_est_demandee_en_francais(self): + with mock.patch.object(menu, "get_lang", return_value="en"): + self.assertEqual(menu.option_langue(lue=True), "--lang fr") + + def test_une_sortie_affichee_suit_la_langue_de_todo(self): + for langue in ("fr", "en"): + with mock.patch.object(menu, "get_lang", return_value=langue): + self.assertEqual(menu.option_langue(), f"--lang {langue}") + + def test_le_pre_remplissage_parle_la_langue_de_todo(self): + """Lancé par sudo -u : EL_LANG n'y arriverait pas, l'option si.""" + with mock.patch.object(menu, "get_lang", return_value="en"): + cmd = menu.miroir_prefetch_cmd("/tmp/liste", []) + self.assertIn("--lang en", cmd) + + def test_le_retrait_d_exception_garde_nft_en_bout_de_tube(self): + """Le message humain va à l'erreur standard ; la sortie standard + porte les règles que nft lit, et ne se traduit jamais.""" + with mock.patch.object(menu, "get_lang", return_value="en"): + cmd = menu.bypass_retrait_cmd("52:54:00:00:00:04") + self.assertIn("--lang en", cmd) + self.assertTrue(cmd.endswith("| sudo nft -f -")) + + +class TestCeQueLeMenuLit(unittest.TestCase): + def test_l_occupation_du_miroir_est_lue_en_francais(self): + vu = {} + + def faux_lire(cmd, delai=15): + vu["cmd"] = cmd + return "objets : 3\ndépôts git : 12 en miroir, 4.2 Gio\n" + + with mock.patch.object( + menu.QemuCacheMenuMixin, "_cache_lire", staticmethod(faux_lire) + ), mock.patch.object(menu, "get_lang", return_value="en"): + rendu = menu.QemuCacheMenuMixin._cache_miroir_occupation() + self.assertIn("--lang fr", vu["cmd"]) + self.assertEqual(rendu, ("12", "4.2 Gio")) + + def test_le_journal_est_lu_dans_les_deux_langues(self): + """La langue du service est celle de son installation, pas celle de + qui ouvre le menu : un refus appris doit être reconnu dans les deux.""" + journal = ( + "tunnel opaque retenu pour npm.example (3 échec(s)) : EOF\n" + "opaque tunnel kept for pypi.example (3 failure(s)): EOF\n" + ) + commandes = [] + + def faux_lire(cmd, delai=15): + commandes.append(cmd) + return journal + + with mock.patch.object( + menu.QemuCacheMenuMixin, "_cache_lire", staticmethod(faux_lire) + ): + hotes = menu.QemuCacheMenuMixin._cache_refus_appris() + self.assertEqual(hotes, ["npm.example", "pypi.example"]) + for motif in menu.REFUS_APPRIS: + self.assertIn(motif, commandes[0]) + + def test_le_catalogue_porte_ce_que_le_menu_lit(self): + """Une traduction qui s'écarte de ces libellés rendrait le menu + aveugle sans qu'aucun test du binaire ne le voie.""" + texte = CATALOGUE.read_text(encoding="utf-8") + self.assertIn('"opaque tunnel kept for %s (', texte) + for libelle in ("authority : %s", "fingerprint : %s"): + self.assertIn(f'"{libelle}', texte) + for prefixe in menu.LIBELLES_TUS: + self.assertTrue( + prefixe in texte or prefixe in ("autorité", "empreinte"), + prefixe, + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/test/test_todo_i18n.py b/test/test_todo_i18n.py index db29c9f..f51ad26 100644 --- a/test/test_todo_i18n.py +++ b/test/test_todo_i18n.py @@ -292,5 +292,22 @@ class TestAucuneCleRepetee(unittest.TestCase): self.assertEqual(len(self._cles()), len(todo_i18n.TRANSLATIONS)) +class TestChaqueCleDitSesDeuxLangues(unittest.TestCase): + """Une clé sans « fr » ou sans « en » ne lève rien au chargement. + + t() retombe alors sur la clé elle-même, si bien que l'écran montre + l'anglais à qui a choisi le français, ou l'inverse, sans que rien ne le + signale. + """ + + def test_every_key_carries_fr_and_en(self): + manques = sorted( + k + for k, v in todo_i18n.TRANSLATIONS.items() + if not isinstance(v, dict) or "fr" not in v or "en" not in v + ) + self.assertEqual(manques, [], "traductions incomplètes") + + if __name__ == "__main__": unittest.main() From cd2b8266d470e52a04eb0082e3bbe87a4b8efb44 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 02:59:01 -0400 Subject: [PATCH 02/13] =?UTF-8?q?[FIX]=20requirement=20erplibre=20:=20d?= =?UTF-8?q?=C3=A9clarer=20openpyxl,=20sauter=20sans=20lui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three Transform data tests wrote an Excel workbook with openpyxl, which the .venv.erplibre requirements never declared, so the unit suite reported three errors on every install. openpyxl is now declared there. The tests skip when it cannot be imported in their own interpreter, since a venv built before this line still lacks it; the dedicated engine venv is not the right probe, as the tests write their workbook in-process. Checked: 707 Transform tests pass, and with openpyxl made unimportable the three are skipped, not errors. --- FR --- Trois tests de Transform data écrivaient un classeur Excel avec openpyxl, que les dépendances de .venv.erplibre ne déclaraient pas : la suite unitaire rendait trois erreurs sur toute installation. openpyxl y est désormais déclaré. Les tests se sautent quand il ne s'importe pas dans leur interpréteur, un venv bâti avant cette ligne ne l'ayant toujours pas ; le venv dédié du moteur n'est pas la bonne sonde, les tests écrivant leur classeur sur place. Vérifié : 707 tests de Transform passent, et openpyxl rendu inimportable, les trois sont sautés et non en erreur. Assisted-by: Claude Opus 5 --- requirement/erplibre_require-ments.txt | 5 +++++ script/data/external_file.py | 4 +--- script/todo/transform_menu.py | 2 +- script/todo/transform_setup.py | 9 +++++---- test/test_transform_external.py | 18 ++++++++++++++++++ 5 files changed, 30 insertions(+), 8 deletions(-) diff --git a/requirement/erplibre_require-ments.txt b/requirement/erplibre_require-ments.txt index 742caf8..dd21a07 100644 --- a/requirement/erplibre_require-ments.txt +++ b/requirement/erplibre_require-ments.txt @@ -37,6 +37,11 @@ rich # Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc # un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame. lxml +# Classeurs Excel de « Transform data », et les tests qui les éprouvent. Le +# moteur garde son venv dédié (script/todo/transform_setup.py) : un +# .venv.erplibre installé avant cette ligne n'a pas openpyxl, et le code ne +# l'importe donc jamais au niveau d'un module. +openpyxl python-dotenv python-dateutil unidecode diff --git a/script/data/external_file.py b/script/data/external_file.py index c1412cc..2ca5636 100644 --- a/script/data/external_file.py +++ b/script/data/external_file.py @@ -19,9 +19,7 @@ Les formats en pur stdlib — CSV, JSON, XML, et la détection de macros qui n'est qu'un `zipfile.namelist()` — tournent sous l'interpréteur du CLI. Excel et Access exigent un venv dédié. Le module doit donc s'importer sous les deux : AUCUN import de bibliothèque tierce au niveau du module, chacun -vit dans la fonction qui en a besoin. Les tests unitaires du dépôt tournent -sous `.venv.erplibre`, qui n'a pas openpyxl : un import au niveau du module -les ferait tomber tous, y compris ceux des règles pures. +vit dans la fonction qui en a besoin. Le canal de sortie ------------------ diff --git a/script/todo/transform_menu.py b/script/todo/transform_menu.py index 3c4c86a..a616390 100644 --- a/script/todo/transform_menu.py +++ b/script/todo/transform_menu.py @@ -971,7 +971,7 @@ class TransformMenuMixin: Le processus doit lire la source ET écrire la cible : il lui faut l'union des bibliothèques. La source seule laissait csv→xlsx - importer openpyxl sous l'interpréteur du CLI, qui ne l'a pas ; la + importer openpyxl sous l'interpréteur du CLI, qui peut ne pas l'avoir ; la cible seule enverrait un classeur au même interpréteur, qui ne sait pas le lire. """ diff --git a/script/todo/transform_setup.py b/script/todo/transform_setup.py index 1cb47e3..0ca8fda 100644 --- a/script/todo/transform_setup.py +++ b/script/todo/transform_setup.py @@ -4,10 +4,11 @@ """L'environnement de lecture des fichiers externes, posé à la demande. -Excel et Access exigent des bibliothèques qui ne sont dans aucun venv du -dépôt : `.venv.erplibre` porte l'outillage du CLI, le venv Odoo porte celui -d'Odoo, et charger l'un ou l'autre de ces lecteurs les mélangerait à un -sujet qui n'est pas le leur. D'où un venv dédié, bâti au premier besoin. +Excel et Access exigent des bibliothèques qu'aucun venv du dépôt ne porte à +coup sûr : `.venv.erplibre` porte l'outillage du CLI — openpyxl compris, +mais seulement depuis qu'il est dans ses dépendances —, le venv Odoo porte +celui d'Odoo, et le lecteur Access n'est dans aucun. D'où un venv dédié, +bâti au premier besoin. Les formats en pur stdlib n'en ont PAS besoin, et c'est ce que `available()` et `engine_python()` tiennent à la place des appelants : un diff --git a/test/test_transform_external.py b/test/test_transform_external.py index 83b4e56..3aa186c 100644 --- a/test/test_transform_external.py +++ b/test/test_transform_external.py @@ -6849,6 +6849,24 @@ class TestFuiteXlsx(unittest.TestCase): ) +def _openpyxl_ici(): + """Vrai si openpyxl s'importe dans CET interpréteur. + + Pas `transform_setup.available("xlsx")` : il répond vrai dès que le venv + dédié l'a, alors que ces tests écrivent leur classeur ici même. + """ + try: + import openpyxl # noqa: F401 + except ImportError: + return False + return True + + +@unittest.skipUnless( + _openpyxl_ici(), + "openpyxl absent de cet interpréteur : pip install -r" + " requirement/erplibre_require-ments.txt", +) class TestHorodatageDeLaCopie(unittest.TestCase): """La copie ne porte pas l'instant où elle a été tirée. From 7df58956eac26d00e429a35ab340ee5a70a66608 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 02:59:01 -0400 Subject: [PATCH 03/13] =?UTF-8?q?[FIX]=20tests=20assistant=20:=20juger=20l?= =?UTF-8?q?a=20fronti=C3=A8re=20dans=20un=20interpr=C3=A9teur=20neuf?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six tests asserted that an assistant module does not import script.todo.todo by looking at sys.modules of the running process. Under the full suite another test had already imported todo, so all six failed whatever the module did. Each now imports its module in a fresh interpreter and reads the answer there. Checked: the six pass after test_todo_menu, the order that broke them, and a module made to import todo fails its test. --- FR --- Six tests affirmaient qu'un module de l'assistant n'importe pas script.todo.todo en regardant le sys.modules du processus en cours. Dans la suite complète, un autre test avait déjà importé todo : les six échouaient quoi que fasse le module. Chacun importe désormais son module dans un interpréteur neuf et y lit la réponse. Vérifié : les six passent après test_todo_menu, l'ordre qui les faisait tomber, et un module qu'on fait importer todo fait échouer son test. Assisted-by: Claude Opus 5 --- test/test_assistant_claude_sessions.py | 19 ++++++++++++++++++- test/test_assistant_context.py | 19 ++++++++++++++++++- test/test_assistant_gpt_loader.py | 19 ++++++++++++++++++- test/test_assistant_hosts.py | 19 ++++++++++++++++++- test/test_assistant_servers.py | 19 ++++++++++++++++++- test/test_assistant_sweep.py | 19 ++++++++++++++++++- 6 files changed, 108 insertions(+), 6 deletions(-) diff --git a/test/test_assistant_claude_sessions.py b/test/test_assistant_claude_sessions.py index 97c9478..cb439d9 100644 --- a/test/test_assistant_claude_sessions.py +++ b/test/test_assistant_claude_sessions.py @@ -31,6 +31,7 @@ from __future__ import annotations import json import os +import subprocess import sys import tempfile import unittest @@ -323,7 +324,23 @@ class LaFrontiere(unittest.TestCase): """Le paquet doit rester importable sans le CLI.""" def test_le_listage_n_importe_pas_todo(self): - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.claude_sessions;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") if __name__ == "__main__": diff --git a/test/test_assistant_context.py b/test/test_assistant_context.py index 1000848..ba61c5b 100644 --- a/test/test_assistant_context.py +++ b/test/test_assistant_context.py @@ -25,6 +25,7 @@ qu'il porte. from __future__ import annotations import os +import subprocess import sys import tempfile import unittest @@ -344,7 +345,23 @@ class LaFrontiere(unittest.TestCase): """Le paquet doit rester importable sans le CLI.""" def test_le_contexte_n_importe_pas_todo(self): - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.context;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") if __name__ == "__main__": diff --git a/test/test_assistant_gpt_loader.py b/test/test_assistant_gpt_loader.py index 169d56e..01e5dce 100644 --- a/test/test_assistant_gpt_loader.py +++ b/test/test_assistant_gpt_loader.py @@ -24,6 +24,7 @@ temporaires, et le répertoire personnel est détourné là où il compte. from __future__ import annotations import os +import subprocess import sys import tempfile import unittest @@ -394,7 +395,23 @@ class LaFrontiere(unittest.TestCase): """Le paquet doit rester importable sans le CLI.""" def test_le_chargeur_n_importe_pas_todo(self): - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.gpt;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") if __name__ == "__main__": diff --git a/test/test_assistant_hosts.py b/test/test_assistant_hosts.py index 3a93ed6..c2c82b3 100644 --- a/test/test_assistant_hosts.py +++ b/test/test_assistant_hosts.py @@ -40,6 +40,7 @@ niveau `.invalid` est réservé à cet usage, et « azurite », « obsidienne » import os import re +import subprocess import sys import unittest @@ -317,7 +318,23 @@ class LaFrontiere(unittest.TestCase): def test_les_sources_ne_tirent_pas_todo(self): """Importer `script.todo.todo` coûte près d'une seconde et imprime sur la sortie : le paquet doit rester importable seul.""" - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.discover;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") def test_aucun_analyseur_de_config_ssh_n_est_reecrit_ici(self): """`ssh -G` a raison sur les `Include`, les `Match`, l'héritage des diff --git a/test/test_assistant_servers.py b/test/test_assistant_servers.py index 3d89752..1a62658 100644 --- a/test/test_assistant_servers.py +++ b/test/test_assistant_servers.py @@ -28,6 +28,7 @@ n'y paraissent nulle part. """ import os +import subprocess import sys import unittest from pathlib import Path @@ -285,7 +286,23 @@ class LaFrontiere(unittest.TestCase): def test_le_registre_ne_tire_pas_todo(self): """Importer `script.todo.todo` coûte près d'une seconde et imprime sur la sortie : le paquet doit rester importable seul.""" - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.servers;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") if __name__ == "__main__": diff --git a/test/test_assistant_sweep.py b/test/test_assistant_sweep.py index d5ff2e1..88a1e14 100644 --- a/test/test_assistant_sweep.py +++ b/test/test_assistant_sweep.py @@ -35,6 +35,7 @@ paraissent nulle part ailleurs dans le dépôt. import os import re +import subprocess import sys import time import unittest @@ -354,7 +355,23 @@ class LaFrontiere(unittest.TestCase): def test_le_balayage_ne_tire_pas_todo(self): """Importer `script.todo.todo` coûte près d'une seconde et imprime sur la sortie : le paquet doit rester importable seul.""" - self.assertNotIn("script.todo.todo", sys.modules) + # Dans un interpréteur NEUF : la suite complète importe todo par + # ailleurs, et le sys.modules de ce processus en garderait la trace + # quel que soit le module éprouvé ici. + sortie = subprocess.run( + [ + sys.executable, + "-c", + "import sys, script.todo.assistant.discover, script.todo.assistant.fingerprint;" + " print('script.todo.todo' in sys.modules)", + ], + cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + capture_output=True, + text=True, + timeout=60, + ) + self.assertEqual(sortie.returncode, 0, sortie.stderr) + self.assertEqual(sortie.stdout.strip(), "False") def test_aucun_asyncio_du_depot_n_est_utilise(self): """`AsyncioPool` enveloppe des sous-processus et non des sockets, et From 90ef1d24e789a9db4153cd7e49be157d32a1c6bf Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 02:59:02 -0400 Subject: [PATCH 04/13] =?UTF-8?q?[ADD]=20cache=20qemu=20:=20revalider=20le?= =?UTF-8?q?s=20index,=20ranger=20chaque=20repr=C3=A9sentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sur une installation complète, 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 déjà gardé part désormais avec son ETag : l'amont juge chaque requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept », chaque représentation est rangée à part, faute de quoi deux clients qui alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip, nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent. --- EN --- On a full install the pip indexes, npm metadata and repo bundle weighed about 110 MB per VM, taken whole every time. An index already kept now leaves with its ETag: upstream judges every request, and a « 304 » serves the body from disk. Under « Vary: Accept » each representation is stored apart, without which two clients alternating between them re-download each in turn. The PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the archive they describe. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 6 + CHANGELOG.fr.md | 3 + CHANGELOG.md | 3 + script/qemu/cache_offline.py | 8 +- script/qemu_cache/catalogue_en.go | 1 + script/qemu_cache/classify.go | 5 + script/qemu_cache/classify_test.go | 21 +++ script/qemu_cache/main.go | 2 +- script/qemu_cache/proxy.go | 88 +++++++++++ script/qemu_cache/revalidation_test.go | 207 +++++++++++++++++++++++++ script/qemu_cache/store.go | 54 +++++++ script/qemu_cache/variante_test.go | 200 ++++++++++++++++++++++++ script/todo/qemu_cache_menu.py | 1 + 13 files changed, 595 insertions(+), 4 deletions(-) create mode 100644 script/qemu_cache/revalidation_test.go create mode 100644 script/qemu_cache/variante_test.go diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index c3e224f..d9156ad 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -127,6 +127,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing - Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool - 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 @@ -138,6 +140,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien - Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil - 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 ## Fixed @@ -182,6 +186,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose - A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes - **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones +- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them @@ -222,6 +227,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle - Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes - **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais +- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit ## Removed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 8d6706b..0e0723b 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -61,6 +61,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - Le menu des tests longs demande avant de créer de vraies machines, et redemande, avec des mots à lui, avant que `--detruire` efface des machines avec leurs disques. La commande est montrée d'abord, c'est elle qui rend la question répondable ; un plan à blanc ou un rapport de performance ne crée rien et ne demande rien - Chaque entrée du menu Proxmox VE porte une icône, la même image voulant dire la même action que dans les autres menus de l'outil - 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 ## Corrigé @@ -101,6 +103,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle - Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes - **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais +- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit ## Retiré diff --git a/CHANGELOG.md b/CHANGELOG.md index c007051..d4247ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The long-test menu asks before creating real machines, and asks again, in words of its own, before `--detruire` removes machines with their disks. The command is shown first, which is what makes the question answerable; a dry run or a performance report creates nothing and asks nothing - Every entry of the Proxmox VE menu carries an icon, the same picture meaning the same action as in the other menus of the tool - 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 ## Fixed @@ -101,6 +103,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose - A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes - **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones +- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them ## Removed diff --git a/script/qemu/cache_offline.py b/script/qemu/cache_offline.py index d02cf40..683b4c2 100644 --- a/script/qemu/cache_offline.py +++ b/script/qemu/cache_offline.py @@ -422,12 +422,14 @@ def table_posee_cmd(table: str = TABLE) -> str: # chercher là plutôt que de recopier son chemin, qui est réglable. CONF = "/etc/erplibre_go_qemu_cache/env" -# Les issues qui prouvent qu'un CORPS est en réserve. « fetched » n'en est -# pas : elle couvre aussi le « 304 » d'une revalidation, qui n'a pas de corps. +# Les issues qui prouvent qu'un CORPS est en réserve. « revalidated » en est : +# l'amont a confirmé la copie gardée, et c'est elle qui est servie. « fetched » +# n'en est pas : elle couvre aussi le « 304 » rendu à un client qui posait sa +# propre condition, sans corps. # « stored-status » et « stale-status » n'en sont pas davantage : elles # portent un objet de STATUT seul — une redirection ou un refus gardés, sans # corps — et l'index d'une suite gardé en 404 ne la rend pas lisible. -ISSUES_EN_RESERVE = ("stored", "hit", "stale") +ISSUES_EN_RESERVE = ("stored", "hit", "stale", "revalidated") def journal(conf: str = CONF) -> str: diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index 9092632..7b20be9 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -9,6 +9,7 @@ package main // qu'aucun T(...) n'emploie, et une traduction qui ne garde pas les mêmes // verbes de format que son original. var anglais = map[string]string{ + "cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v", "langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG", "granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month", "durée vide": "empty duration", diff --git a/script/qemu_cache/classify.go b/script/qemu_cache/classify.go index f333b12..5132a93 100644 --- a/script/qemu_cache/classify.go +++ b/script/qemu_cache/classify.go @@ -55,6 +55,11 @@ var immutableSuffixes = []string{ ".pkg.tar.zst.sig", ".pkg.tar.xz.sig", // écosystèmes Python et Node ".whl", ".tgz", + // métadonnées d'une distribution Python servies à part (PEP 658) : le nom + // est celui de l'archive suivi de « .metadata », sous le même chemin + // d'empreinte. Sans ces suffixes, « .metadata » ne correspond à rien et le + // fichier tombe dans le volatil par défaut, repris à chaque installation. + ".whl.metadata", ".tar.gz.metadata", ".zip.metadata", // images et supports d'installation ".qcow2", ".iso", ".img", ".raw", ".vmdk", // archives amont diff --git a/script/qemu_cache/classify_test.go b/script/qemu_cache/classify_test.go index c33a3bc..b1a1202 100644 --- a/script/qemu_cache/classify_test.go +++ b/script/qemu_cache/classify_test.go @@ -24,6 +24,10 @@ func TestClassify(t *testing.T) { ClassImmutable, "paquet Fedora"}, {"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl", ClassImmutable, "roue Python"}, + {"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata", + ClassImmutable, "métadonnées d'une roue (PEP 658)"}, + {"https://pypi.example/packages/ab/cd/requests-2.33.0.tar.gz.metadata", + ClassImmutable, "métadonnées d'une archive source (PEP 658)"}, {"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2", ClassImmutable, "image cloud"}, @@ -247,3 +251,20 @@ func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) { t.Errorf("une version nommée classée « %s », attendu « immutable »", got) } } + +// Les métadonnées PEP 658 se rangent sans leur hôte, comme l'archive qu'elles +// décrivent : leur chemin porte déjà l'empreinte du contenu. +func TestLesMetadonneesPythonSontPortables(t *testing.T) { + for _, brut := range []string{ + "https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata", + "https://pypi.example/packages/ab/cd/requests-2.33.0.zip.metadata", + } { + u, err := url.Parse(brut) + if err != nil { + t.Fatal(err) + } + if !PortableParChemin(u) { + t.Errorf("%s n'est pas portable", brut) + } + } +} diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index af711c6..8e6982f 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.4" +const version = "0.2.8" func main() { var ( diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go index 2a79e07..52cf813 100644 --- a/script/qemu_cache/proxy.go +++ b/script/qemu_cache/proxy.go @@ -35,6 +35,7 @@ const ( // en réserve, et un refus gardé ne l'est pas. OutcomeStoredStatus = "stored-status" // pris à l'amont, statut seul gardé OutcomeStaleStatus = "stale-status" // amont muet, statut seul rejoué + OutcomeRevalidated = "revalidated" // l'amont confirme la copie (304), corps servi du disque ) // AccessLog écrit une ligne JSON par requête. Un format à une ligne par @@ -287,6 +288,42 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { amont = sansCondition(r) } + // Un index déjà gardé part avec SON validateur. L'amont reste juge de + // chaque requête — un index n'est jamais servi du disque sans son accord + // tant qu'il répond — mais une copie qu'il déclare à jour n'est pas + // retéléchargée : « 304 » ne porte aucun corps, et le nôtre sort du + // disque. Sans cela, chaque installation reprenait en entier des index + // de dizaines de mégaoctets qui n'avaient pas changé. + // + // L'ETag seul, jamais la date : sous « Vary: Accept », deux + // représentations d'une même URL partagent leur Last-Modified, et un + // « 304 » accordé sur la date validerait celle qui n'est pas gardée. Pas + // sous une clé portable non plus : partagée par tous les miroirs, elle + // présenterait à l'un le validateur d'un autre. La condition du client, + // quand il en pose une, reste la sienne. + // + // Sous « Vary: Accept », la représentation que CE client accepte a pu être + // rangée à part (voir CleVariante) : c'est son validateur qui part, et + // elle qui sort sur « 304 ». + cleVariante := "" + if cacheable && class == ClassVolatile && r.Method == "GET" && !partial { + cleVariante = CleVariante(key, r.Header.Get("Accept")) + } + varianteTenue := cleVariante != "" && p.Store.Detient(cleVariante) + cleRevalidee := key + if varianteTenue { + cleRevalidee = cleVariante + } + revalide := false + if cacheable && (detient || varianteTenue) && class == ClassVolatile && + r.Method == "GET" && !partial && !conditionnelle && !PortableParChemin(u) { + if etag := p.etagGarde(cleRevalidee); etag != "" { + amont = r.Clone(r.Context()) + amont.Header.Set("If-None-Match", etag) + revalide = true + } + } + // Un statut seul vit sous sa propre clé, que les lecteurs de corps ne // calculent pas : voir CleStatut. cleStatut := CleStatut(r.Method, u) @@ -300,6 +337,19 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { (detient || conditionnelle || p.Store.TientStatut(cleStatut)) resp, upErr := p.fetch(amont, u, repli) + if upErr == nil && revalide && resp.StatusCode == http.StatusNotModified { + resp.Body.Close() + // Effacée entre-temps — par un nettoyage —, la copie ne sort plus : + // la requête repart sans condition, et le client reçoit le corps de + // l'amont plutôt qu'un « 304 » qu'il n'a pas demandé. + if p.serveFromStore(w, r, u, cleRevalidee, class, OutcomeRevalidated) { + if cleRevalidee != key { + p.Store.Toucher(key) + } + return + } + resp, upErr = p.fetch(r, u, repli) + } // Une redirection est SUIVIE quand le nom du fichier demandé porte déjà // son identité, et le contenu est gardé sous l'URL DEMANDÉE. // @@ -322,6 +372,13 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { // Un index plus récent que la signature qui l'annonce n'est PAS // servi : voir indexIncoherent. Le client tombe alors sur le « 304 » // qui le laisse garder ses listes, ou sur le refus qui suit. + // La représentation de CE client d'abord : la base porte la dernière + // rangée, qui peut être l'autre. + if cacheable && varianteTenue && !p.indexIncoherent(u, key) && + p.serveFromStore(w, r, u, cleVariante, class, OutcomeStale) { + p.Store.Toucher(key) + return + } if cacheable && !p.indexIncoherent(u, key) && p.serveFromStore(w, r, u, key, class, OutcomeStale) { return @@ -450,6 +507,13 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { outcome = OutcomeStoredStatus } else { outcome = OutcomeStored + // La base est publiée telle qu'avant ; sa copie sous la variante + // garde cette représentation quand l'autre la remplacera. + if cleVariante != "" && varieSurAccept(resp.Header) { + if verr := p.Store.Copier(key, cleVariante); verr != nil { + log.Printf(T("cache : variante de %s non gardée : %v"), u, verr) + } + } } case !cacheable: outcome = OutcomePassthrough @@ -462,6 +526,30 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { }) } +// varieSurAccept dit si la réponse annonce varier selon l'en-tête Accept. +// « Vary » est une liste de noms d'en-têtes, insensible à la casse, et peut +// être répété. +func varieSurAccept(h http.Header) bool { + for _, v := range h.Values("Vary") { + for _, nom := range strings.Split(v, ",") { + if strings.EqualFold(strings.TrimSpace(nom), "Accept") { + return true + } + } + } + return false +} + +// etagGarde rend l'ETag du corps gardé sous la clé, ou "" : rien de gardé, +// un statut seul, ou une réponse d'amont qui n'en portait pas. +func (p *Proxy) etagGarde(key string) string { + m, err := p.Store.LireMeta(key) + if err != nil || m.StatutSeul() { + return "" + } + return m.Header.Get("ETag") +} + // serveFromStore rend vrai quand la réponse est partie du disque. func (p *Proxy) serveFromStore( w http.ResponseWriter, r *http.Request, u *url.URL, key string, diff --git a/script/qemu_cache/revalidation_test.go b/script/qemu_cache/revalidation_test.go new file mode 100644 index 0000000..a639b50 --- /dev/null +++ b/script/qemu_cache/revalidation_test.go @@ -0,0 +1,207 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "sync" + "testing" +) + +// amontValidant sert un corps sous un ETag et rend « 304 » à qui présente le +// bon. Il compte les CORPS envoyés, qui sont ce que la revalidation épargne, +// et note la condition reçue à chaque requête. +type amontValidant struct { + srv *httptest.Server + mu sync.Mutex + etag string // vide : aucune ETag, seulement une date + corps string + corpsParti int + requetes int + conditions []string +} + +func nouvelAmontValidant(t *testing.T, etag, corps string) *amontValidant { + t.Helper() + a := &amontValidant{etag: etag, corps: corps} + a.srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + a.requetes++ + cond := r.Header.Get("If-None-Match") + if cond == "" && r.Header.Get("If-Modified-Since") != "" { + cond = "date:" + r.Header.Get("If-Modified-Since") + } + a.conditions = append(a.conditions, cond) + if a.etag == "" { + w.Header().Set("Last-Modified", "Mon, 14 Sep 2026 14:53:55 GMT") + } else { + w.Header().Set("ETag", a.etag) + if cond == a.etag { + w.WriteHeader(http.StatusNotModified) + return + } + } + a.corpsParti++ + io.WriteString(w, a.corps) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *amontValidant) changer(etag, corps string) { + a.mu.Lock() + defer a.mu.Unlock() + a.etag, a.corps = etag, corps +} + +func (a *amontValidant) releve() (requetes, corps int, conditions []string) { + a.mu.Lock() + defer a.mu.Unlock() + return a.requetes, a.corpsParti, append([]string(nil), a.conditions...) +} + +func (a *amontValidant) hote() string { + u, _ := url.Parse(a.srv.URL) + return u.Host +} + +// indexPip est un index volatil rangé avec son hôte : la revalidation lui est +// ouverte. Le test l'éprouve avant de s'en servir, pour ne pas mesurer une +// règle d'exclusion à la place de celle qu'il nomme. +const indexPip = "/simple/requests/" + +func exigerIndexRevalidable(t *testing.T, hote string) { + t.Helper() + u := &url.URL{Scheme: "http", Host: hote, Path: indexPip} + if Classify(u) != ClassVolatile { + t.Fatalf("%s n'est plus volatil : le test mesurerait autre chose", indexPip) + } +} + +// Un index que l'amont déclare inchangé sort du disque, et son corps ne +// repart pas : c'est tout le gain. L'amont est pourtant interrogé à CHAQUE +// demande — un index n'est jamais servi sans son accord tant qu'il répond. +func TestUnIndexConfirmeParLAmontSortDuDisque(t *testing.T) { + a := nouvelAmontValidant(t, `"v1"`, "index pip, version 1") + exigerIndexRevalidable(t, a.hote()) + p := proxyDeTest(t) + + for i := 1; i <= 3; i++ { + w := demande(t, p, a.hote(), indexPip) + if w.Code != http.StatusOK || w.Body.String() != "index pip, version 1" { + t.Fatalf("demande %d : %d %q", i, w.Code, w.Body.String()) + } + veut := OutcomeRevalidated + if i == 1 { + veut = "miss" + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != veut { + t.Errorf("demande %d servie « %s », attendu « %s »", i, got, veut) + } + } + requetes, corps, conditions := a.releve() + if requetes != 3 { + t.Errorf("l'amont a reçu %d requêtes, attendu 3 : il doit juger chacune", requetes) + } + if corps != 1 { + t.Errorf("%d corps envoyés, attendu 1 : la copie confirmée a été retéléchargée", corps) + } + if conditions[0] != "" || conditions[1] != `"v1"` || conditions[2] != `"v1"` { + t.Errorf("conditions reçues %q : la première sans, les suivantes avec l'ETag gardé", conditions) + } +} + +// Un index qui a changé est repris en entier, remplace la copie, et c'est son +// NOUVEL ETag qui part ensuite. +func TestUnIndexChangeEstRepris(t *testing.T) { + a := nouvelAmontValidant(t, `"v1"`, "version 1") + exigerIndexRevalidable(t, a.hote()) + p := proxyDeTest(t) + + demande(t, p, a.hote(), indexPip) + a.changer(`"v2"`, "version 2") + if w := demande(t, p, a.hote(), indexPip); w.Body.String() != "version 2" { + t.Fatalf("après changement, corps %q", w.Body.String()) + } + w := demande(t, p, a.hote(), indexPip) + if w.Body.String() != "version 2" || w.Header().Get("X-ERPLibre-Cache") != OutcomeRevalidated { + t.Errorf("troisième demande : %q servie « %s »", w.Body.String(), w.Header().Get("X-ERPLibre-Cache")) + } + _, corps, conditions := a.releve() + if corps != 2 { + t.Errorf("%d corps envoyés, attendu 2", corps) + } + if conditions[2] != `"v2"` { + t.Errorf("condition de la troisième demande %q, attendu l'ETag de la copie remplacée", conditions[2]) + } +} + +// Sans ETag, rien ne part : une date seule validerait, sous « Vary: Accept », +// une représentation qui n'est pas celle gardée. +func TestSansETagLIndexRepartEntier(t *testing.T) { + a := nouvelAmontValidant(t, "", "index sans ETag") + exigerIndexRevalidable(t, a.hote()) + p := proxyDeTest(t) + + demande(t, p, a.hote(), indexPip) + w := demande(t, p, a.hote(), indexPip) + if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" { + t.Errorf("seconde demande servie « %s », attendu « miss »", got) + } + _, corps, conditions := a.releve() + if corps != 2 || conditions[1] != "" { + t.Errorf("%d corps, condition %q : attendu 2 corps et aucune condition", corps, conditions[1]) + } +} + +// La condition du client reste la sienne : il demande si SA copie a changé, +// et lui substituer l'ETag du cache répondrait à une autre question. +func TestLaConditionDuClientNEstPasRemplacee(t *testing.T) { + a := nouvelAmontValidant(t, `"v1"`, "version 1") + exigerIndexRevalidable(t, a.hote()) + p := proxyDeTest(t) + + demande(t, p, a.hote(), indexPip) + r := httptest.NewRequest("GET", indexPip, nil) + r.Host = a.hote() + r.Header.Set("If-None-Match", `"copie-du-client"`) + w := httptest.NewRecorder() + p.serve(w, r, "http") + + if w.Code != http.StatusOK || w.Body.String() != "version 1" { + t.Errorf("client dont la copie diffère : %d %q", w.Code, w.Body.String()) + } + _, _, conditions := a.releve() + if conditions[1] != `"copie-du-client"` { + t.Errorf("condition envoyée %q, attendu celle du client", conditions[1]) + } +} + +// Une clé sans hôte est partagée par tous les miroirs d'une liste qui tourne : +// présenter à l'un l'ETag gardé depuis un autre ne se fait pas, et l'index +// repart entier comme avant. +func TestUneCleSansHoteNeRevalidePas(t *testing.T) { + a := nouvelAmontValidant(t, `"v1"`, "base de paquets") + const chemin = "/arch/core/os/x86_64/core.db" + u := &url.URL{Scheme: "http", Host: a.hote(), Path: chemin} + if !PortableParChemin(u) || Classify(u) != ClassVolatile { + t.Fatalf("%s n'est plus un index volatil rangé sans hôte", chemin) + } + p := proxyDeTest(t) + + demande(t, p, a.hote(), chemin) + w := demande(t, p, a.hote(), chemin) + if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" { + t.Errorf("seconde demande servie « %s », attendu « miss »", got) + } + _, corps, conditions := a.releve() + if corps != 2 || conditions[1] != "" { + t.Errorf("%d corps, condition %q : attendu 2 corps et aucune condition", corps, conditions[1]) + } +} diff --git a/script/qemu_cache/store.go b/script/qemu_cache/store.go index d3507a2..c1e3248 100644 --- a/script/qemu_cache/store.go +++ b/script/qemu_cache/store.go @@ -118,6 +118,60 @@ func CleDe(method string, u *url.URL) string { return Key(method, u.String()) } +// CleVariante rend la clé d'une REPRÉSENTATION de l'objet rangé sous key, +// celle que choisit l'en-tête Accept de la requête. +// +// Un amont qui répond « Vary: Accept » sert sous une même URL plusieurs corps +// selon ce que le client accepte : une fiche de paquet complète ou abrégée. +// Rangés sous la seule clé de l'URL, ils se remplacent l'un l'autre, si bien +// qu'une installation qui demande les deux tour à tour les retélécharge +// chaque fois. La clé de base reste écrite comme avant ; la variante s'y +// ajoute. Rend "" pour un Accept vide : la requête n'en choisit aucune. +func CleVariante(key, accept string) string { + accept = strings.Join(strings.Fields(strings.ToLower(accept)), " ") + if accept == "" { + return "" + } + sum := sha256.Sum256([]byte(key + " accept " + accept)) + return hex.EncodeToString(sum[:]) +} + +// Copier publie sous dst le corps et les métadonnées rangés sous src, par le +// même temporaire et le même renommage qu'une écriture ordinaire : une copie +// interrompue ne laisse rien de visible. La date d'usage de src n'est pas +// touchée. +func (s *Store) Copier(src, dst string) error { + m, err := s.LireMeta(src) + if err != nil { + return err + } + _, bodyPath := s.paths(src) + f, err := os.Open(bodyPath) + if err != nil { + return err + } + defer f.Close() + w, err := s.NewWriter(dst, *m) + if err != nil { + return err + } + if _, err := io.Copy(w, f); err != nil { + w.Abort() + return err + } + return w.Commit(m.Size) +} + +// Toucher remet à maintenant la date d'usage du corps rangé sous key, s'il +// existe. Servir une variante sert aussi l'objet de base : sans cela, un +// nettoyage par âge retirerait la base qu'on sert encore par sa variante, et +// --detient la dirait absente. +func (s *Store) Toucher(key string) { + _, bodyPath := s.paths(key) + maintenant := time.Now() + _ = os.Chtimes(bodyPath, maintenant, maintenant) +} + // CleStatut rend la clé sous laquelle un STATUT SEUL est rangé et cherché. // // Un espace de clés à part, et non la clé du corps : un lecteur qui ne diff --git a/script/qemu_cache/variante_test.go b/script/qemu_cache/variante_test.go new file mode 100644 index 0000000..7876c8f --- /dev/null +++ b/script/qemu_cache/variante_test.go @@ -0,0 +1,200 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "io" + "net/http" + "net/http/httptest" + "net/url" + "os" + "strings" + "sync" + "testing" + "time" +) + +// Les deux en-têtes Accept d'un client de registre : la fiche abrégée, et la +// fiche complète. +const ( + acceptAbrege = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*" + acceptComplet = "application/json" +) + +// amontAVariantes sert deux représentations d'une même URL selon Accept, chacune +// sous son ETag, avec « Vary: Accept » ; « 304 » à qui présente l'ETag de la +// représentation qu'il demande. Il compte les corps envoyés par représentation. +type amontAVariantes struct { + srv *httptest.Server + mu sync.Mutex + vary bool + corps map[string]int +} + +func nouvelAmontAVariantes(t *testing.T, vary bool) *amontAVariantes { + t.Helper() + a := &amontAVariantes{vary: vary, corps: map[string]int{}} + a.srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + a.mu.Lock() + defer a.mu.Unlock() + rep, etag, corps := "complète", `"complet"`, "fiche complète, très longue" + if strings.Contains(r.Header.Get("Accept"), "install-v1") { + rep, etag, corps = "abrégée", `"abrege"`, "fiche abrégée" + } + if a.vary { + w.Header().Set("Vary", "accept-encoding, accept") + } + w.Header().Set("ETag", etag) + if r.Header.Get("If-None-Match") == etag { + w.WriteHeader(http.StatusNotModified) + return + } + a.corps[rep]++ + io.WriteString(w, corps) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *amontAVariantes) envoyes(rep string) int { + a.mu.Lock() + defer a.mu.Unlock() + return a.corps[rep] +} + +func (a *amontAVariantes) hote() string { + u, _ := url.Parse(a.srv.URL) + return u.Host +} + +const ficheNpm = "/npm" + +func demandeAccept(t *testing.T, p *Proxy, hote, accept string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("GET", ficheNpm, nil) + r.Host = hote + if accept != "" { + r.Header.Set("Accept", accept) + } + w := httptest.NewRecorder() + p.serve(w, r, "http") + return w +} + +// Deux représentations demandées tour à tour ne repartent qu'une fois chacune : +// rangée à part, l'une ne remplace plus l'autre. +func TestDeuxRepresentationsQuiAlternentNeRepartentQuUneFois(t *testing.T) { + a := nouvelAmontAVariantes(t, true) + u := &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm} + if Classify(u) != ClassVolatile || PortableParChemin(u) { + t.Fatalf("%s n'est plus un volatil rangé avec son hôte", ficheNpm) + } + p := proxyDeTest(t) + + ordre := []string{acceptAbrege, acceptComplet, acceptAbrege, acceptComplet, acceptAbrege} + for i, accept := range ordre { + w := demandeAccept(t, p, a.hote(), accept) + veut := "fiche complète, très longue" + if accept == acceptAbrege { + veut = "fiche abrégée" + } + if w.Body.String() != veut { + t.Fatalf("demande %d : corps %q, attendu %q", i+1, w.Body.String(), veut) + } + } + if n := a.envoyes("abrégée"); n != 1 { + t.Errorf("fiche abrégée envoyée %d fois, attendu 1", n) + } + if n := a.envoyes("complète"); n != 1 { + t.Errorf("fiche complète envoyée %d fois, attendu 1", n) + } +} + +// La clé de base reste tenue : --detient, le pré-vol et le rattrapage hors +// ligne ne connaissent pas l'en-tête Accept. +func TestLaCleDeBaseResteTenuePourDetient(t *testing.T) { + a := nouvelAmontAVariantes(t, true) + p := proxyDeTest(t) + demandeAccept(t, p, a.hote(), acceptAbrege) + + d := p.Store.Detenir("GET", "http://"+a.hote()+ficheNpm) + if d.Verdict != VerdictGarde { + t.Errorf("--detient dit %q pour une fiche gardée", d.Verdict) + } +} + +// Sans « Vary: Accept », rien n'est rangé à part : la réponse ne varie pas, et +// la dupliquer ne ferait que doubler le disque. +func TestSansVaryAcceptAucuneVariante(t *testing.T) { + a := nouvelAmontAVariantes(t, false) + p := proxyDeTest(t) + demandeAccept(t, p, a.hote(), acceptAbrege) + + base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm}) + if p.Store.Detient(CleVariante(base, acceptAbrege)) { + t.Error("une variante a été rangée pour une réponse sans Vary: Accept") + } + if !p.Store.Detient(base) { + t.Error("la clé de base n'est plus écrite") + } +} + +// Amont muet : le client reçoit SA représentation, pas la dernière rangée sous +// la base, qui peut être l'autre. +func TestHorsLigneLaRepresentationDuClientSort(t *testing.T) { + a := nouvelAmontAVariantes(t, true) + p := proxyDeTest(t) + hote := a.hote() + demandeAccept(t, p, hote, acceptAbrege) + demandeAccept(t, p, hote, acceptComplet) // la base porte désormais la complète + a.srv.Close() + + w := demandeAccept(t, p, hote, acceptAbrege) + if w.Code != http.StatusOK || w.Body.String() != "fiche abrégée" { + t.Errorf("hors ligne : %d %q, attendu la fiche abrégée", w.Code, w.Body.String()) + } +} + +// Une requête sans Accept ne choisit aucune représentation : elle suit la clé +// de base, comme avant. +func TestSansAcceptLaBaseSeule(t *testing.T) { + if CleVariante("cle", "") != "" || CleVariante("cle", " ") != "" { + t.Error("un Accept vide produit une clé de variante") + } + if CleVariante("cle", "Application/JSON") != CleVariante("cle", "application/json") { + t.Error("la casse d'Accept change la variante") + } + if !varieSurAccept(http.Header{"Vary": {"accept-encoding, Accept"}}) || + varieSurAccept(http.Header{"Vary": {"accept-encoding"}}) { + t.Error("lecture de Vary erronée") + } +} + +// Servir une variante sert aussi l'objet de base : sa date d'usage suit, sans +// quoi un nettoyage par âge retirerait la base qu'on sert encore, et --detient +// la dirait absente. +func TestServirUneVarianteRajeunitLaBase(t *testing.T) { + a := nouvelAmontAVariantes(t, true) + p := proxyDeTest(t) + demandeAccept(t, p, a.hote(), acceptAbrege) + + base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm}) + _, corpsBase := p.Store.paths(base) + vieux := time.Now().Add(-90 * 24 * time.Hour) + if err := os.Chtimes(corpsBase, vieux, vieux); err != nil { + t.Fatal(err) + } + w := demandeAccept(t, p, a.hote(), acceptAbrege) + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeRevalidated { + t.Fatalf("seconde demande servie « %s », attendu « %s »", got, OutcomeRevalidated) + } + fi, err := os.Stat(corpsBase) + if err != nil { + t.Fatal(err) + } + if time.Since(fi.ModTime()) > time.Hour { + t.Errorf("la base date encore du %s : son usage n'a pas été noté", fi.ModTime()) + } +} diff --git a/script/todo/qemu_cache_menu.py b/script/todo/qemu_cache_menu.py index 2b368b5..630d2d7 100644 --- a/script/todo/qemu_cache_menu.py +++ b/script/todo/qemu_cache_menu.py @@ -113,6 +113,7 @@ MITM_GO = os.path.join("script", "qemu_cache", "mitm.go") # nombreuses, faute d'avoir été relue quand elles sont apparues. ORDRE_ISSUES = ( "hit", + "revalidated", "mirror", "stored", "stale", From 4b60dcba308100bb910d5f51bf45d8b765629678 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:17 -0400 Subject: [PATCH 05/13] =?UTF-8?q?[ADD]=20d=C3=A9ploiement=20qemu=20:=20cou?= =?UTF-8?q?per=20l'audit=20npm,=20traduire=20l'exception?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'audit de npm interroge un service qu'aucun cache ne rejoue : amont coupé, il échoue à chaque installation sans rien vérifier. Une VM déployée hors ligne reçoit donc NPM_CONFIG_AUDIT=false, écrit dans /etc/environment et relu après cloud-init ; une VM en ligne garde son audit. Le retrait d'une exception du détournement parle désormais la langue du menu, son message allant à l'erreur standard tandis que la sortie standard garde les règles que nft lit, qui ne se traduisent jamais. --- EN --- The npm audit queries a service no cache can replay: upstream cut, it fails every install without checking anything. A VM deployed offline therefore gets NPM_CONFIG_AUDIT=false, written in /etc/environment and read back after cloud-init; an online VM keeps its audit. Removing a redirection exception now speaks the menu's language, its message going to standard error while standard output keeps the rules nft reads, which are never translated. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 6 +++++ script/qemu/deploy_qemu.py | 40 +++++++++++++++++++++++++++++- script/todo/proxmox_menu.py | 14 ++++++++--- script/todo/qemu_deploy.py | 6 +++++ test/test_proxmox_form.py | 45 ++++++++++++++++++++++++++++++++-- test/test_qemu_cache_bypass.py | 17 +++++++++++++ test/test_qemu_cache_charge.py | 32 ++++++++++++++++++++++++ test/test_qemu_cache_form.py | 19 ++++++++++++++ test/test_qemu_cache_guest.py | 45 ++++++++++++++++++++++++++++++++++ 9 files changed, 218 insertions(+), 6 deletions(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 18af5c5..b128060 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -622,9 +622,13 @@ def deployer( distro=DISTRO, version=VERSION, charge="minimum", + hors_ligne=False, ): """Une VM Arch, branchée sur le cache ou non. Rend son adresse, ou ''. + `hors_ligne` : la VM naît l'amont du cache coupé. Le déploiement y + désactive ce qu'aucun cache ne rejoue, comme le ferait le formulaire. + Sans le cache, la VM télécharge en direct : c'est le TÉMOIN, la mesure de ce que coûte une installation quand rien n'est gardé. Un gain ne veut rien dire sans lui. @@ -645,6 +649,7 @@ def deployer( f" --ssh-key {shlex.quote(cle_publique())}" + ( f" --cache-ca {shlex.quote(CA)}" + + (" --offline" if hors_ligne else "") if avec_cache else " --cache-bypass" ) @@ -949,6 +954,7 @@ def contre_epreuve( distro=distro, version=version, charge=charge, + hors_ligne=True, ) if not adresse: return False diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 01a1447..a70cd54 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2523,6 +2523,14 @@ CACHE_TRUST = { # disparaît alors que la VM garde sa variable. CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") +# Ce qu'une VM déployée l'amont du cache coupé reçoit en plus. L'audit de npm +# interroge un service distant qu'aucun cache ne peut rejouer : hors ligne il +# échoue à chaque installation sans rien vérifier. La variable reste dans la +# VM après son installation ; un « npm config set audit true » la contredit. +# Séparée de CACHE_ENV_VARS, que le binaire du cache recopie et que le test +# d'accord avec le Go compare. +OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),) + CACHE_CERT_NAME = "erplibre-cache.crt" @@ -2667,6 +2675,9 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str: geste = ( f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}" f" --bypass-name {shlex.quote(args.name)}" + # La commande passe par sudo, qui retire EL_LANG : la langue du + # message du binaire va donc en option. + f" --lang {shlex.quote(langue_des_messages(args))}" ) if shutil.which("nft"): # Le binaire écrit le fichier et rend sur sa sortie le geste à chaud. @@ -2681,6 +2692,20 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str: return mac +def langue_des_messages(args: argparse.Namespace) -> str: + """La langue des messages : --lang du déploiement, sinon celle de todo.py, + sinon le français. Rend toujours « fr » ou « en ».""" + langue = (getattr(args, "lang", "") or "").strip().lower() + if not langue: + try: + from script.todo.todo_i18n import get_lang + + langue = get_lang() + except Exception: + langue = "fr" + return "en" if langue.startswith("en") else "fr" + + def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: """L'autorité du cache, posée par cloud-init à l'étape init. @@ -2742,6 +2767,12 @@ def cache_commands(args: argparse.Namespace) -> list[str]: f"sh -c 'grep -q ^{var}= /etc/environment" f" || echo {var}={faisceau} >> /etc/environment'" ) + if getattr(args, "offline", False): + for var, valeur in OFFLINE_ENV_VARS: + commandes.append( + f"sh -c 'grep -q ^{var}= /etc/environment" + f" || echo {var}={valeur} >> /etc/environment'" + ) return commandes @@ -2765,7 +2796,7 @@ def cache_env_reload(fichier: str = "/etc/environment") -> str: séparateur final, sans effet quand le fichier est absent ou ne les porte pas, et qui ne fait pas échouer une commande sous « set -e ». """ - motif = "|".join(CACHE_ENV_VARS) + motif = "|".join(CACHE_ENV_VARS + tuple(v for v, _ in OFFLINE_ENV_VARS)) return ( f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}' ' | sed "s/^/export /")"; fi' @@ -4479,6 +4510,13 @@ def build_parser() -> argparse.ArgumentParser: "cette autorité dès son premier démarrage et ses téléchargements " "passent par le cache. Absent, rien n'est posé.", ) + g_cloud.add_argument( + "--offline", + action="store_true", + help="La VM est déployée l'amont du cache coupé : ce qu'aucun cache ne " + "peut rejouer y est désactivé, l'audit de npm d'abord. Sans effet " + "sans --cache-ca.", + ) g_cloud.add_argument( "--cache-bypass", action="store_true", diff --git a/script/todo/proxmox_menu.py b/script/todo/proxmox_menu.py index c675a9c..4c996e6 100644 --- a/script/todo/proxmox_menu.py +++ b/script/todo/proxmox_menu.py @@ -1690,9 +1690,12 @@ class ProxmoxMenuMixin: return "" return self._qemu_cache_ca_path() - def _pve_set_cache_ca(self, cible, vm, ca): + def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False): """Pose l'autorité du cache DANS la VM, par ssh. + `hors_ligne` : l'amont du cache est coupé pour ce déploiement ; la VM + reçoit en plus ce que la voie libvirt pose sous « --offline ». + Même source que la voie libvirt — `cache_files` et `cache_commands` de deploy_qemu — livrée autrement : « qm set » ne sait écrire aucun fichier, comme pour le guide et pour le fuseau. @@ -1707,7 +1710,10 @@ class ProxmoxMenuMixin: except Exception: # pragma: no cover - dépend du module return False args = types.SimpleNamespace( - distro=vm.get("distro") or "", cache_ca=ca, cache_bypass=False + distro=vm.get("distro") or "", + cache_ca=ca, + cache_bypass=False, + offline=hors_ligne, ) fichiers = mod.cache_files(args) if not fichiers: @@ -2122,7 +2128,9 @@ class ProxmoxMenuMixin: # range ses index sous l'hôte demandé, et une VM qui en # réclame un autre ne retrouve rien de ce qui est gardé. self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu) - self._pve_set_cache_ca(vm["alias"], vm, ca_cache) + self._pve_set_cache_ca( + vm["alias"], vm, ca_cache, hors_ligne=bool(coupee) + ) # Après la création, qui a posé l'écran accéléré : l'accès au # nœud de rendu est une affaire de COMPTE, et il se donne # dans l'invité. diff --git a/script/todo/qemu_deploy.py b/script/todo/qemu_deploy.py index 2ade1e6..2d64186 100644 --- a/script/todo/qemu_deploy.py +++ b/script/todo/qemu_deploy.py @@ -1114,6 +1114,7 @@ class QemuDeployMixin: git_email="", cache_ca="", cache_bypass=False, + offline=False, ): """Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu dry-run ET le déploiement réel).""" @@ -1163,6 +1164,10 @@ class QemuDeployMixin: # l'approuve dès son premier démarrage, sans quoi le détournement # lui présente un certificat qu'elle rejette. parts += ["--cache-ca", cache_ca] + if offline: + # L'amont du cache sera coupé : ce qu'aucun cache ne rejoue, + # l'audit de npm d'abord, est désactivé dans la VM. + parts.append("--offline") # L'identité git de la VM. Sans ces options, deploy_qemu recopie celle # de l'HÔTE : le formulaire la montre et permet de la changer, il ne # la remplace pas par du vide. @@ -1259,6 +1264,7 @@ class QemuDeployMixin: self._qemu_cache_ca_path() if self._qemu_cache_active() else "" ), cache_bypass=bool(spec.get("cache_bypass")), + offline=bool(spec.get("offline")), ) # Où l'installateur du cache pose son autorité. Un test compare cette diff --git a/test/test_proxmox_form.py b/test/test_proxmox_form.py index a519495..edc87c0 100644 --- a/test/test_proxmox_form.py +++ b/test/test_proxmox_form.py @@ -1262,7 +1262,7 @@ class TestLeMiroirAptDesVmProxmox(unittest.TestCase): ) self.assertLess( src.index('self._pve_set_apt_mirror(vm["alias"]'), - src.index('self._pve_set_cache_ca(vm["alias"]'), + src.index("self._pve_set_cache_ca("), ) @@ -2438,7 +2438,7 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase): todo._pve_write_guide = lambda *a, **k: True todo._pve_set_timezone = lambda *a, **k: True todo._qemu_import_module = lambda: None - todo._pve_set_cache_ca = lambda cible, vm, ca: ordre.append( + todo._pve_set_cache_ca = lambda cible, vm, ca, **k: ordre.append( ("autorité", cible, ca) ) todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append( @@ -2511,5 +2511,46 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase): self.assertEqual(appels, []) +class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase): + """Une VM Proxmox déployée hors ligne reçoit, comme la voie libvirt, de quoi + couper l'audit de npm : aucun cache ne rejoue ce service.""" + + def test_seule_la_vm_hors_ligne_coupe_l_audit(self): + import contextlib + import io + import sys + import tempfile + + sys.argv = ["todo.py"] + from script.todo.todo import TODO + + todo = TODO.__new__(TODO) + vu = {} + + def faux_ssh(cible, cmd, timeout=120): + vu["cmd"] = cmd + return 0, "" + + todo._pve_ssh = faux_ssh + with tempfile.NamedTemporaryFile( + "w", suffix=".crt", delete=False + ) as fh: + fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n") + fh.write("-----END CERTIFICATE-----\n") + ca = fh.name + rendus = {} + with contextlib.redirect_stdout(io.StringIO()): + for hors_ligne in (True, False): + todo._pve_set_cache_ca( + "hote+vm-a", + {"name": "vm-a", "distro": "ubuntu"}, + ca, + hors_ligne=hors_ligne, + ) + rendus[hors_ligne] = vu.get("cmd", "") + self.assertIn("NPM_CONFIG_AUDIT", rendus[True]) + self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False]) + + if __name__ == "__main__": unittest.main(verbosity=2) diff --git a/test/test_qemu_cache_bypass.py b/test/test_qemu_cache_bypass.py index 1bb9107..985599d 100644 --- a/test/test_qemu_cache_bypass.py +++ b/test/test_qemu_cache_bypass.py @@ -118,6 +118,23 @@ class TestLExceptionEstPoseeAvant(unittest.TestCase): self.assertTrue(pose, "l'exception n'a pas été posée") self.assertIn("nft -f -", " ".join(pose[0])) + def test_le_message_du_binaire_suit_la_langue(self): + """La commande passe par sudo, qui retire EL_LANG : sans l'option, le + binaire répondrait en français à qui déploie en anglais.""" + args = args_neufs() + args.lang = "en" + runner = FauxRunner({"is-active": (0, "")}) + with mock.patch.object( + deploy_qemu.os.path, "isfile", return_value=True + ), mock.patch.object( + deploy_qemu.shutil, "which", return_value="/usr/bin/nft" + ), mock.patch.object( + deploy_qemu, "macs_deja_prises", return_value=set() + ): + deploy_qemu.cache_bypass_apply(args, runner) + pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)] + self.assertIn("--lang en", " ".join(pose[0])) + def test_sans_cache_installe_rien_nest_pose(self): """Exiger un cache pour pouvoir s'en passer n'aurait aucun sens : sans lui, rien n'intercepte.""" diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 79ff885..5a007b2 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -250,5 +250,37 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase): self.assertEqual(len(ecrit["vms"]), 1) +class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase): + """Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long + doit le lui demander, comme le formulaire, sans quoi il mesure une VM que + personne ne déploierait ainsi.""" + + def commande(self, **kw): + from unittest import mock + + with mock.patch.object(QC, "dire") as dit, mock.patch.object( + QC, "cle_publique", return_value="/tmp/cle.pub" + ): + QC.deployer("vm", None, dry_run=True, **kw) + return " ".join(str(a) for c in dit.call_args_list for a in c.args) + + def test_la_vm_hors_ligne_recoit_offline(self): + self.assertIn("--offline", self.commande(hors_ligne=True)) + + def test_les_vm_en_ligne_n_en_recoivent_pas(self): + self.assertNotIn("--offline", self.commande()) + + def test_le_temoin_sans_cache_n_en_recoit_pas(self): + self.assertNotIn( + "--offline", self.commande(avec_cache=False, hors_ligne=True) + ) + + def test_la_contre_epreuve_le_demande(self): + import inspect + + source = inspect.getsource(QC.contre_epreuve) + self.assertIn("hors_ligne=True", source) + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_cache_form.py b/test/test_qemu_cache_form.py index 88844d5..bf31db7 100644 --- a/test/test_qemu_cache_form.py +++ b/test/test_qemu_cache_form.py @@ -121,6 +121,25 @@ class TestCommandeProduite(unittest.TestCase): todo._qemu_cache_active = lambda: actif and bool(ca) return todo._qemu_deploy_parts_for(self.vm(), spec, dry_run=True) + def test_hors_ligne_le_drapeau_offline(self): + """Hors ligne, la VM reçoit de quoi couper l'audit de npm.""" + parts = self.parts( + {"install": None, "offline": True}, ca="/tmp/essai-ca.crt" + ) + self.assertIn("--offline", parts) + + def test_en_ligne_aucun_drapeau_offline(self): + parts = self.parts({"install": None}, ca="/tmp/essai-ca.crt") + self.assertNotIn("--offline", parts) + + def test_une_vm_exceptee_n_a_pas_de_drapeau_offline(self): + """Elle ne traverse pas le cache : rien à couper chez elle.""" + parts = self.parts( + {"install": None, "offline": True, "cache_bypass": True}, + ca="/tmp/essai-ca.crt", + ) + self.assertNotIn("--offline", parts) + def test_service_arrete_aucun_drapeau(self): """Sans interception, l'autorité n'a rien à faire dans la VM.""" parts = self.parts({"install": None}, ca="/tmp/ca.crt", actif=False) diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index 35a7d23..e1f44e8 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -33,10 +33,13 @@ from script.qemu.deploy_qemu import ( # noqa: E402 CACHE_CERT_NAME, CACHE_ENV_VARS, CACHE_TRUST, + OFFLINE_ENV_VARS, cache_env_reload, cache_family, cache_files, + cache_commands, cache_runcmd, + langue_des_messages, ) RULES_GO = RACINE / "script" / "qemu_cache" / "rules.go" @@ -228,6 +231,48 @@ class TestLesVariablesRelues(unittest.TestCase): self.assertEqual(code, 0, err) +class TestLeHorsLigneCoupeLAuditNpm(unittest.TestCase): + """Hors ligne, l'audit de npm interroge un service qu'aucun cache ne rejoue.""" + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def commandes(self, offline): + args = faux_args(Path(self.tmp.name), "ubuntu") + args.offline = offline + return "\n".join(cache_commands(args)) + + def test_une_vm_hors_ligne_recoit_l_audit_coupe(self): + self.assertIn("NPM_CONFIG_AUDIT=false", self.commandes(True)) + + def test_une_vm_en_ligne_garde_son_audit(self): + self.assertNotIn("NPM_CONFIG_AUDIT", self.commandes(False)) + + def test_la_variable_est_relue_apres_cloud_init(self): + """Le « npm install » lancé sans sudo vit dans une session ouverte + avant que cloud-init n'écrive la variable.""" + for var, _ in OFFLINE_ENV_VARS: + self.assertIn(var, cache_env_reload()) + + +class TestLaLangueDesMessagesDuCache(unittest.TestCase): + def test_l_option_du_deploiement_l_emporte(self): + self.assertEqual( + langue_des_messages(argparse.Namespace(lang="en")), "en" + ) + self.assertEqual( + langue_des_messages(argparse.Namespace(lang="FR")), "fr" + ) + + def test_une_valeur_inconnue_rend_le_francais(self): + self.assertEqual( + langue_des_messages(argparse.Namespace(lang="de")), "fr" + ) + + class TestAccordAvecLeGo(unittest.TestCase): """La table Python et la table Go doivent dire la même chose. From cb57a79da00ab6dca29d5d4c8b79b1e5a1651300 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:17 -0400 Subject: [PATCH 06/13] =?UTF-8?q?[ADD]=20test=20long=20cache=20:=20encha?= =?UTF-8?q?=C3=AEner=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() From f20e21b7d20aa114e8f537ad3477dee2158b1128 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:17 -0400 Subject: [PATCH 07/13] =?UTF-8?q?[ADD]=20cache=20qemu=20:=20nettoyage=20pa?= =?UTF-8?q?r=20=C3=A2ge=20et=20plafond=20de=20taille?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le cache ne diminuait jamais de lui-même et vit sur le disque de l'orchestrateur : un parc qui déploie chaque jour finissait par le remplir, sans que rien ne le signale. Deux règles existent désormais, éteintes par défaut et réglées depuis le menu : l'âge du dernier usage, et un plafond de taille qui efface le moins récemment servi jusqu'à tenir dessous, objets et dépôts git confondus. Un minuteur systemd quotidien les applique, et une réinstallation garde les valeurs choisies. Le menu montre d'abord ce qui partirait. --- EN --- The cache never shrank by itself and lives on the orchestrator's disk: a site deploying every day filled it up with nothing to say so. Two rules now exist, off by default and set from the menu: the age of last use, and a size ceiling that erases the least recently served until the cache fits under it, objects and git mirrors alike. A daily systemd timer applies them, and a reinstall keeps the chosen values. The menu first shows what would go. Assisted-by: Claude Opus 5 --- CHANGELOG.base.md | 2 + CHANGELOG.fr.md | 1 + CHANGELOG.md | 1 + script/install/install_qemu_cache.sh | 62 +++++++++++- script/qemu_cache/catalogue_en.go | 13 ++- script/qemu_cache/main.go | 24 ++++- script/qemu_cache/plafond.go | 142 +++++++++++++++++++++++++++ script/qemu_cache/plafond_test.go | 136 +++++++++++++++++++++++++ script/todo/qemu_cache_menu.py | 140 ++++++++++++++++++++++++++ script/todo/todo.py | 1 + script/todo/todo_i18n.py | 68 ++++++++++++- test/test_qemu_cache_form.py | 4 +- test/test_qemu_cache_install.py | 82 ++++++++++++++++ test/test_qemu_cache_menu.py | 76 +++++++++++++- 14 files changed, 742 insertions(+), 10 deletions(-) create mode 100644 script/qemu_cache/plafond.go create mode 100644 script/qemu_cache/plafond_test.go diff --git a/CHANGELOG.base.md b/CHANGELOG.base.md index 66420c3..4f08722 100644 --- a/CHANGELOG.base.md +++ b/CHANGELOG.base.md @@ -71,6 +71,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 +- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen @@ -114,6 +115,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 +- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies ## Changed diff --git a/CHANGELOG.fr.md b/CHANGELOG.fr.md index 4aabbe3..c32e16a 100644 --- a/CHANGELOG.fr.md +++ b/CHANGELOG.fr.md @@ -51,6 +51,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 +- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies ## Modifié diff --git a/CHANGELOG.md b/CHANGELOG.md index 4a9460d..a059d18 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - 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 +- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen ## Changed diff --git a/script/install/install_qemu_cache.sh b/script/install/install_qemu_cache.sh index d34572d..e159b48 100755 --- a/script/install/install_qemu_cache.sh +++ b/script/install/install_qemu_cache.sh @@ -40,7 +40,19 @@ EL_BYPASS_FILE="${EL_BYPASS_FILE:-/etc/erplibre_go_qemu_cache/bypass}" EL_GIT_MIRROR_DIR="${EL_GIT_MIRROR_DIR:-${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}/git}" BIN="/usr/local/bin/erplibre_go_qemu_cache" -CONF_DIR="/etc/erplibre_go_qemu_cache" +CONF_DIR="${EL_CONF_DIR:-/etc/erplibre_go_qemu_cache}" +PURGE_UNIT="/etc/systemd/system/erplibre-go-qemu-cache-purge.service" +PURGE_TIMER="/etc/systemd/system/erplibre-go-qemu-cache-purge.timer" + +# Les réglages de nettoyage choisis depuis le menu survivent à une +# réinstallation : le fichier env est réécrit en entier, et les retrouver vides +# changerait en silence le comportement choisi. Une valeur passée dans +# l'environnement, même vide, l'emporte — c'est ainsi qu'on les désactive. +reglage_existant() { + sed -n "s/^$1=//p" "${CONF_DIR}/env" 2>/dev/null | head -n 1 +} +EL_PURGE_AGE="${EL_PURGE_AGE-$(reglage_existant EL_PURGE_AGE)}" +EL_MAX_SIZE="${EL_MAX_SIZE-$(reglage_existant EL_MAX_SIZE)}" UNIT="/etc/systemd/system/erplibre-go-qemu-cache.service" SERVICE_USER="elqcache" GO_MIN_MAJOR=1 @@ -267,6 +279,12 @@ EL_EXCLUDE=${EL_EXCLUDE} EL_BYPASS_FILE=${EL_BYPASS_FILE} EL_GIT_MIRROR_DIR=${EL_GIT_MIRROR_DIR} EL_LANG=${EL_LANG} +# Nettoyage automatique, chaque jour ; vide = désactivé. Réglable depuis le +# menu du cache de todo.py, sans réinstaller. +# EL_PURGE_AGE : ce qui n'a pas servi depuis ce délai part (ex. 90j). +# EL_MAX_SIZE : au-delà, le moins récemment servi part (ex. 50G). +EL_PURGE_AGE=${EL_PURGE_AGE} +EL_MAX_SIZE=${EL_MAX_SIZE} CONF chmod 0644 "${CONF_DIR}/env" } @@ -325,6 +343,46 @@ UNITE systemctl daemon-reload } +ecrire_purge() { + # Un minuteur quotidien qui ne fait RIEN tant que les deux réglages sont + # vides : le nettoyage reste manuel par défaut, et le régler suffit à + # l'activer, sans réinstaller. Sous le compte du service, propriétaire du + # magasin : un effacement en root laisserait des répertoires que le service + # ne pourrait plus écrire. « $$ » rend un « $ » au shell : la variable vient + # de l'EnvironmentFile au moment de l'exécution, jamais de ce script. + cat >"$PURGE_UNIT" <"$PURGE_TIMER" </dev/null 2>&1 || true systemctl enable erplibre-go-qemu-cache.service >/dev/null 2>&1 || true systemctl restart erplibre-go-qemu-cache.service diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index 7b20be9..aa1ad50 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -9,9 +9,16 @@ package main // qu'aucun T(...) n'emploie, et une traduction qui ne garde pas les mêmes // verbes de format que son original. var anglais = map[string]string{ - "cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v", - "langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG", - "granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month", + "taille vide": "empty size", + "taille illisible %q : essayer 50G, 500M, 2T": "unreadable size %q: try 50G, 500M, 2T", + "effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)": "erase the least recently served until the cache fits under this size (e.g. 50G)", + "purge au plafond : %v\n": "size purge: %v\n", + "[à blanc] ": "[dry run] ", + "plafond %s : le cache occupe %s\n": "ceiling %s: the cache occupies %s\n", + "%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n": "%sobjects: %d, %s; repositories: %d, %s; %s left\n", + "cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v", + "langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG", + "granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month", "durée vide": "empty duration", "durée illisible %q": "unreadable duration %q", "durée illisible %q : essayer 30j, 12h, 90m": "unreadable duration %q: try 30d, 12h, 90m", diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index 8e6982f..ab495ce 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.8" +const version = "0.2.9" func main() { var ( @@ -77,6 +77,8 @@ func main() { T("effacer TOUT le cache : objets et dépôts en miroir")) purgeAvant = flag.String("purge-older-than", "", T("n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)")) + purgeTaille = flag.String("purge-to-size", "", + T("effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)")) gitList = flag.Bool("git-mirror-list", false, T("dire les dépôts tenus en miroir, du plus lourd au plus léger")) gitRemove = flag.String("git-mirror-remove", "", @@ -234,6 +236,26 @@ func main() { fmt.Printf(T("dépôts effacés : %d, %s rendus\n"), nd, HumanBytes(octd)) return } + if *purgeTaille != "" { + plafond, err := LireTaille(*purgeTaille) + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } + b, err := PurgerJusqua(store, miroir, plafond, *dryRun) + if err != nil { + fmt.Fprintf(os.Stderr, T("purge au plafond : %v\n"), err) + } + prefixe := "" + if *dryRun { + prefixe = T("[à blanc] ") + } + fmt.Printf(T("plafond %s : le cache occupe %s\n"), HumanBytes(plafond), HumanBytes(b.Avant)) + fmt.Printf(T("%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n"), prefixe, + b.Objets, HumanBytes(b.OctetsObjets), b.Depots, HumanBytes(b.OctetsDepots), + HumanBytes(b.Apres)) + return + } if *gitList { depots := miroir.Depots() if len(depots) == 0 { diff --git a/script/qemu_cache/plafond.go b/script/qemu_cache/plafond.go new file mode 100644 index 0000000..d86a1a1 --- /dev/null +++ b/script/qemu_cache/plafond.go @@ -0,0 +1,142 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" +) + +// LireTaille lit une taille : un nombre, suivi ou non d'une unité binaire. +// +// « 50G », « 50Gio », « 50GiB », « 1.5 G », « 500M », « 2T » ou un nombre +// d'octets. Les unités valent des puissances de 1024 : c'est ce qu'affiche +// HumanBytes, et un plafond lu autrement que la taille affichée ne se +// comparerait pas à elle. +func LireTaille(s string) (int64, error) { + brut := strings.TrimSpace(s) + if brut == "" { + return 0, fmt.Errorf("%s", T("taille vide")) + } + t := strings.ToUpper(strings.ReplaceAll(brut, " ", "")) + for _, suffixe := range []string{"IB", "IO", "B", "O"} { + if strings.HasSuffix(t, suffixe) && len(t) > len(suffixe) { + t = strings.TrimSuffix(t, suffixe) + break + } + } + facteur := int64(1) + if n := len(t); n > 0 { + switch t[n-1] { + case 'K': + facteur = 1 << 10 + case 'M': + facteur = 1 << 20 + case 'G': + facteur = 1 << 30 + case 'T': + facteur = 1 << 40 + } + if facteur > 1 { + t = t[:n-1] + } + } + v, err := strconv.ParseFloat(t, 64) + if err != nil || v <= 0 { + return 0, fmt.Errorf(T("taille illisible %q : essayer 50G, 500M, 2T"), brut) + } + return int64(v * float64(facteur)), nil +} + +// BilanPlafond dit ce qu'une purge au plafond a effacé, ou effacerait. +type BilanPlafond struct { + Objets int + OctetsObjets int64 + Depots int + OctetsDepots int64 + Avant int64 + Apres int64 +} + +// candidat est un objet du magasin ou un dépôt en miroir, avec son dernier +// usage : ce qui décide de l'ordre d'éviction. +type candidat struct { + chemin string + octets int64 + usage time.Time + depot bool +} + +// PurgerJusqua efface le moins récemment servi, objets et dépôts confondus, +// jusqu'à ce que l'ensemble tienne sous le plafond. aBlanc compte sans rien +// effacer. +// +// Confondus, parce que le plafond porte sur le disque et que les deux s'y +// disputent la place : un dépôt oublié depuis des mois pèse plus que mille +// paquets servis récemment. L'usage d'un objet est la date de son corps, remise à +// jour à chaque service ; celle d'un dépôt, son dernier rafraîchissement. +// Rien ne part quand le cache tient déjà sous le plafond. +func PurgerJusqua(store *Store, miroir *GitMirror, plafond int64, aBlanc bool) (BilanPlafond, error) { + var b BilanPlafond + var tous []candidat + if store != nil && store.Dir != "" { + filepath.Walk(store.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if horsCasier(store.Dir, p, info) { + return filepath.SkipDir + } + if info.IsDir() || filepath.Ext(p) != ".body" { + return nil + } + tous = append(tous, candidat{p, info.Size(), info.ModTime(), false}) + return nil + }) + } + for _, d := range miroir.Depots() { + tous = append(tous, candidat{d.Chemin, d.Octets, d.Maj, true}) + } + for _, c := range tous { + b.Avant += c.octets + } + b.Apres = b.Avant + if b.Avant <= plafond { + return b, nil + } + sort.SliceStable(tous, func(i, j int) bool { return tous[i].usage.Before(tous[j].usage) }) + for _, c := range tous { + if b.Apres <= plafond { + break + } + if !aBlanc { + if c.depot { + if err := miroir.Retirer(c.chemin); err != nil { + return b, err + } + } else { + if err := os.Remove(c.chemin); err != nil { + continue + } + // Le méta part AVEC le corps : orphelin, il ferait croire à + // une copie présente. + os.Remove(strings.TrimSuffix(c.chemin, ".body") + ".meta") + } + } + b.Apres -= c.octets + if c.depot { + b.Depots++ + b.OctetsDepots += c.octets + } else { + b.Objets++ + b.OctetsObjets += c.octets + } + } + return b, nil +} diff --git a/script/qemu_cache/plafond_test.go b/script/qemu_cache/plafond_test.go new file mode 100644 index 0000000..e14bab1 --- /dev/null +++ b/script/qemu_cache/plafond_test.go @@ -0,0 +1,136 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLireTaille(t *testing.T) { + cas := map[string]int64{ + "50G": 50 << 30, + "50Gio": 50 << 30, + "50GiB": 50 << 30, + "1.5 G": 3 << 29, + "500M": 500 << 20, + "2t": 2 << 40, + "1024": 1024, + "64K": 64 << 10, + } + for brut, veut := range cas { + if got, err := LireTaille(brut); err != nil || got != veut { + t.Errorf("%q : %d (%v), attendu %d", brut, got, err, veut) + } + } + for _, brut := range []string{"", "abc", "5X", "-1G", "0"} { + if _, err := LireTaille(brut); err == nil { + t.Errorf("%q accepté", brut) + } + } +} + +// objetGarde range un corps de n octets, daté d'il y a `age`, et rend sa clé. +func objetGarde(t *testing.T, s *Store, nom string, n int, age time.Duration) string { + t.Helper() + cle := Key("GET", "http://miroir.example/"+nom) + w, err := s.NewWriter(cle, Meta{URL: "http://miroir.example/" + nom, Method: "GET", Status: 200}) + if err != nil { + t.Fatal(err) + } + w.Write([]byte(strings.Repeat("x", n))) + if err := w.Commit(int64(n)); err != nil { + t.Fatal(err) + } + _, corps := s.paths(cle) + quand := time.Now().Add(-age) + os.Chtimes(corps, quand, quand) + return cle +} + +// Le moins récemment servi part d'abord, et la purge s'arrête dès que le +// cache tient sous le plafond : effacer plus rendrait de la place qu'on ne +// demandait pas, au prix de téléchargements repayés. +func TestLePlafondEffaceLePlusAncienDAbord(t *testing.T) { + s := &Store{Dir: t.TempDir()} + vieux := objetGarde(t, s, "vieux", 100, 90*24*time.Hour) + moyen := objetGarde(t, s, "moyen", 100, 30*24*time.Hour) + recent := objetGarde(t, s, "recent", 100, time.Hour) + + b, err := PurgerJusqua(s, &GitMirror{}, 250, false) + if err != nil { + t.Fatal(err) + } + if b.Objets != 1 || b.Avant != 300 || b.Apres != 200 { + t.Errorf("bilan %+v, attendu 1 objet, 300 → 200", b) + } + if s.Detient(vieux) { + t.Error("le plus ancien est resté") + } + if !s.Detient(moyen) || !s.Detient(recent) { + t.Error("un objet plus récent est parti") + } + if metaPath, _ := s.paths(vieux); fichierExiste(metaPath) { + t.Error("le méta de l'objet effacé est resté") + } +} + +func fichierExiste(p string) bool { + _, err := os.Stat(p) + return err == nil +} + +func TestSousLePlafondRienNePart(t *testing.T) { + s := &Store{Dir: t.TempDir()} + cle := objetGarde(t, s, "seul", 100, 90*24*time.Hour) + b, _ := PurgerJusqua(s, &GitMirror{}, 1000, false) + if b.Objets != 0 || !s.Detient(cle) { + t.Errorf("sous le plafond, %d objet(s) effacé(s)", b.Objets) + } +} + +func TestABlancRienNEstEfface(t *testing.T) { + s := &Store{Dir: t.TempDir()} + cle := objetGarde(t, s, "vieux", 100, 90*24*time.Hour) + objetGarde(t, s, "recent", 100, time.Hour) + b, _ := PurgerJusqua(s, &GitMirror{}, 150, true) + if b.Objets != 1 || b.Apres != 100 { + t.Errorf("bilan à blanc %+v, attendu 1 objet compté", b) + } + if !s.Detient(cle) { + t.Error("une purge à blanc a effacé un objet") + } +} + +// Objets et dépôts se disputent le même disque : un dépôt oublié depuis des +// mois part avant des paquets servis récemment. +func TestUnDepotOublieCedeSaPlaceAvantLesObjets(t *testing.T) { + s := &Store{Dir: t.TempDir()} + objet := objetGarde(t, s, "paquet", 100, time.Hour) + m := &GitMirror{Dir: t.TempDir()} + depot := filepath.Join(m.Dir, "forge.example", "vieux.git") + if err := os.MkdirAll(depot, 0o755); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(depot, "pack"), []byte(strings.Repeat("y", 500)), 0o644) + quand := time.Now().Add(-120 * 24 * time.Hour) + os.Chtimes(depot, quand, quand) + + b, err := PurgerJusqua(s, m, 200, false) + if err != nil { + t.Fatal(err) + } + if b.Depots != 1 || b.Objets != 0 { + t.Errorf("bilan %+v, attendu le seul dépôt", b) + } + if fichierExiste(depot) { + t.Error("le dépôt oublié est resté") + } + if !s.Detient(objet) { + t.Error("l'objet récent est parti avant le dépôt oublié") + } +} diff --git a/script/todo/qemu_cache_menu.py b/script/todo/qemu_cache_menu.py index 630d2d7..e6cac9a 100644 --- a/script/todo/qemu_cache_menu.py +++ b/script/todo/qemu_cache_menu.py @@ -66,6 +66,10 @@ def option_langue(lue=False): CACHE_CA = "/var/lib/erplibre_go_qemu_cache/ca.crt" CACHE_SERVICE = "erplibre-go-qemu-cache.service" +# Le nettoyage automatique : une unité ponctuelle et son minuteur quotidien, +# posés par l'installateur, sans effet tant que leurs réglages sont vides. +CACHE_SERVICE_PURGE = "erplibre-go-qemu-cache-purge.service" +CACHE_MINUTEUR_PURGE = "erplibre-go-qemu-cache-purge.timer" CACHE_CONF = "/etc/erplibre_go_qemu_cache/env" CACHE_TABLE = "erplibre_qemu_cache" CACHE_BYPASS = "/etc/erplibre_go_qemu_cache/bypass" @@ -382,6 +386,7 @@ class QemuCacheMenuMixin: {"prompt_description": t("Cache - Fill what offline runs lacked")}, {"prompt_description": t("Cache - Logs")}, {"prompt_description": t("Cache - Copy it to another machine")}, + {"prompt_description": t("Cache - Automatic cleanup")}, ] help_info = self.fill_help_info(choices) while True: @@ -411,6 +416,8 @@ class QemuCacheMenuMixin: self._cache_journaux() elif status == "11": self._cache_transfert() + elif status == "12": + self._cache_nettoyage_auto() else: print(t("Command not found !")) @@ -2075,6 +2082,139 @@ class QemuCacheMenuMixin: print(f" [{recu}] {etat}") print() + # ------------------------------------------------------------------ + # [12] Nettoyage automatique + # ------------------------------------------------------------------ + + def _cache_nettoyage_auto(self): + """Le nettoyage automatique : un minuteur quotidien, sans effet tant + que ses deux réglages sont vides. Le nettoyage reste donc manuel par + défaut, et le régler ici suffit à l'activer, sans réinstaller.""" + print(f"\n🧹 {t('Automatic cleanup of the cache')}") + choices = [ + {"prompt_description": t("Cleanup - Set the age limit")}, + {"prompt_description": t("Cleanup - Set the size ceiling")}, + {"prompt_description": t("Cleanup - Preview now (dry run)")}, + {"prompt_description": t("Cleanup - Run now")}, + ] + help_info = self.fill_help_info(choices) + while True: + self._cache_nettoyage_etat() + status = click.prompt(help_info) + print() + if status == "0": + return False + elif status == "1": + self._cache_nettoyage_regler( + "EL_PURGE_AGE", + t("Not served since (e.g. 90j), empty to disable"), + reglage_age_valide, + ) + elif status == "2": + self._cache_nettoyage_regler( + "EL_MAX_SIZE", + t("Size ceiling (e.g. 50G), empty to disable"), + reglage_taille_valide, + ) + elif status == "3": + self._cache_nettoyage_lancer(a_blanc=True) + elif status == "4": + self._cache_nettoyage_lancer(a_blanc=False) + else: + print(t("Command not found !")) + + def _cache_nettoyage_etat(self): + """Les deux réglages lus dans le fichier du service, et le minuteur.""" + age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF) + taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF) + print(f" · {t('Age limit:')} {age or t('disabled')}") + print(f" · {t('Size ceiling:')} {taille or t('disabled')}") + etat = self._cache_lire(f"systemctl is-enabled {CACHE_MINUTEUR_PURGE}") + etat = etat.strip().splitlines()[0] if etat.strip() else "" + if etat in ("enabled", "static"): + print(f" · {t('Daily timer:')} {etat}") + else: + # Un cache posé avant que l'installateur ne pose le minuteur. + print( + f" ✗ {t('Timer absent: reinstall the cache from entry 1.')}" + ) + print() + + def _cache_nettoyage_regler(self, cle, invite, valide): + """Demande une valeur, la vérifie, montre la commande et l'écrit après + confirmation. Une valeur vide désactive le réglage.""" + valeur = click.prompt(invite, default="", show_default=False).strip() + if valeur and not valide(valeur): + print(f" ✗ {t('Unreadable value:')} {valeur}\n") + return + cmd = commande_ecrire_reglage(cle, valeur) + print(f"{t('Will execute:')} {cmd}") + if not click.confirm(t("Write this setting?")): + return + self.execute.exec_command_live(cmd, source_erplibre=False) + + def _cache_nettoyage_lancer(self, a_blanc): + """À blanc : ce que chaque réglage effacerait, lu sous le compte du + service. Sinon : l'unité du minuteur, lancée tout de suite.""" + age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF) + taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF) + if not age and not taille: + print(f" {t('Nothing to do: both settings are empty.')}\n") + return + if not a_blanc: + cmd = ( + f"sudo systemctl start {CACHE_SERVICE_PURGE}" + f" && sudo journalctl -u {CACHE_SERVICE_PURGE} -n 20 --no-pager" + ) + print(f"{t('Will execute:')} {cmd}\n") + self.execute.exec_command_live(cmd, source_erplibre=False) + return + for option, valeur in ( + ("--purge-older-than", age), + ("--purge-to-size", taille), + ): + if not valeur: + continue + cmd = ( + f"sudo -u {cache_offline.SERVICE_USER} {CACHE_BIN}" + f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}" + f" {option} {shlex.quote(valeur)} --dry-run {option_langue()}" + ) + print(f"{t('Will execute:')} {cmd}\n") + self.execute.exec_command_live(cmd, source_erplibre=False) + + +def reglage_age_valide(valeur): + """Vrai si le binaire lira ce délai (voir LireDuree) : « 90j », « 12h », + « 1h30m ». Un délai nul est refusé : il effacerait tout chaque jour.""" + v = (valeur or "").strip().lower() + if not re.fullmatch(r"\d+(\.\d+)?[jd]|(\d+(\.\d+)?(ms|h|m|s))+", v): + return False + return any(c in "123456789" for c in v) + + +def reglage_taille_valide(valeur): + """Vrai si le binaire lira cette taille (voir LireTaille) : « 50G », + « 500M », « 1.5 T ». Une taille nulle est refusée.""" + v = (valeur or "").strip().lower().replace(" ", "") + m = re.fullmatch(r"(\d+(\.\d+)?)([kmgt])?(io|ib|b|o)?", v) + return bool(m) and float(m.group(1)) > 0 + + +def commande_ecrire_reglage(cle, valeur, fichier=CACHE_CONF): + """La commande qui pose « cle=valeur » dans le fichier de réglages du + service : la ligne est remplacée si elle existe, ajoutée sinon. Une valeur + vide la laisse présente et vide, ce qui désactive le réglage. La valeur + est déjà validée : elle ne porte ni « | » ni guillemet.""" + ligne = f"{cle}={valeur}" + f = shlex.quote(fichier) + script = ( + f"if grep -q {shlex.quote('^' + cle + '=')} {f}; then" + f" sed -i {shlex.quote('s|^' + cle + '=.*|' + ligne + '|')} {f};" + f" else echo {shlex.quote(ligne)} >> {f}; fi" + ) + return f"sudo sh -c {shlex.quote(script)}" + def exclusions_declarees(racine): """Les hôtes que le cache passe d'office en tunnel, lus dans SA source. diff --git a/script/todo/todo.py b/script/todo/todo.py index 3ee5bb8..fde32b5 100755 --- a/script/todo/todo.py +++ b/script/todo/todo.py @@ -615,6 +615,7 @@ class TODO( "_cache_exceptions": "Exceptions", "_cache_journaux": "Logs", "_cache_miroir_git": "Git mirrors", + "_cache_nettoyage_auto": "Automatic cleanup", "prompt_execute_qemu": "QEMU/KVM", "prompt_execute_proxmox": "Proxmox VE", "prompt_execute_vpn": "VPN", diff --git a/script/todo/todo_i18n.py b/script/todo/todo_i18n.py index 0b7599b..1c40330 100644 --- a/script/todo/todo_i18n.py +++ b/script/todo/todo_i18n.py @@ -6459,6 +6459,70 @@ TRANSLATIONS = { "fr": "Lire l'erreur ci-dessus, la corriger, et relancer l'entrée 1.", "en": "Read the error above, fix it, and run entry 1.", }, + "Cache - Automatic cleanup": { + "fr": "🧹 Cache - Nettoyage automatique", + "en": "🧹 Cache - Automatic cleanup", + }, + "Automatic cleanup of the cache": { + "fr": "Nettoyage automatique du cache", + "en": "Automatic cleanup of the cache", + }, + "Cleanup - Set the age limit": { + "fr": "⏳ Nettoyage - Régler l'âge limite", + "en": "⏳ Cleanup - Set the age limit", + }, + "Cleanup - Set the size ceiling": { + "fr": "📏 Nettoyage - Régler le plafond de taille", + "en": "📏 Cleanup - Set the size ceiling", + }, + "Cleanup - Preview now (dry run)": { + "fr": "🔍 Nettoyage - Voir ce qui partirait (à blanc)", + "en": "🔍 Cleanup - Preview now (dry run)", + }, + "Cleanup - Run now": { + "fr": "🧹 Nettoyage - Lancer maintenant", + "en": "🧹 Cleanup - Run now", + }, + "Age limit:": { + "fr": "Âge limite :", + "en": "Age limit:", + }, + "Size ceiling:": { + "fr": "Plafond de taille :", + "en": "Size ceiling:", + }, + "disabled": { + "fr": "désactivé", + "en": "disabled", + }, + "Daily timer:": { + "fr": "Minuteur quotidien :", + "en": "Daily timer:", + }, + "Timer absent: reinstall the cache from entry 1.": { + "fr": "Minuteur absent : réinstaller le cache par l'entrée 1.", + "en": "Timer absent: reinstall the cache from entry 1.", + }, + "Not served since (e.g. 90j), empty to disable": { + "fr": "Pas servi depuis (ex. 90j), vide pour désactiver", + "en": "Not served since (e.g. 90j), empty to disable", + }, + "Size ceiling (e.g. 50G), empty to disable": { + "fr": "Plafond de taille (ex. 50G), vide pour désactiver", + "en": "Size ceiling (e.g. 50G), empty to disable", + }, + "Unreadable value:": { + "fr": "Valeur illisible :", + "en": "Unreadable value:", + }, + "Write this setting?": { + "fr": "Écrire ce réglage ?", + "en": "Write this setting?", + }, + "Nothing to do: both settings are empty.": { + "fr": "Rien à faire : les deux réglages sont vides.", + "en": "Nothing to do: both settings are empty.", + }, "Cache - Copy it to another machine": { "fr": "📦 Cache - L'emporter sur une autre machine", "en": "📦 Cache - Copy it to another machine", @@ -13191,9 +13255,7 @@ TRANSLATIONS = { "Pas de terminal pour saisir le mot de passe du coffre :" " coffre non ouvert." ), - "en": ( - "No terminal to type the vault password: vault left closed." - ), + "en": ("No terminal to type the vault password: vault left closed."), }, "Some gateways demand an embedded browser (SAML): openconnect stops on" " \u00ab No SSO handler \u00bb and a helper is needed for the web step. It is" diff --git a/test/test_qemu_cache_form.py b/test/test_qemu_cache_form.py index bf31db7..65ce1f8 100644 --- a/test/test_qemu_cache_form.py +++ b/test/test_qemu_cache_form.py @@ -280,7 +280,9 @@ class TestAccordAvecLInstallateur(unittest.TestCase): def test_meme_nom_de_service(self): texte = INSTALLATEUR.read_text(encoding="utf-8") - m = re.search(r"UNIT=\"/etc/systemd/system/([^\"]+)\"", texte) + # En début de ligne : « PURGE_UNIT=… » contient aussi « UNIT= », et + # désigne l'unité du nettoyage, pas celle du service. + m = re.search(r"^UNIT=\"/etc/systemd/system/([^\"]+)\"", texte, re.M) self.assertIsNotNone(m, "le nom de l'unité est introuvable") self.assertEqual( QemuDeployMixin.QEMU_CACHE_SERVICE, diff --git a/test/test_qemu_cache_install.py b/test/test_qemu_cache_install.py index 9b42f62..1e2f470 100644 --- a/test/test_qemu_cache_install.py +++ b/test/test_qemu_cache_install.py @@ -199,6 +199,88 @@ class TestUniteSystemd(unittest.TestCase): self.assertNotIn("/root/", self.unite, "l'unité porte le compte root") +class TestNettoyageAutomatique(unittest.TestCase): + """Le minuteur de nettoyage, tel qu'il est ÉCRIT, et les réglages qu'une + réinstallation doit garder.""" + + @classmethod + def setUpClass(cls): + cls.tmp = tempfile.mkdtemp() + unit = Path(cls.tmp) / "purge.service" + timer = Path(cls.tmp) / "purge.timer" + prelude = ( + f'export EL_SRC_DIR="{SOURCES}"\n' + f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n' + "systemctl() { :; }\n" + f'PURGE_UNIT="{unit}"\n' + f'PURGE_TIMER="{timer}"\n' + "ecrire_purge\n" + ) + r = subprocess.run( + ["bash", "-c", prelude], capture_output=True, text=True + ) + if r.returncode != 0 or not unit.is_file() or not timer.is_file(): + raise AssertionError(f"minuteur non généré : {r.stdout}{r.stderr}") + cls.unite = unit.read_text(encoding="utf-8") + cls.minuteur = timer.read_text(encoding="utf-8") + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.tmp, ignore_errors=True) + + def test_rien_ne_part_tant_que_les_reglages_sont_vides(self): + """Par défaut, manuel : chaque geste est gardé par son réglage.""" + self.assertIn('[ -n "$$EL_PURGE_AGE" ]', self.unite) + self.assertIn('[ -n "$$EL_MAX_SIZE" ]', self.unite) + + def test_les_deux_nettoyages_sont_appeles(self): + self.assertIn('--purge-older-than "$$EL_PURGE_AGE"', self.unite) + self.assertIn('--purge-to-size "$$EL_MAX_SIZE"', self.unite) + + def test_les_variables_viennent_de_l_environnement_a_l_execution(self): + """« $$ » dans l'unité : systemd rend un « $ » au shell. Un « $ » seul + serait remplacé par systemd AVANT, à partir de rien.""" + self.assertIn("EnvironmentFile=", self.unite) + self.assertNotRegex(self.unite, r"(? Date: Wed, 16 Sep 2026 03:00:18 -0400 Subject: [PATCH 08/13] =?UTF-8?q?[FIX]=20d=C3=A9ploiement=20et=20mesure=20?= =?UTF-8?q?:=20openSUSE,=20sudo,=20console,=20m=C3=A9moire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Quatre défauts qu'une campagne complète sur les sept systèmes du catalogue a mis au jour. La famille zypper visait un faisceau de certificats qu'openSUSE n'écrit pas, et pip s'arrêtait là, en ligne comme hors ligne. sudo remet l'environnement à zéro : sans env_keep, une installation lancée par sudo rejette l'autorité du cache là où PAM ne relit pas /etc/environment. Le journal de console n'était gardé que pour la voie installateur, si bien qu'une image cloud bloquée avant ssh ne laissait rien à lire. Et une VM de 8 Gio restait allumée après sa mesure, épuisant la mémoire de l'hôte. --- EN --- Four faults a full campaign over the catalogue's seven systems brought out. The zypper family pointed at a certificate bundle openSUSE does not write, and pip stopped there, online as well as offline. sudo resets the environment: without env_keep, an install run by sudo rejects the cache authority where PAM does not reread /etc/environment. The console log was kept for the installer path only, so a cloud image stuck before ssh left nothing to read. And an 8 GiB VM stayed up after its measurement, exhausting the host's memory. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 24 ++++++++ script/qemu/deploy_qemu.py | 56 +++++++++++++---- script/qemu_cache/rules.go | 5 +- script/qemu_cache/rules_test.go | 2 +- test/test_qemu_cache_charge.py | 62 +++++++++++++++++++ test/test_qemu_cache_guest.py | 105 ++++++++++++++++++++++++++++++-- 6 files changed, 236 insertions(+), 18 deletions(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 119b7ff..25dfbb7 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -691,6 +691,27 @@ def dans_la_vm(adresse, commande, delai, journal, montrer=False): return executer(ssh, delai, journal, montrer=montrer) +def eteindre(nom, journal, dry_run=False): + """Éteint une VM dont la mesure est faite, sans l'effacer. + + Une VM de la charge « erplibre » tient 8 Gio de mémoire. Laissée allumée + pendant que la suivante s'installe, puis la troisième, elles s'additionnent, + et l'hôte manque de mémoire en pleine série. Éteinte, elle garde son disque + pour qui voudrait l'inspecter, et « --detruire » la retrouve par son nom. + Un échec d'extinction est dit et n'arrête rien : la mesure est déjà faite. + """ + cmd = f"sudo -n virsh -c qemu:///system destroy {shlex.quote(nom)}" + if dry_run: + dire(f" [à blanc] {cmd}", journal) + return + code, _ = executer(cmd, 120, journal) + if code: + dire( + f" ⚠ {nom} : extinction impossible ({code}), elle reste allumée", + journal, + ) + + def attendre_ssh(adresse, journal): for _essai in range(40): code, _ = dans_la_vm(adresse, "true", 30, None) @@ -1310,6 +1331,9 @@ def _boucle(args, rapport, journal, acces, decalage): } 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: diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index a70cd54..a482f64 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2507,10 +2507,14 @@ CACHE_TRUST = { "update-ca-trust", "/etc/pki/tls/certs/ca-bundle.crt", ), + # openSUSE n'écrit aucun « /etc/ssl/certs/ca-certificates.crt » : son + # faisceau est « ca-bundle.pem ». Une variable qui vise un fichier absent + # fait échouer pip sur « Could not find a suitable TLS CA certificate + # bundle », en ligne comme hors ligne. "zypper": ( "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", ), } @@ -2767,15 +2771,49 @@ def cache_commands(args: argparse.Namespace) -> list[str]: f"sh -c 'grep -q ^{var}= /etc/environment" f" || echo {var}={faisceau} >> /etc/environment'" ) + gardees = list(CACHE_ENV_VARS) if getattr(args, "offline", False): for var, valeur in OFFLINE_ENV_VARS: commandes.append( f"sh -c 'grep -q ^{var}= /etc/environment" f" || echo {var}={valeur} >> /etc/environment'" ) + gardees.append(var) + commandes.append(commande_sudoers(gardees)) return commandes +CACHE_SUDOERS = "/etc/sudoers.d/erplibre-cache" + + +def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str: + """La commande qui fait traverser « sudo » aux variables du cache. + + sudo remet l'environnement à zéro. Seul un module PAM qui relit + /etc/environment pour sudo y ramène les variables, et toutes les + distributions ne le configurent pas : sans lui, « sudo npm install -g » + rejette l'autorité du cache sur « self-signed certificate in certificate + chain », quand le même npm sans sudo l'accepte. « env_keep » les garde + partout, pourvu que la session appelante les porte — ce que + cache_env_reload lui assure. + + Le fichier est écrit sous un nom à point, que sudo ignore, vérifié par + « visudo -c », puis renommé : un fichier invalide dans sudoers.d rendrait + sudo inutilisable sur toute la machine. Un échec — visudo absent, pas de + répertoire sudoers.d — retire le temporaire et ne fait pas échouer la + commande. Une variable par ligne, sans guillemets : la commande passe + telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh. + """ + dossier, nom = fichier.rsplit("/", 1) + tmp = f"{dossier}/.{nom}" + return ( + f"sh -c 'for v in {' '.join(variables)};" + f" do echo Defaults env_keep += $v; done > {tmp}" + f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}" + f" || rm -f {tmp}'" + ) + + def cache_runcmd(args: argparse.Namespace) -> list[str]: """Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init.""" return [f" - {c}" for c in cache_commands(args)] @@ -4022,16 +4060,12 @@ def virt_install( "--network", args.network, "--console", - # Journal de console pour la voie installateur. Une console « pty » - # seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une - # VM que personne ne regarde, et il ne reste RIEN à lire ensuite — - # exactement « l'installation a échoué, pas de sortie pertinente ». - # Le fichier, lui, survit à l'arrêt du domaine. - ( - f"pty,target_type={console_target},log.file={console_log}" - if installer - else f"pty,target_type={console_target}" - ), + # Journal de console, pour toutes les voies. Une console « pty » seule + # ne garde rien : quand d-i échoue, ou qu'une image cloud reste bloquée + # avant son serveur ssh, la VM l'écrit à un écran que personne ne + # regarde, et il ne reste RIEN à lire ensuite. Le fichier, lui, survit + # à l'arrêt du domaine. + f"pty,target_type={console_target},log.file={console_log}", # Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à # virsh de piloter la VM SANS réseau (ex. étendre le FS invité après # un redimensionnement de disque). Inoffensif si l'agent est absent. diff --git a/script/qemu_cache/rules.go b/script/qemu_cache/rules.go index c4d3c03..f4ac7a4 100644 --- a/script/qemu_cache/rules.go +++ b/script/qemu_cache/rules.go @@ -151,9 +151,10 @@ func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) { "/etc/pki/tls/certs/ca-bundle.crt", true case "zypper": // openSUSE range ses ancres ailleurs que la famille RHEL, tout en - // employant la même commande que Debian. + // employant la même commande que Debian ; son faisceau n'est pas celui + // de Debian, et une variable qui vise un fichier absent casse pip. return "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", true + "/etc/ssl/ca-bundle.pem", true } return "", "", "", false } diff --git a/script/qemu_cache/rules_test.go b/script/qemu_cache/rules_test.go index 24a9eaa..73c1a9f 100644 --- a/script/qemu_cache/rules_test.go +++ b/script/qemu_cache/rules_test.go @@ -118,7 +118,7 @@ func TestCommandeDeConfiance(t *testing.T) { }, "zypper": { "/etc/pki/trust/anchors", "update-ca-certificates", - "/etc/ssl/certs/ca-certificates.crt", + "/etc/ssl/ca-bundle.pem", }, } for famille, attendu := range cas { diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index a68f18b..609d919 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -366,5 +366,67 @@ class TestPlusieursSystemes(unittest.TestCase): self.assertEqual(appels, [("ubuntu", DISTROS["ubuntu"][1])]) +class TestUneVmMesureeEstEteinte(unittest.TestCase): + """Deux VM de 8 Gio allumées ensemble, puis trois, épuisent la mémoire de + l'hôte en pleine série : chacune s'éteint dès que sa mesure est faite.""" + + def test_chaque_vm_s_eteint_apres_sa_propre_mesure(self): + import argparse + import tempfile + from unittest import mock + + ordre = [] + args = argparse.Namespace( + dry_run=False, + sans_cache=False, + hors_ligne=False, + distro="debian", + version="12", + charge="erplibre", + ) + with tempfile.TemporaryDirectory() as rep: + rapport = {"_fichier": str(Path(rep) / "r.json"), "vms": []} + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "noter_uuid" + ), mock.patch.object( + QC, "deployer", return_value="10.0.0.1" + ), mock.patch.object( + QC, "attendre_ssh", return_value=True + ), mock.patch.object( + QC, + "poser_les_paquets", + side_effect=lambda *a, **k: ordre.append("paquets") or True, + ), mock.patch.object( + QC, + "eteindre", + side_effect=lambda nom, *a, **k: ordre.append(nom), + ), mock.patch.object( + QC, "verdict", return_value=True + ): + QC._boucle(args, rapport, None, "", 0) + base = QC.base_des_noms(args) + self.assertEqual( + ordre, ["paquets", f"{base}-1", "paquets", f"{base}-2"] + ) + + def test_eteindre_n_efface_rien(self): + from unittest import mock + + with mock.patch.object(QC, "dire") as dit: + QC.eteindre("vm-essai", None, dry_run=True) + annonce = " ".join(str(a) for c in dit.call_args_list for a in c.args) + self.assertIn("destroy vm-essai", annonce) + self.assertNotIn("undefine", annonce) + + def test_a_blanc_rien_n_est_lance(self): + from unittest import mock + + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "executer" + ) as ex: + QC.eteindre("vm-essai", None, dry_run=True) + ex.assert_not_called() + + if __name__ == "__main__": unittest.main() diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index e1f44e8..a313016 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -32,13 +32,15 @@ sys.path.insert(0, str(RACINE)) from script.qemu.deploy_qemu import ( # noqa: E402 CACHE_CERT_NAME, CACHE_ENV_VARS, + CACHE_SUDOERS, CACHE_TRUST, OFFLINE_ENV_VARS, + cache_commands, cache_env_reload, cache_family, cache_files, - cache_commands, cache_runcmd, + commande_sudoers, langue_des_messages, ) @@ -134,6 +136,10 @@ class TestRuncmd(unittest.TestCase): self.tmp = tempfile.TemporaryDirectory() self.lignes = cache_runcmd(faux_args(Path(self.tmp.name), "arch")) + # Les écritures de variables, sans la confiance ni le sudoers. + self.variables = [ + l for l in self.lignes[1:] if "/etc/environment" in l + ] def tearDown(self): self.tmp.cleanup() @@ -159,13 +165,14 @@ class TestRuncmd(unittest.TestCase): def test_les_variables_vont_dans_etc_environment(self): """PAM lit /etc/environment pour TOUTE session ssh, non interactive comprise : c'est la seule voie qui atteint une commande distante.""" - for ligne in self.lignes[1:]: - self.assertIn("/etc/environment", ligne) + self.assertEqual(len(self.variables), len(CACHE_ENV_VARS)) + for var in CACHE_ENV_VARS: + self.assertTrue(any(f"{var}=" in l for l in self.variables), var) def test_ecriture_idempotente(self): """runcmd ne tourne qu'une fois par instance, mais un opérateur peut rejouer la commande : elle ne doit pas empiler les doublons.""" - for ligne in self.lignes[1:]: + for ligne in self.variables: self.assertIn("grep -q", ligne) def test_aucune_commande_ne_peut_faire_echouer_le_boot(self): @@ -258,6 +265,96 @@ class TestLeHorsLigneCoupeLAuditNpm(unittest.TestCase): self.assertIn(var, cache_env_reload()) +class TestLesVariablesTraversentSudo(unittest.TestCase): + """sudo remet l'environnement à zéro : sans « env_keep », une installation + lancée par « sudo npm » rejette l'autorité du cache là où PAM ne relit pas + /etc/environment pour sudo.""" + + def setUp(self): + import tempfile + + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + + def commande(self, offline=False, distro="debian"): + args = faux_args(Path(self.tmp.name), distro) + args.offline = offline + lignes = [c for c in cache_commands(args) if "env_keep" in c] + self.assertEqual(len(lignes), 1, "une seule écriture du sudoers") + return lignes[0] + + def test_chaque_variable_du_cache_est_gardee(self): + commande = self.commande() + for var in CACHE_ENV_VARS: + self.assertIn(var, commande) + self.assertNotIn("NPM_CONFIG_AUDIT", commande) + + def test_hors_ligne_l_audit_coupe_traverse_aussi(self): + for var, _ in OFFLINE_ENV_VARS: + self.assertIn(var, self.commande(offline=True)) + + def test_vient_apres_les_variables(self): + """La confiance reste la première commande ; le sudoers suit.""" + args = faux_args(Path(self.tmp.name), "debian") + commandes = cache_commands(args) + self.assertIn("env_keep", commandes[-1]) + + def test_le_fichier_ecrit_est_celui_que_sudo_lit(self): + """sudo ignore un nom de sudoers.d qui porte un point : c'est ce qui + rend le temporaire inoffensif, et interdit ce point au nom final.""" + nom_final = CACHE_SUDOERS.rsplit("/", 1)[1] + self.assertNotIn(".", nom_final) + self.assertTrue(CACHE_SUDOERS.startswith("/etc/sudoers.d/")) + + def test_le_fichier_est_verifie_avant_d_etre_pose(self): + commande = commande_sudoers(["A"], "/etc/sudoers.d/essai") + self.assertLess( + commande.index("visudo -cf"), + commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"), + ) + self.assertIn("chmod 0440", commande) + self.assertTrue( + commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai") + ) + + def test_le_contenu_ecrit_est_une_ligne_par_variable(self): + """Joué dans un vrai shell, visudo remplacé : c'est le fichier produit + qui compte, et son absence quand la vérification échoue.""" + import subprocess + import tempfile + + with tempfile.TemporaryDirectory() as rep: + bin_ = Path(rep) / "bin" + bin_.mkdir() + dossier = Path(rep) / "sudoers.d" + dossier.mkdir() + for verdict, attendu in (("0", True), ("1", False)): + faux = bin_ / "visudo" + faux.write_text(f"#!/bin/sh\nexit {verdict}\n") + faux.chmod(0o755) + fichier = dossier / "erplibre-cache" + commande = commande_sudoers( + ["PIP_CERT", "NODE_EXTRA_CA_CERTS"], str(fichier) + ) + res = subprocess.run( + ["sh", "-c", commande], + capture_output=True, + text=True, + env={"PATH": f"{bin_}:/usr/bin:/bin"}, + ) + with self.subTest(visudo=verdict): + self.assertEqual(res.returncode, 0, res.stderr) + self.assertEqual(fichier.exists(), attendu) + self.assertFalse((dossier / ".erplibre-cache").exists()) + if attendu: + self.assertEqual( + fichier.read_text(), + "Defaults env_keep += PIP_CERT\n" + "Defaults env_keep += NODE_EXTRA_CA_CERTS\n", + ) + fichier.unlink() + + class TestLaLangueDesMessagesDuCache(unittest.TestCase): def test_l_option_du_deploiement_l_emporte(self): self.assertEqual( From 6a4bd546fc9fe4125d8416438e0745526ceaa935 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:18 -0400 Subject: [PATCH 09/13] =?UTF-8?q?[FIX]=20cache=20qemu=20:=20m=C3=A9tadonn?= =?UTF-8?q?=C3=A9es=20RPM=20fig=C3=A9es,=20plages=20prises=20en=20entier?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An RPM repository names each metadata file after the hash of its content; only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of megabytes was taken whole on every install, about 110 MB per VM on a RHEL family system. dnf fetches .zck metadata by ranges, and a range is never stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now immutable, and a range on a missing immutable file makes the cache fetch the whole file in the background, once per key. Checked: a range served offline from disk, each rule mutation-tested, race detector clean. --- FR --- Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ; seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages, et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé absent fait prendre au cache le fichier entier en arrière-plan, une fois par clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par mutation, détecteur de courses muet. Assisted-by: Claude Opus 5 --- script/qemu_cache/classify.go | 18 +++- script/qemu_cache/classify_test.go | 30 ++++++ script/qemu_cache/completion_test.go | 139 +++++++++++++++++++++++++++ script/qemu_cache/main.go | 2 +- script/qemu_cache/proxy.go | 94 ++++++++++++++++++ script/qemu_cache/proxy_test.go | 26 +++-- 6 files changed, 291 insertions(+), 18 deletions(-) create mode 100644 script/qemu_cache/completion_test.go diff --git a/script/qemu_cache/classify.go b/script/qemu_cache/classify.go index 5132a93..5592338 100644 --- a/script/qemu_cache/classify.go +++ b/script/qemu_cache/classify.go @@ -98,6 +98,20 @@ var volatileSuffixes = []string{ var parEmpreinte = regexp.MustCompile( `/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`) +// parEmpreinteDeDepot reconnaît une métadonnée de dépôt RPM nommée par +// l'empreinte de son contenu : « …/repodata/-primary.xml.zck ». +// Seul « repomd.xml » y est volatile : il désigne la version COURANTE de +// chaque métadonnée par ce nom, si bien qu'un contenu nouveau porte un nom +// nouveau. Le fichier nommé est aussi figé qu'un paquet. +// +// Volatile, il est repris en entier à chaque installation — un « primary » +// pèse des dizaines de mégaoctets par dépôt. Et dnf télécharge un « .zck » par +// plages, qu'aucun volatile ne garde : amont coupé, la VM suivante n'avait +// rien. Figé, il sort du disque, et une plage y déclenche la prise du fichier +// entier (voir completer). +var parEmpreinteDeDepot = regexp.MustCompile( + `/repodata/[0-9a-fA-F]{32,128}-[^/]+$`) + // dernierePublication reconnaît « ///releases/latest/ // download/ » : un POINTEUR vers la dernière version publiée, dont // la cible change à chaque publication. @@ -165,7 +179,7 @@ func Classify(u *url.URL) Class { if dernierePublication.MatchString(u.Path) { return ClassVolatile } - if parEmpreinte.MatchString(u.Path) { + if parEmpreinte.MatchString(u.Path) || parEmpreinteDeDepot.MatchString(u.Path) { return ClassImmutable } for _, n := range volatileNames { @@ -207,7 +221,7 @@ func PortableParChemin(u *url.URL) bool { // POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une // somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne // suffirait donc pas à le rendre portable. - if parEmpreinte.MatchString(u.Path) { + if parEmpreinte.MatchString(u.Path) || parEmpreinteDeDepot.MatchString(u.Path) { return true } name := strings.ToLower(path.Base(u.Path)) diff --git a/script/qemu_cache/classify_test.go b/script/qemu_cache/classify_test.go index b1a1202..7e359c3 100644 --- a/script/qemu_cache/classify_test.go +++ b/script/qemu_cache/classify_test.go @@ -209,6 +209,36 @@ func TestLesIndexParEmpreinteSontImmuables(t *testing.T) { } } +// Une métadonnée RPM nommée par sa somme est figée, quelle que soit sa +// compression ; « repomd.xml », qui la désigne, reste volatile, et un nom sans +// somme en tête garde la règle de son suffixe. +func TestLesMetadonneesRPMParEmpreinteSontImmuables(t *testing.T) { + somme := strings.Repeat("0123456789abcdef", 4) + for _, nom := range []string{ + somme + "-primary.xml.zck", somme + "-primary.xml.gz", + somme + "-updateinfo.xml.zst", somme + "-comps-BaseOS.x86_64.xml", + } { + u, _ := url.Parse("https://miroir.example/fedora/linux/updates/42/Everything/x86_64/repodata/" + nom) + if got := Classify(u); got != ClassImmutable { + t.Errorf("%s classé « %s », attendu « immutable »", nom, got) + } + if !PortableParChemin(u) { + t.Errorf("%s n'est pas jugé portable", nom) + } + } + for _, brut := range []string{ + "https://miroir.example/fedora/repodata/repomd.xml", + "https://miroir.example/fedora/repodata/primary.xml.gz", + "https://miroir.example/fedora/repodata/pas-une-somme-primary.xml.zck", + "https://miroir.example/fedora/ailleurs/" + somme + "-primary.xml.zck", + } { + u, _ := url.Parse(brut) + if got := Classify(u); got != ClassVolatile { + t.Errorf("%s classé « %s », attendu « volatile »", brut, got) + } + } +} + // Le même index par empreinte, servi par deux miroirs. Sans clé portable, // changer de miroir vide le cache de ses index : une installation hors ligne // échoue alors sur des octets que le magasin détient pourtant, et le message diff --git a/script/qemu_cache/completion_test.go b/script/qemu_cache/completion_test.go new file mode 100644 index 0000000..b16db6e --- /dev/null +++ b/script/qemu_cache/completion_test.go @@ -0,0 +1,139 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "bytes" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" +) + +// amontAPlages sert un corps fixe et honore « Range » comme un miroir : 206 +// et le seul fragment demandé. Il compte séparément les plages et les corps +// entiers envoyés. +type amontAPlages struct { + srv *httptest.Server + plages int64 + entiers int64 +} + +func nouvelAmontAPlages(t *testing.T, corps string) *amontAPlages { + t.Helper() + a := &amontAPlages{} + a.srv = httptest.NewServer(http.HandlerFunc( + func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Range") != "" { + atomic.AddInt64(&a.plages, 1) + } else { + atomic.AddInt64(&a.entiers, 1) + } + http.ServeContent(w, r, "", time.Time{}, bytes.NewReader([]byte(corps))) + })) + t.Cleanup(a.srv.Close) + return a +} + +func (a *amontAPlages) hote() string { + return strings.TrimPrefix(a.srv.URL, "http://") +} + +func demandePlage(t *testing.T, p *Proxy, hote, chemin, plage string) *httptest.ResponseRecorder { + t.Helper() + r := httptest.NewRequest("GET", chemin, nil) + r.Host = hote + r.Header.Set("Range", plage) + w := httptest.NewRecorder() + p.serve(w, r, "http") + return w +} + +const metadonneeZck = "/fedora/updates/repodata/" + + "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" + + "-primary.xml.zck" + +// Une plage sur une métadonnée figée absente fait prendre le fichier entier, +// une fois ; les plages suivantes sortent du disque sans rien demander à +// l'amont. +func TestUnePlageFaitGarderLeFichierEntier(t *testing.T) { + a := nouvelAmontAPlages(t, "en-tete|morceau-1|morceau-2") + p := proxyDeTest(t) + + w := demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-6") + if w.Code != http.StatusPartialContent || w.Body.String() != "en-tete" { + t.Fatalf("première plage : %d %q", w.Code, w.Body.String()) + } + p.attendreCompletions() + + w = demandePlage(t, p, a.hote(), metadonneeZck, "bytes=8-16") + if w.Code != http.StatusPartialContent || w.Body.String() != "morceau-1" { + t.Errorf("seconde plage : %d %q", w.Code, w.Body.String()) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit { + t.Errorf("seconde plage servie « %s », attendu « %s »", got, OutcomeHit) + } + if n := atomic.LoadInt64(&a.entiers); n != 1 { + t.Errorf("%d corps entiers pris à l'amont, attendu 1", n) + } + if n := atomic.LoadInt64(&a.plages); n != 1 { + t.Errorf("%d plages envoyées à l'amont, attendu 1", n) + } +} + +// Amont coupé, la plage d'une VM suivante sort du disque : c'est ce qui manquait +// à une installation hors ligne. +func TestHorsLigneLaPlageSortDuDisque(t *testing.T) { + a := nouvelAmontAPlages(t, "en-tete|morceau-1|morceau-2") + p := proxyDeTest(t) + hote := a.hote() + demandePlage(t, p, hote, metadonneeZck, "bytes=0-6") + p.attendreCompletions() + a.srv.Close() + + w := demandePlage(t, p, hote, metadonneeZck, "bytes=18-26") + if w.Code != http.StatusPartialContent || w.Body.String() != "morceau-2" { + t.Errorf("hors ligne : %d %q", w.Code, w.Body.String()) + } +} + +// Un index volatile demandé par plage ne déclenche aucune prise : il n'est +// jamais servi du disque tant que l'amont répond, le garder n'épargnerait rien. +func TestUnePlageSurUnIndexNePrendRien(t *testing.T) { + a := nouvelAmontAPlages(t, "index de dépôt") + p := proxyDeTest(t) + + demandePlage(t, p, a.hote(), "/fedora/repodata/repomd.xml", "bytes=0-4") + p.attendreCompletions() + if n := atomic.LoadInt64(&a.entiers); n != 0 { + t.Errorf("%d corps entiers pris pour un index volatile, attendu 0", n) + } +} + +// Plusieurs plages simultanées sur le même fichier ne prennent le corps entier +// qu'une fois. +func TestDesPlagesSimultaneesNePrennentQuUneFois(t *testing.T) { + a := nouvelAmontAPlages(t, strings.Repeat("x", 1<<16)) + p := proxyDeTest(t) + u, _ := url.Parse("http://" + a.hote() + metadonneeZck) + key := CleDe("GET", u) + p.completions.Store(key, true) // une prise est déjà en cours + + for i := 0; i < 5; i++ { + demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-9") + } + p.attendreCompletions() + if n := atomic.LoadInt64(&a.entiers); n != 0 { + t.Errorf("%d prises lancées alors qu'une était en cours, attendu 0", n) + } + p.completions.Delete(key) + demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-9") + p.attendreCompletions() + if n := atomic.LoadInt64(&a.entiers); n != 1 { + t.Errorf("%d prises après la fin de la première, attendu 1", n) + } +} diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index ab495ce..c623909 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.9" +const version = "0.2.10" func main() { var ( diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go index 52cf813..fbe771a 100644 --- a/script/qemu_cache/proxy.go +++ b/script/qemu_cache/proxy.go @@ -4,8 +4,10 @@ package main import ( + "context" "encoding/json" "fmt" + "io" "log" "net" "net/http" @@ -134,6 +136,11 @@ type Proxy struct { // vise l'un d'eux sur une adresse de cette machine est une boucle. Vide, // rien n'est refusé. Ecoutes []int + + // completions tient les clés dont le corps entier est en cours de prise + // (voir completer) ; enCours les compte, pour qui doit les attendre. + completions sync.Map + enCours sync.WaitGroup } // NewProxy monte le client amont. Aucun délai GLOBAL n'est posé : une image @@ -244,6 +251,11 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { if p.serveFromStore(w, r, u, key, class, OutcomeHit) { return } + // La plage passe à l'amont et ne se garde pas ; le fichier entier est + // pris à part, pour que la plage suivante sorte du disque. + if partial && r.Method == "GET" { + p.completer(r, u, key, class) + } } // La négociation git n'est pas relayée quand un miroir peut la servir : @@ -540,6 +552,88 @@ func varieSurAccept(h http.Header) bool { return false } +// delaiCompletion borne la prise d'un corps entier en arrière-plan. Aucun +// client n'attend cette prise : sans borne, un amont qui cesse d'envoyer au +// milieu du corps la garderait ouverte pour toujours. +const delaiCompletion = time.Hour + +// completer prend à l'amont, en arrière-plan, le corps ENTIER d'un fichier +// figé dont un client n'a demandé qu'une plage, et le garde sous sa clé. +// +// dnf télécharge ses métadonnées zchunk par plages, et pacman reprend de même +// un paquet interrompu : une plage ne se garde pas, si bien que sans cette +// prise ces fichiers repartiraient à l'amont à chaque VM — et, amont coupé, ne +// seraient pas là. Une fois gardé, le fichier sert toute plage depuis le +// disque. +// +// Une seule prise par clé à la fois, et la requête du client ne l'attend pas. +// Elle part sans plage ni condition, avec le seul agent de l'invité, et un +// amont connu muet n'est pas recomposé. Une prise manquée se retente à la +// plage suivante. +func (p *Proxy) completer(r *http.Request, u *url.URL, key string, class Class) { + if _, deja := p.completions.LoadOrStore(key, true); deja { + return + } + entete := http.Header{} + if agent := r.Header.Get("User-Agent"); agent != "" { + entete.Set("User-Agent", agent) + } + p.enCours.Add(1) + go func() { + defer p.enCours.Done() + defer p.completions.Delete(key) + p.prendreEntier(u, key, class, entete) + }() +} + +// attendreCompletions rend la main quand plus aucune prise n'est en cours. +func (p *Proxy) attendreCompletions() { + p.enCours.Wait() +} + +// prendreEntier fait la prise elle-même : un 200 entier, publié sous la clé +// par le même écrivain que le chemin du client, taille annoncée vérifiée. Tout +// autre statut, ou une clé devenue détenue entre-temps, ne publie rien. +func (p *Proxy) prendreEntier(u *url.URL, key string, class Class, entete http.Header) { + ctx, annuler := context.WithTimeout(context.Background(), delaiCompletion) + defer annuler() + req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil) + if err != nil { + return + } + req.Header = entete + resp, err := p.fetch(req, u, true) + if err != nil { + return + } + resp = p.suivreRedirections(req, u, resp) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK || p.Store.Detient(key) { + return + } + cw, err := p.Store.NewWriter(key, Meta{ + URL: u.String(), Method: "GET", Status: resp.StatusCode, + Header: resp.Header.Clone(), Class: class.String(), + }) + if err != nil { + log.Printf(T("cache : écriture impossible pour %s : %v"), u, err) + return + } + n, err := io.Copy(cw, resp.Body) + if err != nil { + cw.Abort() + return + } + if err := cw.Commit(resp.ContentLength); err != nil { + log.Printf(T("cache : %s non gardé : %v"), u, err) + return + } + p.record(accessLine{ + Method: "GET", URL: u.String(), Class: class.String(), + Outcome: OutcomeStored, Status: resp.StatusCode, Bytes: n, Upstream: true, + }) +} + // etagGarde rend l'ETag du corps gardé sous la clé, ou "" : rien de gardé, // un statut seul, ou une réponse d'amont qui n'en portait pas. func (p *Proxy) etagGarde(key string) string { diff --git a/script/qemu_cache/proxy_test.go b/script/qemu_cache/proxy_test.go index 8de0aa8..ca2be28 100644 --- a/script/qemu_cache/proxy_test.go +++ b/script/qemu_cache/proxy_test.go @@ -161,26 +161,22 @@ func TestDefautHorsLigneNommeLeFichier(t *testing.T) { } } -// Une requête partielle ne remplit pas le cache : un fragment ne sert à rien -// à la demande suivante, et le garder comme un corps entier servirait un -// paquet tronqué. +// Une requête partielle ne garde jamais SON fragment : le garder comme un +// corps entier servirait un paquet tronqué. Ce qui entre au cache est le corps +// entier, pris à part (voir completion_test.go). func TestRequetePartielleNonGardee(t *testing.T) { - a := nouvelAmont(t, "0123456789") + a := nouvelAmontAPlages(t, "0123456789") p := proxyDeTest(t) chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst" - r := httptest.NewRequest("GET", chemin, nil) - r.Host = a.hote() - r.Header.Set("Range", "bytes=0-4") - p.serve(httptest.NewRecorder(), r, "http") - - // Une demande entière ensuite doit ressortir à l'amont. - w := demande(t, p, a.hote(), chemin) - if got := w.Header().Get("X-ERPLibre-Cache"); got == OutcomeHit { - t.Error("un fragment est entré au cache et a été servi comme un corps entier") + if w := demandePlage(t, p, a.hote(), chemin, "bytes=0-4"); w.Body.String() != "01234" { + t.Fatalf("plage : %q", w.Body.String()) } - if n := a.appels(); n != 2 { - t.Errorf("l'amont a reçu %d requêtes, attendu 2", n) + p.attendreCompletions() + + w := demande(t, p, a.hote(), chemin) + if w.Body.String() != "0123456789" { + t.Errorf("demande entière servie %q : un fragment a été gardé", w.Body.String()) } } From 433fe753c3044b9b0ec3fe06e35a3b71d39dce9a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH 10/13] =?UTF-8?q?[FIX]=20hors=20ligne=20:=20HEAD=20servi,?= =?UTF-8?q?=20attente=20NTP=20lev=C3=A9e,=20=C3=A9tape=20nomm=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Trois défauts qui empêchaient une VM hors ligne d'aboutir. zypper vérifie chaque dépôt par un HEAD, jamais gardé sous une clé portable : le dépôt était déclaré invalide alors que le magasin tenait son index, et un HEAD reçoit désormais les en-têtes du corps du GET gardé. Une image qui attend la synchronisation NTP avant son étape finale ne démarrait jamais son serveur ssh, faute de serveur de temps joignable ; l'attente est levée sur une VM déployée hors ligne. Enfin, une troisième VM en échec nomme l'étape qui l'a arrêtée, là où le résumé d'une série n'affichait qu'un verdict nu. --- EN --- Three faults that kept an offline VM from finishing. zypper checks each repository with a HEAD, never stored under a portable key: the repository was declared invalid while the store held its index, and a HEAD now gets the headers of the stored GET body. An image waiting for NTP synchronisation before its final stage never started its ssh server, no time server being reachable; that wait is lifted on a VM deployed offline. Last, a failing third VM names the step that stopped it, where a series summary showed a bare verdict. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 14 ++++++- script/qemu/deploy_qemu.py | 14 +++++++ script/qemu_cache/detient.go | 15 +++++++ script/qemu_cache/main.go | 2 +- script/qemu_cache/proxy.go | 13 ++++++ script/qemu_cache/statut_test.go | 41 +++++++++++++++++++ test/test_qemu_cache_charge.py | 68 ++++++++++++++++++++++++++++++++ test/test_qemu_cache_guest.py | 34 ++++++++++++++++ 8 files changed, 198 insertions(+), 3 deletions(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 25dfbb7..e2d5259 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -960,13 +960,17 @@ def contre_epreuve( C'est ce qui distingue un cache d'une simple accélération : sans réseau, le déploiement tient encore sur l'index stocké. + + Un échec note l'étape dans rapport["etape_en_echec"], comme la boucle le + fait pour les deux premières VM : le résumé d'une série la nomme. """ dire("", journal) dire(" ── Contre-épreuve : amont coupé ──", journal) + nom = f"{base}-3" if not couper_lamont(journal, dry_run): + rapport["etape_en_echec"] = f"{nom} : coupure de l'amont" return False try: - nom = f"{base}-3" rapport["vms"].append(nom) ecrire_rapport(rapport) adresse = deployer( @@ -979,11 +983,15 @@ def contre_epreuve( hors_ligne=True, ) if not adresse: + rapport["etape_en_echec"] = f"{nom} : déploiement" return False noter_uuid(rapport, nom, dry_run) if not dry_run and not attendre_ssh(adresse, journal): + rapport["etape_en_echec"] = f"{nom} : ssh" return False ok = poser_les_paquets(adresse, journal, dry_run, distro, charge) + if not ok: + rapport["etape_en_echec"] = f"{nom} : paquets" if ok: dire( " ✓ la troisième VM s'est bâtie sans que le cache joigne" @@ -1364,7 +1372,9 @@ def _boucle(args, rapport, journal, acces, decalage): and ok ) - return _clore(args, rapport, journal, ok) + return _clore( + args, rapport, journal, ok, rapport.pop("etape_en_echec", "") + ) def _clore(args, rapport, journal, ok, etape=""): diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index a482f64..2a5311d 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2535,6 +2535,18 @@ CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") # d'accord avec le Go compare. OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),) +# Ce qu'une VM déployée l'amont coupé exécute tôt au démarrage. Une image qui +# active systemd-time-wait-sync — celle d'Arch — retient time-sync.target +# jusqu'à la première synchronisation NTP, et « cloud-final » est ordonné +# après : sans serveur de temps joignable, l'étape finale ne démarre jamais, ni +# les clés d'hôte ssh qu'elle génère, et la VM reste sans ssh. L'arrêter lève +# l'attente ; une image qui ne l'active pas n'en voit aucun effet. « bootcmd » +# tourne à l'étape réseau de cloud-init, avant l'étape finale. +OFFLINE_BOOTCMD = [ + "bootcmd:", + " - systemctl stop --no-block systemd-time-wait-sync.service || true", +] + CACHE_CERT_NAME = "erplibre-cache.crt" @@ -2960,6 +2972,8 @@ def build_cloud_config( # Active et démarre SSH quel que soit le nom du service (ssh sur # Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter # sans SSH accessible. + if getattr(args, "offline", False): + lines += OFFLINE_BOOTCMD lines += ["runcmd:"] # En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à # jour un invité rejette le certificat que le cache présente. diff --git a/script/qemu_cache/detient.go b/script/qemu_cache/detient.go index d2c9209..1855430 100644 --- a/script/qemu_cache/detient.go +++ b/script/qemu_cache/detient.go @@ -97,6 +97,7 @@ func (s *Store) Detenir(methode, brut string) Detention { return d } if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) { + s.corpsDuGET(&d, u) return d } d.Verdict = VerdictAbsent @@ -106,10 +107,24 @@ func (s *Store) Detenir(methode, brut string) Detention { } if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() { d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt + return d + } + if methode == "HEAD" { + s.corpsDuGET(&d, u) } return d } +// corpsDuGET marque « garde » un HEAD dont le GET de la même URL a son corps +// en réserve : hors ligne, le service répond au HEAD avec les en-têtes de ce +// corps, APRÈS le statut gardé du HEAD lui-même. Sans corps, le verdict reste +// celui que l'appelant a posé. +func (s *Store) corpsDuGET(d *Detention, u *url.URL) { + if m, ok := s.lirePresent(CleDe("GET", u)); ok && !m.StatutSeul() { + d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt + } +} + // lirePresent rend le méta d'une clé quand l'objet est complet : méta lisible // et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié. func (s *Store) lirePresent(key string) (*Meta, bool) { diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index c623909..cc51234 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.10" +const version = "0.2.11" func main() { var ( diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go index fbe771a..7283cde 100644 --- a/script/qemu_cache/proxy.go +++ b/script/qemu_cache/proxy.go @@ -423,6 +423,19 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) { return } + // Un HEAD sans statut gardé demande les en-têtes que rendrait le GET : + // le corps gardé pour ce GET les porte. zypper vérifie ainsi chaque + // dépôt avant de le lire, et un HEAD ne se garde jamais sous une clé + // portable ; sans ce repli, le dépôt dont le magasin tient l'index est + // déclaré invalide hors ligne. ServeContent n'écrit aucun corps pour + // un HEAD. + if cacheable && r.Method == http.MethodHead { + cleCorps := CleDe(http.MethodGet, u) + if !p.indexIncoherent(u, cleCorps) && + p.serveFromStore(w, r, u, cleCorps, class, OutcomeStale) { + return + } + } p.offlineMiss( w, u, class, r.Method, clientDe(r.RemoteAddr), upErr, ) diff --git a/script/qemu_cache/statut_test.go b/script/qemu_cache/statut_test.go index d8c4e5f..23dd92e 100644 --- a/script/qemu_cache/statut_test.go +++ b/script/qemu_cache/statut_test.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -397,6 +398,46 @@ func TestLImmuableNeGardePasSonRefus(t *testing.T) { } } +// Hors ligne, un HEAD sur un index dont le GET a son corps en réserve reçoit +// les en-têtes de ce corps, sans corps : c'est ainsi que zypper juge un dépôt +// valide avant de le lire. Sous une clé portable, aucun statut de HEAD ne se +// garde, et sans ce repli le dépôt entier était déclaré invalide. +func TestHorsLigneUnHeadSortDuCorpsDuGet(t *testing.T) { + const corps = "index" + const chemin = "/distribution/leap/16.0/repo/oss/x86_64/repodata/repomd.xml" + a := nouvelAmontScripte(t, servir(corps)) + p := proxyDeTest(t) + u, _ := url.Parse("http://" + a.hote() + chemin) + if Classify(u) != ClassVolatile || !PortableParChemin(u) { + t.Fatalf("%s n'est plus un index volatil portable", chemin) + } + joue(t, p, "GET", a.hote(), chemin) + joue(t, p, "HEAD", a.hote(), chemin) + base := "http://" + a.hote() + + hote := a.couper() + w := joue(t, p, "HEAD", hote, chemin) + if w.Code != http.StatusOK { + t.Fatalf("HEAD hors ligne : code %d, attendu 200", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("HEAD hors ligne avec un corps de %d octets", w.Body.Len()) + } + if got := w.Header().Get("Content-Length"); got != strconv.Itoa(len(corps)) { + t.Errorf("longueur %q, attendu celle du corps gardé (%d)", got, len(corps)) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale) + } + if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictGarde { + t.Errorf("--detient dit %q pour un HEAD que le service sert", got) + } + // Sans GET gardé, rien ne change : le HEAD reste un 504. + if w := joue(t, p, "HEAD", hote, "/distribution/autre/repodata/repomd.xml"); w.Code != http.StatusGatewayTimeout { + t.Errorf("HEAD sans corps gardé : code %d, attendu 504", w.Code) + } +} + // Un HEAD garde son statut : un installateur lit la version publiée dans le // « Location » d'un HEAD, et n'a besoin de rien d'autre. // diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 609d919..4e09504 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -250,6 +250,74 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase): self.assertEqual(len(ecrit["vms"]), 1) +class TestLaContreEpreuveNommeSonEtape(unittest.TestCase): + """Une troisième VM en échec doit nommer son étape dans le rapport, comme + les deux premières : le résumé d'une série n'a sinon qu'un « échec ».""" + + def boucler(self, echoue): + import argparse + import json + import tempfile + from unittest import mock + + args = argparse.Namespace( + dry_run=False, + sans_cache=False, + hors_ligne=True, + distro="debian", + version="12", + charge="minimum", + ) + # Les deux premières VM réussissent ; la troisième échoue à l'appel + # nommé, c'est-à-dire au troisième appel de cette fonction. + appels = {"n": 0} + + def selon(nom, reussi): + def f(*a, **k): + appels["n"] += nom == echoue + if nom == echoue and appels["n"] == 3: + return "" if nom == "deployer" else False + return reussi + + return f + + with tempfile.TemporaryDirectory() as rep: + fichier = str(Path(rep) / "rapport.json") + rapport = {"_fichier": fichier, "vms": []} + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "noter_uuid" + ), mock.patch.object(QC, "eteindre"), mock.patch.object( + QC, "verdict", return_value=True + ), mock.patch.object( + QC, "couper_lamont", return_value=True + ), mock.patch.object( + QC, "rebrancher_lamont", create=True + ), mock.patch.object( + QC, "deployer", side_effect=selon("deployer", "10.0.0.1") + ), mock.patch.object( + QC, "attendre_ssh", side_effect=selon("attendre_ssh", True) + ), mock.patch.object( + QC, + "poser_les_paquets", + side_effect=selon("poser_les_paquets", True), + ): + code = QC._boucle(args, rapport, None, "", 0) + with open(fichier, encoding="utf-8") as fh: + return code, json.load(fh) + + def test_chaque_etape_de_la_troisieme_vm_est_nommee(self): + for etape, mot in ( + ("deployer", "-3 : déploiement"), + ("attendre_ssh", "-3 : ssh"), + ("poser_les_paquets", "-3 : paquets"), + ): + with self.subTest(etape=etape): + code, ecrit = self.boucler(etape) + self.assertEqual(code, 1) + self.assertEqual(ecrit.get("verdict"), "échec") + self.assertIn(mot, ecrit.get("etape_en_echec", "")) + + class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase): """Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long doit le lui demander, comme le formulaire, sans quoi il mesure une VM que diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index a313016..257099d 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -26,6 +26,8 @@ import sys import unittest from pathlib import Path +import yaml + RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE)) @@ -34,7 +36,10 @@ from script.qemu.deploy_qemu import ( # noqa: E402 CACHE_ENV_VARS, CACHE_SUDOERS, CACHE_TRUST, + OFFLINE_BOOTCMD, OFFLINE_ENV_VARS, + build_cloud_config, + build_parser, cache_commands, cache_env_reload, cache_family, @@ -355,6 +360,35 @@ class TestLesVariablesTraversentSudo(unittest.TestCase): fichier.unlink() +class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase): + """Une image qui attend la synchronisation NTP avant « cloud-final » ne + démarre jamais son étape finale sans serveur de temps : ni clés d'hôte, ni + ssh. Une VM déployée hors ligne lève cette attente dès bootcmd.""" + + def config(self, *extra): + args = build_parser().parse_args( + ["--distro", "arch", "--hostname", "vm", *extra] + ) + return yaml.safe_load(build_cloud_config(args, None, [])) + + def test_une_vm_hors_ligne_arrete_l_attente(self): + cmd = self.config("--offline").get("bootcmd") or [] + self.assertTrue( + any("systemd-time-wait-sync" in c and "stop" in c for c in cmd), + cmd, + ) + + def test_la_commande_ne_bloque_ni_n_echoue(self): + """--no-block : bootcmd ne doit pas attendre un travail qui attend + lui-même le réseau ; « || true » : une image sans l'unité continue.""" + for ligne in OFFLINE_BOOTCMD[1:]: + self.assertIn("--no-block", ligne) + self.assertTrue(ligne.rstrip().endswith("|| true")) + + def test_une_vm_en_ligne_garde_sa_synchronisation(self): + self.assertNotIn("bootcmd", self.config()) + + class TestLaLangueDesMessagesDuCache(unittest.TestCase): def test_l_option_du_deploiement_l_emporte(self): self.assertEqual( From 156956a1f027dfbf18aed4072aba25359c69cb26 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH 11/13] [FIX] test long cache : ne pas compter le refus d'un miroir en faute MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The verdict counted every upstream line of an already seen package as a file the cache failed to serve. A mirror that publishes the distribution under another path answers 404 to each package; the client moves to the next mirror, which the cache serves from disk. The 404 goes upstream on both VMs without anything missing, and an openSUSE campaign failed on six such lines. An upstream answer of 400 or more is now reported apart and not counted. Checked: a test for the refusal, and one where a delivered 200 still fails. --- FR --- Le verdict comptait toute ligne d'amont d'un paquet déjà vu comme un fichier que le cache n'a pas servi. Un miroir qui publie la distribution sous un autre chemin répond 404 à chaque paquet ; le client passe au miroir suivant, que le cache sert du disque. Le 404 repart à l'amont aux deux VM sans que rien ne manque, et une campagne openSUSE échouait sur six de ces lignes. Une réponse d'amont de 400 ou plus est désormais signalée à part, sans compter. Vérifié : un test du refus, et un où un 200 livré échoue toujours. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 25 ++++++++++++++++++++++- test/test_qemu_cache_mesure.py | 36 +++++++++++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 4 deletions(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index e2d5259..b259f17 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -828,7 +828,24 @@ def verdict(premier, second, journal): vues1 = {l["url"] for l in p1} # Le critère : ce que les DEUX ont demandé ne doit pas être ressorti. - fautes = [l for l in p2 if l["url"] in vues1 and l.get("upstream")] + # + # Un REFUS de l'amont n'en est pas : un miroir qui range sa distribution + # sous un autre chemin répond 404 à chaque paquet, le client passe au + # miroir suivant, et le cache sert celui-là du disque. Il n'a rien livré + # que le cache aurait dû garder — un 404 figé masquerait le fichier publié + # ensuite. Le compter en faute fait échouer une mesure où tout a été servi. + refus = [ + l + for l in p2 + if l.get("upstream") + and isinstance(l.get("status"), int) + and l["status"] >= 400 + ] + fautes = [ + l + for l in p2 + if l["url"] in vues1 and l.get("upstream") and l not in refus + ] # Ce que la seconde a découvert seule : légitime sur une publication # continue, montré pour que personne ne prenne un miroir qui bouge pour # une panne de cache. @@ -901,6 +918,12 @@ def verdict(premier, second, journal): ) for l in neufs[:5]: dire(f" + {l['url'].rsplit('/', 1)[-1]}", journal) + if refus: + dire( + f" ({len(refus)} refus de l'amont, non comptés : un miroir a" + " répondu par une erreur, le fichier est venu d'ailleurs)", + journal, + ) if fautes: dire("", journal) dire( diff --git a/test/test_qemu_cache_mesure.py b/test/test_qemu_cache_mesure.py index 92fdde4..e21b9b9 100644 --- a/test/test_qemu_cache_mesure.py +++ b/test/test_qemu_cache_mesure.py @@ -42,20 +42,25 @@ def charger(): QC = charger() -def ligne(url, upstream, octets=1000, issue="stored", classe="immutable"): +def ligne( + url, upstream, octets=1000, issue="stored", classe="immutable", statut=None +): """Une ligne du journal d'accès du cache. La CLASSE est ce que le filtre lit désormais : c'est le cache qui décide si un fichier est figé, et la tenir ici en dur revenait à laisser un index - se faire passer pour un paquet. + se faire passer pour un paquet. Le STATUT n'est écrit que s'il est donné. """ - return { + l = { "url": url, "upstream": upstream, "bytes": octets, "outcome": issue, "class": classe, } + if statut is not None: + l["status"] = statut + return l PAQUET_A = ( @@ -155,6 +160,31 @@ class TestVerdict(unittest.TestCase): de mesure, donc pas de succès.""" self.assertFalse(QC.verdict([ligne(PAQUET_A, True)], [], None)) + def test_un_miroir_qui_refuse_ne_fait_pas_echouer(self): + """Un miroir rangé sous un autre chemin répond 404 à chaque paquet ; le + client prend le fichier ailleurs, et le cache le sert du disque. Le + 404 repart à l'amont aux deux VM sans que rien n'ait manqué.""" + mauvais = PAQUET_A.replace("miroir.example", "autre.example/x") + premier = [ + ligne(mauvais, True, octets=153, issue="fetched", statut=404), + ligne(PAQUET_A, True, statut=200), + ] + second = [ + ligne(mauvais, True, octets=153, issue="fetched", statut=404), + ligne(PAQUET_A, False, issue="hit", statut=200), + ] + self.assertTrue( + QC.verdict(premier, second, None), + "le 404 d'un miroir a été compté comme un paquet ressorti", + ) + + def test_un_paquet_livre_deux_fois_reste_une_faute(self): + """Le refus est écarté, pas la réponse livrée : un 200 déjà vu qui + repart à l'amont fait toujours échouer.""" + premier = [ligne(PAQUET_A, True, statut=200)] + second = [ligne(PAQUET_A, True, statut=200)] + self.assertFalse(QC.verdict(premier, second, None)) + def test_le_melange(self): premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)] second = [ From 76ea0a27078b81473ca743fded007622138c8056 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH 12/13] =?UTF-8?q?[FIX]=20cache=20qemu=20:=20une=20rafale?= =?UTF-8?q?=20de=20coupures=20ne=20condamne=20plus=20un=20h=C3=B4te?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A package manager opens several connections to one repository at once and closes those it does not use. The cache saw three cut handshakes in the same second, counted three refusals, and moved the host to an opaque tunnel, which never consults the store. A client then waited four hours on that tunnel, holding a socket the far end had closed, until its own deadline killed it. Cuts closer than two seconds now count as one incident; a client that really rejects the authority retries seconds later and is banned as before. A closing tunnel is logged with its duration and bytes each way. --- FR --- Un gestionnaire de paquets ouvre plusieurs connexions de front vers un dépôt et ferme celles dont il ne se sert pas. Le cache voyait trois poignées de main coupées dans la même seconde, comptait trois refus, et passait l'hôte en tunnel opaque, qui ne consulte jamais le magasin. Un client attendait alors quatre heures sur ce tunnel, sur un socket que l'autre bout avait refermé, jusqu'à son propre délai. Deux coupures à moins de deux secondes comptent désormais pour un incident ; un client qui rejette vraiment l'autorité réessaie et reste condamné. La fin d'un tunnel est journalisée. Assisted-by: Claude Opus 5 --- script/qemu_cache/catalogue_en.go | 1 + script/qemu_cache/main.go | 2 +- script/qemu_cache/mitm.go | 81 +++++++++++++++++++++++++++++-- script/qemu_cache/mitm_test.go | 57 ++++++++++++++++++++++ 4 files changed, 135 insertions(+), 6 deletions(-) diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index aa1ad50..bd4a686 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -134,6 +134,7 @@ var anglais = map[string]string{ "tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)", "tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener", "tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead", + "tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest", "tunnel vers %s : %v": "tunnel to %s: %v", "ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)", "longueur d'enregistrement invraisemblable : %d": "implausible record length: %d", diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index cc51234..f5e86ae 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.11" +const version = "0.2.12" func main() { var ( diff --git a/script/qemu_cache/mitm.go b/script/qemu_cache/mitm.go index 92be2de..f919f23 100644 --- a/script/qemu_cache/mitm.go +++ b/script/qemu_cache/mitm.go @@ -26,6 +26,7 @@ import ( "path/filepath" "strings" "sync" + "sync/atomic" "time" ) @@ -233,8 +234,27 @@ type Refusals struct { // Seuil : combien de poignées de main de suite doivent échouer avant de // conclure. Nul, la valeur par défaut s'applique. Seuil int + // Rafale : deux coupures plus rapprochées que cela ne comptent que pour + // une. Nulle, la valeur par défaut s'applique ; négative, chaque coupure + // compte, ce dont un test se sert pour condamner sans attendre. + Rafale time.Duration + // Maintenant rend l'instant courant. Nulle, time.Now : un test injecte + // son horloge pour espacer des coupures sans dormir. + Maintenant func() time.Time + // dernier : quand a été COMPTÉE la dernière coupure d'un hôte, ce qui + // distingue une rafale d'un échec qui se répète. + dernier map[string]time.Time } +// RafaleParDefaut : en deçà, deux poignées de main coupées sont le MÊME +// incident. +// +// apt ouvre plusieurs connexions de front vers un dépôt et ferme celles dont +// il ne se sert pas : trois coupures dans la même seconde atteignaient le +// seuil sans que rien n'ait rejeté notre certificat. Un client qui refuse +// vraiment réessaie et échoue encore, à des secondes de là. +const RafaleParDefaut = 2 * time.Second + // SeuilParDefaut : trois échecs de suite avant de renoncer à déchiffrer. // // Un client qui ne nous fera jamais confiance échoue à CHAQUE fois ; une @@ -269,8 +289,10 @@ func NewRefusals(static []string) *Refusals { declares: map[string]bool{}, apprises: map[string]refus{}, echecs: map[string]int{}, + dernier: map[string]time.Time{}, Oubli: OubliParDefaut, Seuil: SeuilParDefaut, + Rafale: RafaleParDefaut, } for _, h := range static { if h = strings.TrimSpace(strings.ToLower(h)); h != "" { @@ -309,7 +331,7 @@ func (r *Refusals) Has(host string) bool { if vu.alerte { return true } - if r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + if r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli { r.oublier(host) return false } @@ -322,6 +344,7 @@ func (r *Refusals) Has(host string) bool { func (r *Refusals) oublier(host string) { delete(r.apprises, host) delete(r.echecs, host) + delete(r.dernier, host) } // Echec note une poignée de main manquée et dit si l'hôte passe en tunnel. @@ -343,6 +366,18 @@ func (r *Refusals) Echec(host string, raison error) bool { seuil = SeuilParDefaut } alerte := estRefusTLS(raison) + maintenant := r.maintenant() + // Une rafale ne compte qu'une fois. Sans cela, un client qui ouvre + // plusieurs connexions de front et ferme les inutiles atteint le seuil + // seul : l'hôte passe en tunnel, le magasin cesse de le servir, et le + // client qui attend sur ce tunnel n'a plus rien pour s'en sortir. + if !alerte { + if precedent, vu := r.dernier[host]; vu && + maintenant.Sub(precedent) < r.rafale() { + return false + } + r.dernier[host] = maintenant + } r.echecs[host]++ if !alerte && r.echecs[host] < seuil { return false @@ -354,10 +389,27 @@ func (r *Refusals) Echec(host string, raison error) bool { // La NATURE est retenue avec l'instant : elle décide si ce refus se // rouvrira. Un seuil atteint sur des coupures reste un soupçon, même // répété trois fois. - r.apprises[host] = refus{quand: time.Now(), alerte: alerte} + r.apprises[host] = refus{quand: maintenant, alerte: alerte} return true } +// maintenant rend l'instant courant, celui de l'horloge injectée s'il y en a. +func (r *Refusals) maintenant() time.Time { + if r.Maintenant != nil { + return r.Maintenant() + } + return time.Now() +} + +// rafale rend la distance en deçà de laquelle deux coupures n'en font qu'une. +// Négative, elle les fait toutes compter. +func (r *Refusals) rafale() time.Duration { + if r.Rafale != 0 { + return r.Rafale + } + return RafaleParDefaut +} + // Reussite efface le compte d'un hôte : la coupure d'avant n'était qu'un // incident, et deux incidents éloignés ne doivent pas s'additionner jusqu'au // seuil. @@ -365,6 +417,7 @@ func (r *Refusals) Reussite(host string) { r.mu.Lock() defer r.mu.Unlock() delete(r.echecs, host) + delete(r.dernier, host) } func (r *Refusals) List() []string { @@ -375,7 +428,7 @@ func (r *Refusals) List() []string { out = append(out, h) } for h, vu := range r.apprises { - if !vu.alerte && r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli { + if !vu.alerte && r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli { r.oublier(h) continue } @@ -596,10 +649,28 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool { Client: clientDe(c.RemoteAddr().String()), }) + // Un tunnel qui se referme ne laissait AUCUNE trace : le client qui + // attendait dessus paraissait bloqué sans cause, et rien au journal ne + // disait lequel des deux bouts s'était taire. La durée et les octets par + // direction le disent — un tunnel qui meurt sans avoir rien rendu à + // l'invité est l'empreinte d'un amont qui coupe. + debut := time.Now() + var versAmont, versClient atomic.Int64 done := make(chan struct{}, 2) - go func() { io.Copy(up, c); done <- struct{}{} }() - go func() { io.Copy(c, up); done <- struct{}{} }() + go func() { + n, _ := io.Copy(up, c) + versAmont.Store(n) + done <- struct{}{} + }() + go func() { + n, _ := io.Copy(c, up) + versClient.Store(n) + done <- struct{}{} + }() <-done + log.Printf(T("tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité"), + dst, time.Since(debut).Round(time.Second), + HumanBytes(versAmont.Load()), HumanBytes(versClient.Load())) return false } diff --git a/script/qemu_cache/mitm_test.go b/script/qemu_cache/mitm_test.go index 1675ef2..1497d4f 100644 --- a/script/qemu_cache/mitm_test.go +++ b/script/qemu_cache/mitm_test.go @@ -210,6 +210,7 @@ func TestPremierEnregistrementNonTLS(t *testing.T) { // produit une VM qui démarre et coupe. Seule la répétition les sépare. func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) { r := NewRefusals(nil) + r.Rafale = -1 // chaque coupure compte : c'est le SEUIL qu'on éprouve ici coupure := errors.New("read: connection reset by peer") for i := 1; i < r.Seuil; i++ { if r.Echec("miroir.example", coupure) { @@ -223,9 +224,14 @@ func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) { func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) { r := NewRefusals(nil) + // Des coupures ESPACÉES, telles qu'un client qui réessaie les produit : + // l'horloge avance entre chacune, sans faire dormir le test. + horloge := time.Now() + r.Maintenant = func() time.Time { return horloge } coupure := errors.New("EOF") for i := 0; i < r.Seuil; i++ { r.Echec("npm.example", coupure) + horloge = horloge.Add(10 * time.Second) } if !r.Has("npm.example") { t.Error("un client qui échoue à chaque fois n'est jamais mis en" + @@ -244,6 +250,7 @@ func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) { func TestUnRefusDeTransportSeRouvre(t *testing.T) { r := NewRefusals(nil) r.Oubli = 50 * time.Millisecond + r.Rafale = -1 coupure := errors.New("local error: tls: bad record MAC") for i := 0; i < r.Seuil; i++ { r.Echec("miroir.example", coupure) @@ -275,6 +282,7 @@ func TestLOubliRemetLeCompteAZero(t *testing.T) { t.Run(nom, func(t *testing.T) { r := NewRefusals(nil) r.Oubli = 50 * time.Millisecond + r.Rafale = -1 for i := 0; i < r.Seuil; i++ { r.Echec("registre.example", io.EOF) } @@ -307,6 +315,7 @@ func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) { const hote = "registre.example.invalid" r := NewRefusals(nil) + r.Rafale = -1 for i := 0; i < r.Seuil; i++ { r.Echec(hote, io.EOF) } @@ -367,6 +376,7 @@ func TestLOubliEstActifParDefaut(t *testing.T) { // s'additionner jusqu'au seuil. func TestUneReussiteEffaceLeCompte(t *testing.T) { r := NewRefusals(nil) + r.Rafale = -1 coupure := errors.New("EOF") for i := 1; i < r.Seuil; i++ { r.Echec("h.example", coupure) @@ -380,6 +390,53 @@ func TestUneReussiteEffaceLeCompte(t *testing.T) { } } +// Une RAFALE est un seul incident. apt ouvre plusieurs connexions de front +// vers un dépôt et ferme celles dont il ne se sert pas : trois coupures dans +// la même seconde condamnaient l'hôte sans que rien n'ait rejeté notre +// certificat. Le client repartait alors sur un tunnel opaque, et son +// « apt-get update » y attendait jusqu'au délai — quatre heures. +func TestUneRafaleDeCoupuresNeCondamnePas(t *testing.T) { + r := NewRefusals(nil) + horloge := time.Now() + r.Maintenant = func() time.Time { return horloge } + coupure := errors.New("read: connection reset by peer") + + // Dix connexions fermées dans le même instant : un seul incident. + for i := 0; i < 10; i++ { + if r.Echec("deb.example", coupure) { + t.Fatalf("condamné sur la coupure %d d'une même rafale", i+1) + } + } + if r.Has("deb.example") { + t.Fatal("une rafale simultanée condamne l'hôte") + } + + // Le temps passe : les coupures suivantes sont d'autres incidents, et le + // seuil finit par tomber. + for i := 1; i < r.Seuil; i++ { + horloge = horloge.Add(RafaleParDefaut + time.Second) + r.Echec("deb.example", coupure) + } + if !r.Has("deb.example") { + t.Error("des coupures espacées ne condamnent plus : un client qui" + + " rejette vraiment notre autorité ne passerait jamais en tunnel") + } +} + +// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat. +// La rafale ne la retient pas : dix alertes d'affilée valent une décision. +func TestUneAlerteTrancheMemeEnRafale(t *testing.T) { + r := NewRefusals(nil) + horloge := time.Now() + r.Maintenant = func() time.Time { return horloge } + if !r.Echec("epingleur.example", errRefus) { + t.Fatal("une alerte ne tranche pas dans le même instant qu'une autre") + } + if !r.Has("epingleur.example") { + t.Error("l'alerte n'est pas retenue") + } +} + // Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat. func TestUneAlerteCondamneDesLePremierEchec(t *testing.T) { r := NewRefusals(nil) From aa6774939f98f5a41f304da33d74bfcbc25935b8 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH 13/13] =?UTF-8?q?[FIX]=20d=C3=A9ploiement=20qemu=20:=20att?= =?UTF-8?q?endre=20l'=C3=A9tape=20finale=20de=20cloud-init?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « cloud-init status --wait » rend la main dès que cloud-init se déclare en erreur — un module accessoire y suffit — alors que son étape finale écrit encore l'autorité du cache, les variables et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans ces variables, PAM ne relisant plus le fichier, et sudo n'a alors rien à conserver : l'installation lancée par sudo rejette le certificat du cache. L'attente guette désormais l'unité cloud-final, et son seul état « activating » : ce oneshot reste ACTIF une fois fini, si bien qu'« is-active » y ferait attendre la borne entière pour rien. --- EN --- « cloud-init status --wait » returns as soon as cloud-init declares itself in error — an accessory module is enough — while its final stage still writes the cache authority, the variables and the sudoers file. A session opened in that second lives without those variables, PAM never rereading the file, and sudo then has nothing to keep: an install run by sudo rejects the cache certificate. The wait now watches the cloud-final unit, and only its « activating » state: this oneshot stays ACTIVE once finished, so « is-active » would wait the whole bound for nothing. Assisted-by: Claude Opus 5 --- long_test/qemu_cache.py | 4 +++ script/qemu/deploy_qemu.py | 29 +++++++++++++++ script/todo/qemu_install.py | 10 +++++- test/test_qemu_cache_charge.py | 63 +++++++++++++++++++++++++++++++++ test/test_qemu_desktop_tools.py | 17 +++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index b259f17..9e1edba 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -67,6 +67,7 @@ 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, ) @@ -175,6 +176,9 @@ def avec_reprises(commande): 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()}" diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index 2a5311d..d5ac905 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2853,6 +2853,35 @@ def cache_env_reload(fichier: str = "/etc/environment") -> str: ) +def attente_cloud_final(bornes: int = 150) -> str: + """Attend que l'ÉTAPE FINALE de cloud-init ait fini d'écrire. + + « cloud-init status --wait » rend la main dès que cloud-init se déclare en + ERREUR, et un module accessoire suffit à l'y mettre — une locale que + l'invité ne connaît pas, par exemple. Son étape finale, elle, continue : + c'est elle qui pose l'autorité du cache, les variables de /etc/environment + et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans + ces variables pour toute sa durée — PAM ne relit plus le fichier — et + « sudo » n'a alors rien à conserver, si bien qu'un npm lancé par sudo + rejette l'autorité du cache sur « self-signed certificate in certificate + chain ». + + L'unité tranche, mais PAS par « is-active » : cloud-final est un service + « oneshot » qui reste ACTIF une fois terminé — is-active y est vrai pour + toujours, et attendre là-dessus coûte la borne entière à chaque VM, sans + rien détecter. Seul « activating » dit que l'étape écrit encore. + + Bornée à `bornes` tours de deux secondes. Sans effet là où l'unité n'existe + pas : « show » y rend un état vide ou « inactive », jamais « activating » — + une image sans cloud-init, ou une VM déjà installée, n'attendent rien. + """ + return ( + 'n=0; while [ "$(systemctl show -p ActiveState --value' + ' cloud-final.service 2>/dev/null)" = activating ]; do n=$((n+1));' + f" [ $n -ge {bornes} ] && break; sleep 2; done" + ) + + def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]: """Fichiers d'accueil de la VM : le guide de connexion, l'identité git. diff --git a/script/todo/qemu_install.py b/script/todo/qemu_install.py index 97d9d2c..a556da1 100644 --- a/script/todo/qemu_install.py +++ b/script/todo/qemu_install.py @@ -425,7 +425,10 @@ class QemuInstallMixin: suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis installait sur un index jamais rafraîchi : « Impossible de trouver le paquet », un message qui n'accuse personne.""" - from script.qemu.deploy_qemu import cache_env_reload + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) return ( "if command -v cloud-init >/dev/null 2>&1; then " @@ -445,6 +448,11 @@ class QemuInstallMixin: "n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; " "do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; " "fi; " + # « status --wait » rend la main dès que cloud-init se déclare en + # ERREUR, alors que son étape finale écrit encore l'autorité, les + # variables et le sudoers : attendre l'unité, faute de quoi elles + # arrivent après cette session, qui vivra sans elles. + + attente_cloud_final() + "; " # Les variables du cache sont écrites par cloud-init PENDANT # l'attente : cette session, ouverte avant, ne les a pas reçues. + cache_env_reload() + "; " diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 4e09504..3fdda3b 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -139,6 +139,69 @@ class TestLaCharge(unittest.TestCase): i = QC.ATTENDRE_CLOUD_INIT.index("status --wait") self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:]) + def test_lattente_suit_l_unite_et_non_le_seul_statut(self): + """« status --wait » rend la main dès que cloud-init se déclare en + erreur — un module accessoire y suffit — alors que son étape finale + écrit encore l'autorité du cache et les variables. Une session ouverte + à cette seconde-là vit sans elles, et « sudo » n'a rien à conserver : + « sudo npm » rejette alors le certificat du cache.""" + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) + + attente = QC.ATTENDRE_CLOUD_INIT + self.assertIn("cloud-final", attente_cloud_final()) + self.assertIn(attente_cloud_final(), attente) + self.assertLess( + attente.index(attente_cloud_final()), + attente.index(cache_env_reload()), + "les variables sont relues avant la fin de l'étape qui les écrit", + ) + + def test_lattente_de_l_unite_est_bornee_et_sans_effet_ailleurs(self): + """Une VM sans cloud-init ne doit pas payer l'attente, et une unité + qui ne finit jamais ne doit pas tenir la campagne indéfiniment.""" + from script.qemu.deploy_qemu import attente_cloud_final + + court = attente_cloud_final(3) + self.assertIn("[ $n -ge 3 ]", court) + self.assertIn("2>/dev/null", court) + + def test_lattente_guette_activating_et_non_is_active(self): + """cloud-final est un « oneshot » qui reste ACTIF une fois terminé : + « is-active » y est vrai pour toujours. Attendre là-dessus paie la + borne entière sur chaque VM — cinq minutes — sans jamais rien + détecter. Seul « activating » dit que l'étape écrit encore.""" + from script.qemu.deploy_qemu import attente_cloud_final + + attente = attente_cloud_final() + self.assertIn("activating", attente) + self.assertNotIn("is-active", attente) + + def test_lattente_de_l_unite_rend_la_main_sur_cette_machine(self): + """Jouée dans un vrai shell, hors VM : l'hôte n'a pas d'étape finale + en cours, et l'attente doit donc rendre la main tout de suite.""" + import subprocess + import time + + from script.qemu.deploy_qemu import attente_cloud_final + + debut = time.monotonic() + res = subprocess.run( + ["sh", "-c", attente_cloud_final()], + capture_output=True, + text=True, + timeout=30, + ) + self.assertEqual(0, res.returncode, res.stderr[-200:]) + self.assertLess( + time.monotonic() - debut, + 5, + "l'attente tourne alors que rien n'écrit : elle guette un état" + " qu'un service oneshot garde pour toujours", + ) + def test_la_charge_est_du_shell_valide(self): """Une instruction collée sans séparateur casse la commande entière, et la VM ne dit alors pas pourquoi elle n'a rien installé.""" diff --git a/test/test_qemu_desktop_tools.py b/test/test_qemu_desktop_tools.py index d9b96ba..ee91a10 100644 --- a/test/test_qemu_desktop_tools.py +++ b/test/test_qemu_desktop_tools.py @@ -1589,6 +1589,23 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase): "les variables sont relues avant que cloud-init les ait écrites", ) + def test_lattente_suit_l_unite_et_non_le_seul_statut(self): + """cloud-init se déclare en erreur pour un module accessoire, et + « status --wait » rend alors la main pendant que son étape finale + écrit encore l'autorité du cache et les variables.""" + from script.qemu.deploy_qemu import ( + attente_cloud_final, + cache_env_reload, + ) + + attente = self.todo._qemu_cloud_init_wait() + self.assertIn(attente_cloud_final(), attente) + self.assertLess( + attente.index(attente_cloud_final()), + attente.index(cache_env_reload()), + "les variables sont relues avant la fin de l'étape qui les écrit", + ) + def test_le_nom_du_service_est_celui_que_le_deploiement_donne(self): """Deux noms qui divergent et l'attente ne trouve jamais rien.""" from script.qemu import deploy_qemu