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()