[ADD] cache qemu : messages du binaire en anglais ou en français
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
This commit is contained in:
parent
b11f90e3a0
commit
fb2f71477e
23 changed files with 813 additions and 239 deletions
|
|
@ -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
|
||||
|
||||
<!-- [fr] -->
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
<!-- [en] -->
|
||||
## Fixed
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 " +
|
||||
"%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")
|
||||
"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é."))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 »
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
155
script/qemu_cache/catalogue_en.go
Normal file
155
script/qemu_cache/catalogue_en.go
Normal file
|
|
@ -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",
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
95
script/qemu_cache/langue.go
Normal file
95
script/qemu_cache/langue.go
Normal file
|
|
@ -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
|
||||
}
|
||||
238
script/qemu_cache/langue_test.go
Normal file
238
script/qemu_cache/langue_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 -"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
103
test/test_qemu_cache_langue.py
Normal file
103
test/test_qemu_cache_langue.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Reference in a new issue