Merge branch 'stage/qemu_cache'

[ADD] qemu : cache des téléchargements des VM, hors ligne, 3D Proxmox

14 commits. Les VM d'un hôte retéléchargeaient les mêmes paquets et dépôts à
chaque installation, et rien ne prouvait qu'une installation tienne sans
réseau. Un cache transparent sert du disque ce qui ne change pas, garde git en
miroir et peut couper le réseau de toute une installation. Sur une vraie
installation d'ERPLibre et d'Odoo 18, la seconde VM n'a tiré de l'amont aucun
paquet ni requête git. Viennent avec : la 3D sous Proxmox VE, le verrou apt et
l'agent invité attendus, les outils optionnels posés ou dits absents. Vérifié :
tests Go, et 6318 tests unitaires dont 9 échecs déjà présents sur master ; la
campagne hors ligne complète n'a pas été rejouée après ses deux derniers
correctifs.

--- EN ---

14 commits. A host's VMs downloaded the same packages and repositories again
on every install, and nothing proved an install could hold without the
network. A transparent cache serves from disk what never changes, mirrors git
and can cut the network for a whole install. On a real ERPLibre and Odoo 18
install, the second VM pulled no package and no git request upstream. With it:
3D on Proxmox VE, the apt lock and the guest agent waited for, optional tools
installed or reported missing. Verified: Go tests, and 6318 unit tests with 9
failures already present on master; the full offline run was not replayed
after its last two fixes.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-16 05:36:50 -04:00
commit f317401881
63 changed files with 3737 additions and 324 deletions

View file

@ -70,6 +70,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline
- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
<!-- [fr] -->
@ -112,6 +114,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne
- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
<!-- [en] -->
## Changed
@ -126,6 +130,10 @@ 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
- A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated`
- A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone
- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit
<!-- [fr] -->
@ -136,6 +144,10 @@ 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
- Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated`
- Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL
- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit
<!-- [en] -->
## Fixed
@ -180,6 +192,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose
- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes
- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones
- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them
<!-- [fr] -->
@ -220,6 +233,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle
- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes
- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais
- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit
<!-- [en] -->
## Removed

View file

@ -50,6 +50,8 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- La coupure hors ligne tombe avec la dernière installation, et non à la fermeture du suivi : une unité root, qui reçoit la levée au lancement, attend le marqueur de fin de chaque installation et survit au suivi fermé tôt, à todo.py tué ou au terminal perdu — 12 h au plus. Le suivi est donc obligatoire hors ligne. Un second déploiement hors ligne est refusé pendant qu'un premier tourne, et un déploiement en ligne lancé entre-temps est prévenu qu'il tournerait hors ligne
- F5 lit aussi les essais hors ligne précédents de la même VM et prévient « au moins N adresses ont manqué », moins ce que le magasin détient désormais (`erplibre_go_qemu_cache --detient`, en lecture seule, sans root). **Déploiement › Cache QEMU › Combler ce qui a manqué hors ligne** les rejoue en ligne, à travers le cache
- Le journal d'installation nomme le commit que la VM exécute ; hors ligne, le récapitulatif dit, branche par branche, quel commit le miroir du cache donnera
- `long_test/qemu_cache.py --distro tous` (ou une liste séparée par des virgules) enchaîne une campagne par système du catalogue, défait les VM de chaque système avant le suivant, et finit sur un tableau des verdicts, durées et octets d'amont. Un échec n'arrête pas la série
- Le cache de téléchargement QEMU peut se nettoyer chaque jour : par âge (`EL_PURGE_AGE`, ex. `90j`) et par plafond de taille (`EL_MAX_SIZE`, ex. `50G`, le moins récemment servi partant d'abord, objets et miroirs git confondus). Les deux sont désactivés par défaut et se règlent depuis **Déploiement › Cache QEMU › Nettoyage automatique**, qui montre ce qui partirait ; `--purge-to-size` applique le plafond à la main. Une réinstallation garde les valeurs choisies
## Modifié
@ -60,6 +62,10 @@ 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
- Un index de dépôt que le cache détient déjà est revalidé par son ETag au lieu d'être retéléchargé : l'amont juge toujours chaque requête, et un « 304 » sert le corps gardé depuis le disque. Sur une installation complète d'ERPLibre, les index pip, les métadonnées npm et le bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque fois. Un index rangé sans son hôte — partagé par tous les miroirs d'une liste qui tourne — et une réponse sans ETag sont repris en entier comme avant ; le journal d'accès nomme la nouvelle issue `revalidated`
- Une page de registre servie sous `Vary: Accept` garde une copie par représentation. npm demande la même page `/npm` abrégée, puis complète, puis de nouveau abrégée ; rangées sous une seule clé, elles se remplaçaient et les 31 Mo repartaient à chaque installation. Chaque représentation est désormais revalidée et, hors ligne, servie à part ; `--detient` lit toujours la page sous sa seule URL
- Une VM déployée l'amont du cache coupé — formulaire QEMU, Proxmox VE, ou `deploy_qemu.py --offline` — a l'audit de sécurité de npm désactivé (`NPM_CONFIG_AUDIT=false`) : il interroge un service qu'aucun cache ne rejoue, et échouait à chaque installation hors ligne. Une VM en ligne garde son audit
## Corrigé
@ -100,6 +106,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- L'avertissement sur les miroirs ne compte que les dépôts de la version d'Odoo déployée. Il additionnait tous les manifestes du dépôt — le déprécié compris — et annonçait 170 miroirs manquants là où un vrai déploiement Odoo 18 en rencontre quatre : une alarme qui sonne pour rien est une alarme qu'on cesse de lire. Le remplissage des miroirs, lui, prend toujours de l'avance pour toutes les versions, ce qui est son rôle
- Un hôte banni du déchiffrement sur une rafale d'erreurs de transport retrouve sa chance. Trois poignées de main manquées d'affilée le passaient en tunnel opaque, et un tunnel ne consulte jamais le magasin : un miroir de distribution condamné par quelques enregistrements corrompus renvoyait tout son trafic à l'amont — y compris les centaines d'objets déjà détenus pour lui — jusqu'au redémarrage du service. Une alerte TLS bannit toujours définitivement, le client ayant regardé notre certificat et l'ayant refusé ; mais une coupure répétée n'est qu'un soupçon, et elle se rouvre au bout de dix minutes
- **Déploiement › Cache QEMU › Miroirs git** remplit à part la base de la version d'Odoo active, ou ses modules extra — à côté du remplissage de tous les manifestes, qui prend des heures. Chaque liste dit combien de dépôts elle déclare et combien n'ont pas encore de miroir. Ce qu'un déploiement clone se lit désormais avec la règle et les listes de la fusion des manifestes elle-même : les modules extra, installés seulement sur demande, et le projet mobile ne comptent plus, là où l'avertissement hors ligne annonçait quatre miroirs manquants qu'une installation Odoo 18 par défaut ne clone jamais
- Les métadonnées PEP 658 de pip — le fichier `.whl.metadata` récupéré avant chaque roue — sont servies du disque. Le nom finit par `.metadata`, qu'aucune règle ne connaissait : chaque fichier repartait à chaque installation, 178 sur une installation complète d'ERPLibre. Les copies gardées avant ce changement ne sont plus atteintes ; la prochaine installation en ligne les remplit
## Retiré

View file

@ -50,6 +50,8 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The offline cut ends when the last installation ends, not when the monitor closes: a root unit, handed the lift at launch, waits for every installation's exit marker and survives the monitor closed early, todo.py killed or the terminal gone — 12 h at most. The monitor is therefore required while offline. A second offline deployment is refused while one runs, and an online deployment started meanwhile is told it would run offline
- F5 also reads the previous offline runs of the same VM and warns « at least N addresses were missing », minus what the store holds now (`erplibre_go_qemu_cache --detient`, read-only, no root). **Deployment › QEMU cache › Fill what offline runs lacked** replays them online, through the cache
- The install log names the commit the VM runs; offline, the recap says, branch by branch, which commit the cache's mirror will give
- `long_test/qemu_cache.py --distro tous` (or a comma list) chains one campaign per catalogue system, destroys each system's VMs before the next, and ends on a table of verdict, durations and upstream bytes. A failure does not stop the series
- The QEMU download cache can clean itself up every day: by age (`EL_PURGE_AGE`, e.g. `90j`) and by size ceiling (`EL_MAX_SIZE`, e.g. `50G`, the least recently served going first, objects and git mirrors alike). Both are off by default and set from **Deployment › QEMU cache › Automatic cleanup**, which previews what would go; `--purge-to-size` runs the ceiling by hand. A reinstall keeps the values chosen
## Changed
@ -60,6 +62,10 @@ 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
- A repository index the cache already holds is revalidated with its ETag rather than downloaded again: upstream still judges every request, and a « 304 » serves the stored body from disk. On a full ERPLibre install the pip indexes, npm metadata and repo bundle had been about 110 MB per VM, taken whole each time. An index stored without its host — shared by every mirror of a rotating list — and an answer carrying no ETag are taken whole as before; the access log names the new outcome `revalidated`
- A registry page served under `Vary: Accept` keeps one copy per representation. npm asks for the same `/npm` page abridged, then complete, then abridged again; kept under one key they replaced each other and the 31 MB were fetched on every install. Each representation is now revalidated and, offline, served on its own; `--detient` still reads the page under its URL alone
- A VM deployed with the cache upstream cut — QEMU form, Proxmox VE, or `deploy_qemu.py --offline` — has npm's security audit turned off (`NPM_CONFIG_AUDIT=false`): it queries a remote service no cache can replay, and failed on every offline install. An online VM keeps its audit
## Fixed
@ -100,6 +106,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The mirror warning counts only the repositories of the Odoo version being deployed. It used to add up every manifest in the repository — the deprecated one included — and announced 170 missing mirrors where a real Odoo 18 deployment meets four: an alarm that fires for nothing is one that stops being read. Filling the mirrors still takes every version ahead, which is its purpose
- A host banned from decryption on a burst of transport errors gets another chance. Three failed handshakes in a row put it in an opaque tunnel, and a tunnel never consults the store: a distribution mirror condemned by a few corrupted records sent all its traffic back upstream, including the hundreds of objects already held for it, until the service was restarted. A TLS alert still bans for good — the client looked at our certificate and refused it — but a repeated cut is only a suspicion, and it reopens after ten minutes
- **Deployment › QEMU cache › Git mirrors** fills the base of the active Odoo version, or its extra modules, on their own — beside the full fill of every manifest, which takes hours. Each list shows how many repositories it declares and how many still lack a mirror. What a deployment clones is now read with the manifest merge's own rule and lists, so the extra modules, installed only on request, and the mobile project no longer count: the offline warning announced four missing mirrors that a default Odoo 18 install never clones
- pip's PEP 658 metadata — the `.whl.metadata` file fetched before each wheel — is served from disk. The name ends in `.metadata`, which no rule knew, so each file was taken again on every install: 178 of them on a full ERPLibre install. Copies stored before this change are not reached again; the next online install refills them
## Removed

View file

@ -39,6 +39,7 @@ stocké, et le journal doit dire sur quel instantané elle se bâtit.
"""
import argparse
import copy
import json
import os
import re
@ -66,6 +67,7 @@ from script.qemu import cache_offline # noqa: E402
from script.qemu.deploy_qemu import ( # noqa: E402
DISTRO_PKG,
DISTROS,
attente_cloud_final,
cache_env_reload,
distro_label,
)
@ -174,6 +176,9 @@ def avec_reprises(commande):
ATTENDRE_CLOUD_INIT = (
"if command -v cloud-init >/dev/null 2>&1; then"
" sudo timeout 900 cloud-init status --wait >/dev/null 2>&1 || true; fi"
# « status --wait » rend la main dès que cloud-init se déclare en erreur,
# alors que son étape finale écrit encore : l'unité, elle, dit la vérité.
f"; {attente_cloud_final()}"
# La session s'ouvre avant que cloud-init n'écrive les variables du cache :
# sans les relire, un npm lancé sans sudo rejette l'autorité du cache.
f"; {cache_env_reload()}"
@ -622,9 +627,13 @@ def deployer(
distro=DISTRO,
version=VERSION,
charge="minimum",
hors_ligne=False,
):
"""Une VM Arch, branchée sur le cache ou non. Rend son adresse, ou ''.
`hors_ligne` : la VM naît l'amont du cache coupé. Le déploiement y
désactive ce qu'aucun cache ne rejoue, comme le ferait le formulaire.
Sans le cache, la VM télécharge en direct : c'est le TÉMOIN, la mesure de
ce que coûte une installation quand rien n'est gardé. Un gain ne veut rien
dire sans lui.
@ -645,6 +654,7 @@ def deployer(
f" --ssh-key {shlex.quote(cle_publique())}"
+ (
f" --cache-ca {shlex.quote(CA)}"
+ (" --offline" if hors_ligne else "")
if avec_cache
else " --cache-bypass"
)
@ -685,6 +695,27 @@ def dans_la_vm(adresse, commande, delai, journal, montrer=False):
return executer(ssh, delai, journal, montrer=montrer)
def eteindre(nom, journal, dry_run=False):
"""Éteint une VM dont la mesure est faite, sans l'effacer.
Une VM de la charge « erplibre » tient 8 Gio de mémoire. Laissée allumée
pendant que la suivante s'installe, puis la troisième, elles s'additionnent,
et l'hôte manque de mémoire en pleine série. Éteinte, elle garde son disque
pour qui voudrait l'inspecter, et « --detruire » la retrouve par son nom.
Un échec d'extinction est dit et n'arrête rien : la mesure est déjà faite.
"""
cmd = f"sudo -n virsh -c qemu:///system destroy {shlex.quote(nom)}"
if dry_run:
dire(f" [à blanc] {cmd}", journal)
return
code, _ = executer(cmd, 120, journal)
if code:
dire(
f" ⚠ {nom} : extinction impossible ({code}), elle reste allumée",
journal,
)
def attendre_ssh(adresse, journal):
for _essai in range(40):
code, _ = dans_la_vm(adresse, "true", 30, None)
@ -801,7 +832,24 @@ def verdict(premier, second, journal):
vues1 = {l["url"] for l in p1}
# Le critère : ce que les DEUX ont demandé ne doit pas être ressorti.
fautes = [l for l in p2 if l["url"] in vues1 and l.get("upstream")]
#
# Un REFUS de l'amont n'en est pas : un miroir qui range sa distribution
# sous un autre chemin répond 404 à chaque paquet, le client passe au
# miroir suivant, et le cache sert celui-là du disque. Il n'a rien livré
# que le cache aurait dû garder — un 404 figé masquerait le fichier publié
# ensuite. Le compter en faute fait échouer une mesure où tout a été servi.
refus = [
l
for l in p2
if l.get("upstream")
and isinstance(l.get("status"), int)
and l["status"] >= 400
]
fautes = [
l
for l in p2
if l["url"] in vues1 and l.get("upstream") and l not in refus
]
# Ce que la seconde a découvert seule : légitime sur une publication
# continue, montré pour que personne ne prenne un miroir qui bouge pour
# une panne de cache.
@ -874,6 +922,12 @@ def verdict(premier, second, journal):
)
for l in neufs[:5]:
dire(f" + {l['url'].rsplit('/', 1)[-1]}", journal)
if refus:
dire(
f" ({len(refus)} refus de l'amont, non comptés : un miroir a"
" répondu par une erreur, le fichier est venu d'ailleurs)",
journal,
)
if fautes:
dire("", journal)
dire(
@ -933,13 +987,17 @@ def contre_epreuve(
C'est ce qui distingue un cache d'une simple accélération : sans réseau,
le déploiement tient encore sur l'index stocké.
Un échec note l'étape dans rapport["etape_en_echec"], comme la boucle le
fait pour les deux premières VM : le résumé d'une série la nomme.
"""
dire("", journal)
dire(" ── Contre-épreuve : amont coupé ──", journal)
nom = f"{base}-3"
if not couper_lamont(journal, dry_run):
rapport["etape_en_echec"] = f"{nom} : coupure de l'amont"
return False
try:
nom = f"{base}-3"
rapport["vms"].append(nom)
ecrire_rapport(rapport)
adresse = deployer(
@ -949,13 +1007,18 @@ def contre_epreuve(
distro=distro,
version=version,
charge=charge,
hors_ligne=True,
)
if not adresse:
rapport["etape_en_echec"] = f"{nom} : déploiement"
return False
noter_uuid(rapport, nom, dry_run)
if not dry_run and not attendre_ssh(adresse, journal):
rapport["etape_en_echec"] = f"{nom} : ssh"
return False
ok = poser_les_paquets(adresse, journal, dry_run, distro, charge)
if not ok:
rapport["etape_en_echec"] = f"{nom} : paquets"
if ok:
dire(
" ✓ la troisième VM s'est bâtie sans que le cache joigne"
@ -1082,8 +1145,9 @@ def main(argv=None):
parseur.add_argument(
"--distro",
default=DISTRO,
choices=sorted(systemes_mesurables()),
help=f"système des VM du test (défaut : {DISTRO})",
help=f"système des VM du test (défaut : {DISTRO}) ; « tous », ou une"
" liste séparée par des virgules, enchaîne une campagne par système :"
f" {', '.join(sorted(systemes_mesurables()))}",
)
parseur.add_argument(
"--version",
@ -1100,21 +1164,115 @@ def main(argv=None):
" et d'Odoo 18, qui se compte en heures",
)
args = parseur.parse_args(argv)
# Vide veut dire « celle du catalogue » : la recopier ici la figerait, et
# le test installerait une version que le déploiement ne propose plus.
if not args.version:
args.version = DISTROS[args.distro][1]
if args.rapport:
return rapport_comparatif()
if args.detruire:
return detruire(args.dry_run)
try:
systemes = systemes_demandes(args.distro)
except ValueError as err:
parseur.error(str(err))
if len(systemes) > 1:
if args.version:
parseur.error(
"--version ne vaut que pour un seul système : chacun prend"
" celle du catalogue"
)
return campagne_par_systemes(args, systemes)
args.distro = systemes[0]
# Vide veut dire « celle du catalogue » : la recopier ici la figerait, et
# le test installerait une version que le déploiement ne propose plus.
if not args.version:
args.version = DISTROS[args.distro][1]
return une_campagne(args)[0]
def systemes_demandes(valeur):
"""Les systèmes que --distro désigne, dans l'ordre donné, sans doublon.
« tous » désigne chaque système mesurable, dans l'ordre alphabétique ; une
liste séparée par des virgules, ceux qu'elle nomme. Lève ValueError sur un
nom inconnu : le refus tombe avant qu'aucune machine ne soit créée, et non
au milieu d'une série de plusieurs heures.
"""
connus = systemes_mesurables()
if (valeur or "").strip().lower() == "tous":
return sorted(connus)
noms = [n.strip() for n in (valeur or "").split(",") if n.strip()]
inconnus = [n for n in noms if n not in connus]
if not noms or inconnus:
raise ValueError(
f"système inconnu : {', '.join(inconnus) or repr(valeur)} ;"
f" connus : {', '.join(sorted(connus))}, ou « tous »"
)
return list(dict.fromkeys(noms))
def campagne_par_systemes(args, systemes):
"""Une campagne par système, l'une après l'autre, puis leur tableau.
Les machines d'un système sont défaites avant le suivant : trois VM par
système ne tiendraient pas toutes ensemble sur le disque de l'hôte. Un
échec n'arrête pas la série — le tableau doit montrer chaque système, et
c'est justement ce qu'on vient chercher quand l'un d'eux échoue.
"""
resultats = []
for distro in systemes:
un = copy.copy(args)
un.distro, un.version = distro, DISTROS[distro][1]
print(f"\n ══ {distro} {un.version} ══")
code, fichier = une_campagne(un)
resultats.append((distro, un.version, code, fichier))
if not args.dry_run:
detruire()
return resume_par_systemes(resultats)
def resume_par_systemes(resultats):
"""Le tableau d'une série : verdict, durées et octets d'amont de la seconde
VM par système. Rend 0 si chaque système a réussi, 1 sinon."""
print("\n ── Par système ──\n")
print(
f" {'système':<22}{'verdict':<10}{'VM 1':>8}{'VM 2':>8}"
f"{'amont VM 2':>14} étape en échec"
)
tous_reussis = True
for distro, version, code, fichier in resultats:
rapport = {}
if fichier and os.path.exists(fichier):
try:
with open(fichier, encoding="utf-8") as fh:
rapport = json.load(fh)
except (OSError, ValueError):
rapport = {}
durees = list((rapport.get("durees") or {}).values())
octets = list((rapport.get("octets") or {}).values())
rendu = rapport.get("verdict") or ("ok" if code == 0 else "échec")
tous_reussis = tous_reussis and code == 0 and rendu == "ok"
vm1 = f"{durees[0]:.0f}s" if durees else "—"
vm2 = f"{durees[1]:.0f}s" if len(durees) > 1 else "—"
amont = humain(octets[1].get("amont", 0)) if len(octets) > 1 else "—"
print(
f" {distro + ' ' + str(version):<22}{rendu:<10}{vm1:>8}{vm2:>8}"
f"{amont:>14} {rapport.get('etape_en_echec') or ''}"
)
print()
return 0 if tous_reussis else 1
def une_campagne(args):
"""Une campagne sur UN système : préalables, VM, mesure, contre-épreuve.
Rend (code, chemin du rapport) ; le chemin est vide quand la campagne
s'arrête avant d'avoir écrit un rapport.
"""
base = base_des_noms(args)
journal = journal_neuf()
dire(f" journal : {journal}", journal)
if not args.dry_run and not prealables(journal, base):
return 1
return 1, ""
acces = journal_du_cache()
if not acces:
@ -1124,7 +1282,7 @@ def main(argv=None):
journal,
)
if not args.dry_run:
return 1
return 1, ""
rapport = {
"_fichier": chemin_rapport(),
@ -1151,7 +1309,10 @@ def main(argv=None):
if acces and os.path.exists(acces):
decalage = os.path.getsize(acces)
return _boucle(args, rapport, journal, acces, decalage)
return (
_boucle(args, rapport, journal, acces, decalage),
rapport["_fichier"],
)
def _boucle(args, rapport, journal, acces, decalage):
@ -1205,6 +1366,9 @@ def _boucle(args, rapport, journal, acces, decalage):
}
ecrire_rapport(rapport)
dire(f" VM {rang} : paquets posés en {duree:.0f} s", journal)
# Sa mesure est dans le rapport : allumée, elle ne sert plus à rien et
# tient sa mémoire pendant que la suivante s'installe.
eteindre(nom, journal, args.dry_run)
ok = True
if args.dry_run:
@ -1235,7 +1399,9 @@ def _boucle(args, rapport, journal, acces, decalage):
and ok
)
return _clore(args, rapport, journal, ok)
return _clore(
args, rapport, journal, ok, rapport.pop("etape_en_echec", "")
)
def _clore(args, rapport, journal, ok, etape=""):

View file

@ -37,6 +37,11 @@ rich
# Il n'arrivait que par pykeepass / openupgradelib / odoo-module-migrator, donc
# un jour où l'un d'eux s'en passe, il disparaît sans que rien ne le réclame.
lxml
# Classeurs Excel de « Transform data », et les tests qui les éprouvent. Le
# moteur garde son venv dédié (script/todo/transform_setup.py) : un
# .venv.erplibre installé avant cette ligne n'a pas openpyxl, et le code ne
# l'importe donc jamais au niveau d'un module.
openpyxl
python-dotenv
python-dateutil
unidecode

View file

@ -19,9 +19,7 @@ Les formats en pur stdlib — CSV, JSON, XML, et la détection de macros qui
n'est qu'un `zipfile.namelist()` — tournent sous l'interpréteur du CLI.
Excel et Access exigent un venv dédié. Le module doit donc s'importer sous
les deux : AUCUN import de bibliothèque tierce au niveau du module, chacun
vit dans la fonction qui en a besoin. Les tests unitaires du dépôt tournent
sous `.venv.erplibre`, qui n'a pas openpyxl : un import au niveau du module
les ferait tomber tous, y compris ceux des règles pures.
vit dans la fonction qui en a besoin.
Le canal de sortie
------------------

View file

@ -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}"
@ -38,7 +40,19 @@ EL_BYPASS_FILE="${EL_BYPASS_FILE:-/etc/erplibre_go_qemu_cache/bypass}"
EL_GIT_MIRROR_DIR="${EL_GIT_MIRROR_DIR:-${EL_CACHE_DIR:-/var/cache/erplibre_go_qemu_cache}/git}"
BIN="/usr/local/bin/erplibre_go_qemu_cache"
CONF_DIR="/etc/erplibre_go_qemu_cache"
CONF_DIR="${EL_CONF_DIR:-/etc/erplibre_go_qemu_cache}"
PURGE_UNIT="/etc/systemd/system/erplibre-go-qemu-cache-purge.service"
PURGE_TIMER="/etc/systemd/system/erplibre-go-qemu-cache-purge.timer"
# Les réglages de nettoyage choisis depuis le menu survivent à une
# réinstallation : le fichier env est réécrit en entier, et les retrouver vides
# changerait en silence le comportement choisi. Une valeur passée dans
# l'environnement, même vide, l'emporte — c'est ainsi qu'on les désactive.
reglage_existant() {
sed -n "s/^$1=//p" "${CONF_DIR}/env" 2>/dev/null | head -n 1
}
EL_PURGE_AGE="${EL_PURGE_AGE-$(reglage_existant EL_PURGE_AGE)}"
EL_MAX_SIZE="${EL_MAX_SIZE-$(reglage_existant EL_MAX_SIZE)}"
UNIT="/etc/systemd/system/erplibre-go-qemu-cache.service"
SERVICE_USER="elqcache"
GO_MIN_MAJOR=1
@ -264,6 +278,13 @@ 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}
# Nettoyage automatique, chaque jour ; vide = désactivé. Réglable depuis le
# menu du cache de todo.py, sans réinstaller.
# EL_PURGE_AGE : ce qui n'a pas servi depuis ce délai part (ex. 90j).
# EL_MAX_SIZE : au-delà, le moins récemment servi part (ex. 50G).
EL_PURGE_AGE=${EL_PURGE_AGE}
EL_MAX_SIZE=${EL_MAX_SIZE}
CONF
chmod 0644 "${CONF_DIR}/env"
}
@ -322,6 +343,46 @@ UNITE
systemctl daemon-reload
}
ecrire_purge() {
# Un minuteur quotidien qui ne fait RIEN tant que les deux réglages sont
# vides : le nettoyage reste manuel par défaut, et le régler suffit à
# l'activer, sans réinstaller. Sous le compte du service, propriétaire du
# magasin : un effacement en root laisserait des répertoires que le service
# ne pourrait plus écrire. « $$ » rend un « $ » au shell : la variable vient
# de l'EnvironmentFile au moment de l'exécution, jamais de ce script.
cat >"$PURGE_UNIT" <<UNITE
[Unit]
Description=Nettoyage automatique du cache de téléchargement ERPLibre
After=erplibre-go-qemu-cache.service
[Service]
Type=oneshot
EnvironmentFile=${CONF_DIR}/env
User=${SERVICE_USER}
Group=${SERVICE_USER}
ExecStart=/bin/sh -c 'if [ -n "\$\$EL_PURGE_AGE" ]; then ${BIN} --cache-dir "\$\$EL_CACHE_DIR" --git-mirror-dir "\$\$EL_GIT_MIRROR_DIR" --lang "\$\$EL_LANG" --purge-older-than "\$\$EL_PURGE_AGE"; fi; if [ -n "\$\$EL_MAX_SIZE" ]; then ${BIN} --cache-dir "\$\$EL_CACHE_DIR" --git-mirror-dir "\$\$EL_GIT_MIRROR_DIR" --lang "\$\$EL_LANG" --purge-to-size "\$\$EL_MAX_SIZE"; fi'
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=${EL_CACHE_DIR} ${EL_GIT_MIRROR_DIR}
UNITE
cat >"$PURGE_TIMER" <<MINUTEUR
[Unit]
Description=Nettoyage quotidien du cache de téléchargement ERPLibre
[Timer]
OnCalendar=daily
Persistent=true
RandomizedDelaySec=1h
[Install]
WantedBy=timers.target
MINUTEUR
chmod 0644 "$PURGE_UNIT" "$PURGE_TIMER"
systemctl daemon-reload
}
# ---------------------------------------------------------------------------
main() {
@ -334,6 +395,8 @@ main() {
creer_autorite
ecrire_config
ecrire_unite
ecrire_purge
systemctl enable --now erplibre-go-qemu-cache-purge.timer >/dev/null 2>&1 || true
systemctl enable erplibre-go-qemu-cache.service >/dev/null 2>&1 || true
systemctl restart erplibre-go-qemu-cache.service

View file

@ -422,12 +422,14 @@ def table_posee_cmd(table: str = TABLE) -> str:
# chercher là plutôt que de recopier son chemin, qui est réglable.
CONF = "/etc/erplibre_go_qemu_cache/env"
# Les issues qui prouvent qu'un CORPS est en réserve. « fetched » n'en est
# pas : elle couvre aussi le « 304 » d'une revalidation, qui n'a pas de corps.
# Les issues qui prouvent qu'un CORPS est en réserve. « revalidated » en est :
# l'amont a confirmé la copie gardée, et c'est elle qui est servie. « fetched »
# n'en est pas : elle couvre aussi le « 304 » rendu à un client qui posait sa
# propre condition, sans corps.
# « stored-status » et « stale-status » n'en sont pas davantage : elles
# portent un objet de STATUT seul — une redirection ou un refus gardés, sans
# corps — et l'index d'une suite gardé en 404 ne la rend pas lisible.
ISSUES_EN_RESERVE = ("stored", "hit", "stale")
ISSUES_EN_RESERVE = ("stored", "hit", "stale", "revalidated")
def journal(conf: str = CONF) -> str:

View file

@ -2507,10 +2507,14 @@ CACHE_TRUST = {
"update-ca-trust",
"/etc/pki/tls/certs/ca-bundle.crt",
),
# openSUSE n'écrit aucun « /etc/ssl/certs/ca-certificates.crt » : son
# faisceau est « ca-bundle.pem ». Une variable qui vise un fichier absent
# fait échouer pip sur « Could not find a suitable TLS CA certificate
# bundle », en ligne comme hors ligne.
"zypper": (
"/etc/pki/trust/anchors",
"update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/ca-bundle.pem",
),
}
@ -2523,6 +2527,26 @@ CACHE_TRUST = {
# disparaît alors que la VM garde sa variable.
CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
# Ce qu'une VM déployée l'amont du cache coupé reçoit en plus. L'audit de npm
# interroge un service distant qu'aucun cache ne peut rejouer : hors ligne il
# échoue à chaque installation sans rien vérifier. La variable reste dans la
# VM après son installation ; un « npm config set audit true » la contredit.
# Séparée de CACHE_ENV_VARS, que le binaire du cache recopie et que le test
# d'accord avec le Go compare.
OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),)
# Ce qu'une VM déployée l'amont coupé exécute tôt au démarrage. Une image qui
# active systemd-time-wait-sync — celle d'Arch — retient time-sync.target
# jusqu'à la première synchronisation NTP, et « cloud-final » est ordonné
# après : sans serveur de temps joignable, l'étape finale ne démarre jamais, ni
# les clés d'hôte ssh qu'elle génère, et la VM reste sans ssh. L'arrêter lève
# l'attente ; une image qui ne l'active pas n'en voit aucun effet. « bootcmd »
# tourne à l'étape réseau de cloud-init, avant l'étape finale.
OFFLINE_BOOTCMD = [
"bootcmd:",
" - systemctl stop --no-block systemd-time-wait-sync.service || true",
]
CACHE_CERT_NAME = "erplibre-cache.crt"
@ -2667,6 +2691,9 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
geste = (
f"{shlex.quote(CACHE_BIN)} --bypass-add {shlex.quote(mac)}"
f" --bypass-name {shlex.quote(args.name)}"
# La commande passe par sudo, qui retire EL_LANG : la langue du
# message du binaire va donc en option.
f" --lang {shlex.quote(langue_des_messages(args))}"
)
if shutil.which("nft"):
# Le binaire écrit le fichier et rend sur sa sortie le geste à chaud.
@ -2681,6 +2708,20 @@ def cache_bypass_apply(args: argparse.Namespace, runner: Runner) -> str:
return mac
def langue_des_messages(args: argparse.Namespace) -> str:
"""La langue des messages : --lang du déploiement, sinon celle de todo.py,
sinon le français. Rend toujours « fr » ou « en »."""
langue = (getattr(args, "lang", "") or "").strip().lower()
if not langue:
try:
from script.todo.todo_i18n import get_lang
langue = get_lang()
except Exception:
langue = "fr"
return "en" if langue.startswith("en") else "fr"
def cache_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""L'autorité du cache, posée par cloud-init à l'étape init.
@ -2742,9 +2783,49 @@ def cache_commands(args: argparse.Namespace) -> list[str]:
f"sh -c 'grep -q ^{var}= /etc/environment"
f" || echo {var}={faisceau} >> /etc/environment'"
)
gardees = list(CACHE_ENV_VARS)
if getattr(args, "offline", False):
for var, valeur in OFFLINE_ENV_VARS:
commandes.append(
f"sh -c 'grep -q ^{var}= /etc/environment"
f" || echo {var}={valeur} >> /etc/environment'"
)
gardees.append(var)
commandes.append(commande_sudoers(gardees))
return commandes
CACHE_SUDOERS = "/etc/sudoers.d/erplibre-cache"
def commande_sudoers(variables, fichier: str = CACHE_SUDOERS) -> str:
"""La commande qui fait traverser « sudo » aux variables du cache.
sudo remet l'environnement à zéro. Seul un module PAM qui relit
/etc/environment pour sudo y ramène les variables, et toutes les
distributions ne le configurent pas : sans lui, « sudo npm install -g »
rejette l'autorité du cache sur « self-signed certificate in certificate
chain », quand le même npm sans sudo l'accepte. « env_keep » les garde
partout, pourvu que la session appelante les porte — ce que
cache_env_reload lui assure.
Le fichier est écrit sous un nom à point, que sudo ignore, vérifié par
« visudo -c », puis renommé : un fichier invalide dans sudoers.d rendrait
sudo inutilisable sur toute la machine. Un échec — visudo absent, pas de
répertoire sudoers.d — retire le temporaire et ne fait pas échouer la
commande. Une variable par ligne, sans guillemets : la commande passe
telle quelle dans un « runcmd » YAML comme dans un « sh -c » par ssh.
"""
dossier, nom = fichier.rsplit("/", 1)
tmp = f"{dossier}/.{nom}"
return (
f"sh -c 'for v in {' '.join(variables)};"
f" do echo Defaults env_keep += $v; done > {tmp}"
f" && chmod 0440 {tmp} && visudo -cf {tmp} && mv {tmp} {fichier}"
f" || rm -f {tmp}'"
)
def cache_runcmd(args: argparse.Namespace) -> list[str]:
"""Les mêmes gestes, emballés en éléments de « runcmd » pour cloud-init."""
return [f" - {c}" for c in cache_commands(args)]
@ -2765,13 +2846,42 @@ def cache_env_reload(fichier: str = "/etc/environment") -> str:
séparateur final, sans effet quand le fichier est absent ou ne les porte
pas, et qui ne fait pas échouer une commande sous « set -e ».
"""
motif = "|".join(CACHE_ENV_VARS)
motif = "|".join(CACHE_ENV_VARS + tuple(v for v, _ in OFFLINE_ENV_VARS))
return (
f'if [ -r {fichier} ]; then eval "$(grep -E "^({motif})=" {fichier}'
' | sed "s/^/export /")"; fi'
)
def attente_cloud_final(bornes: int = 150) -> str:
"""Attend que l'ÉTAPE FINALE de cloud-init ait fini d'écrire.
« cloud-init status --wait » rend la main dès que cloud-init se déclare en
ERREUR, et un module accessoire suffit à l'y mettre — une locale que
l'invité ne connaît pas, par exemple. Son étape finale, elle, continue :
c'est elle qui pose l'autorité du cache, les variables de /etc/environment
et le fichier sudoers. Une session ouverte dans cette seconde-là vit sans
ces variables pour toute sa durée — PAM ne relit plus le fichier — et
« sudo » n'a alors rien à conserver, si bien qu'un npm lancé par sudo
rejette l'autorité du cache sur « self-signed certificate in certificate
chain ».
L'unité tranche, mais PAS par « is-active » : cloud-final est un service
« oneshot » qui reste ACTIF une fois terminé — is-active y est vrai pour
toujours, et attendre là-dessus coûte la borne entière à chaque VM, sans
rien détecter. Seul « activating » dit que l'étape écrit encore.
Bornée à `bornes` tours de deux secondes. Sans effet là où l'unité n'existe
pas : « show » y rend un état vide ou « inactive », jamais « activating » —
une image sans cloud-init, ou une VM déjà installée, n'attendent rien.
"""
return (
'n=0; while [ "$(systemctl show -p ActiveState --value'
' cloud-final.service 2>/dev/null)" = activating ]; do n=$((n+1));'
f" [ $n -ge {bornes} ] && break; sleep 2; done"
)
def guide_files(args: argparse.Namespace) -> list[tuple[str, str, str, str]]:
"""Fichiers d'accueil de la VM : le guide de connexion, l'identité git.
@ -2891,6 +3001,8 @@ def build_cloud_config(
# Active et démarre SSH quel que soit le nom du service (ssh sur
# Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter
# sans SSH accessible.
if getattr(args, "offline", False):
lines += OFFLINE_BOOTCMD
lines += ["runcmd:"]
# En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à
# jour un invité rejette le certificat que le cache présente.
@ -3991,16 +4103,12 @@ def virt_install(
"--network",
args.network,
"--console",
# Journal de console pour la voie installateur. Une console « pty »
# seule ne gardE rien : quand d-i échoue, il l'écrit à l'écran d'une
# VM que personne ne regarde, et il ne reste RIEN à lire ensuite —
# exactement « l'installation a échoué, pas de sortie pertinente ».
# Le fichier, lui, survit à l'arrêt du domaine.
(
f"pty,target_type={console_target},log.file={console_log}"
if installer
else f"pty,target_type={console_target}"
),
# Journal de console, pour toutes les voies. Une console « pty » seule
# ne garde rien : quand d-i échoue, ou qu'une image cloud reste bloquée
# avant son serveur ssh, la VM l'écrit à un écran que personne ne
# regarde, et il ne reste RIEN à lire ensuite. Le fichier, lui, survit
# à l'arrêt du domaine.
f"pty,target_type={console_target},log.file={console_log}",
# Canal virtio de l'agent invité (org.qemu.guest_agent.0) : permet à
# virsh de piloter la VM SANS réseau (ex. étendre le FS invité après
# un redimensionnement de disque). Inoffensif si l'agent est absent.
@ -4479,6 +4587,13 @@ def build_parser() -> argparse.ArgumentParser:
"cette autorité dès son premier démarrage et ses téléchargements "
"passent par le cache. Absent, rien n'est posé.",
)
g_cloud.add_argument(
"--offline",
action="store_true",
help="La VM est déployée l'amont du cache coupé : ce qu'aucun cache ne "
"peut rejouer y est désactivé, l'audit de npm d'abord. Sans effet "
"sans --cache-ca.",
)
g_cloud.add_argument(
"--cache-bypass",
action="store_true",

View file

@ -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é."))
}

View file

@ -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 »

View file

@ -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
}

View file

@ -0,0 +1,164 @@
// © 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{
"taille vide": "empty size",
"taille illisible %q : essayer 50G, 500M, 2T": "unreadable size %q: try 50G, 500M, 2T",
"effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)": "erase the least recently served until the cache fits under this size (e.g. 50G)",
"purge au plafond : %v\n": "size purge: %v\n",
"[à blanc] ": "[dry run] ",
"plafond %s : le cache occupe %s\n": "ceiling %s: the cache occupies %s\n",
"%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n": "%sobjects: %d, %s; repositories: %d, %s; %s left\n",
"cache : variante de %s non gardée : %v": "cache: variant of %s not kept: %v",
"langue des messages : fr ou en ; à défaut, EL_LANG": "language of messages: fr or en; otherwise EL_LANG",
"granularité inconnue %q : jour, semaine ou mois": "unknown granularity %q: day, week or month",
"durée vide": "empty duration",
"durée illisible %q": "unreadable duration %q",
"durée illisible %q : essayer 30j, 12h, 90m": "unreadable duration %q: try 30d, 12h, 90m",
"Â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 refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest",
"tunnel vers %s : %v": "tunnel to %s: %v",
"ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)",
"longueur d'enregistrement invraisemblable : %d": "implausible record length: %d",
"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",
}

View file

@ -55,6 +55,11 @@ var immutableSuffixes = []string{
".pkg.tar.zst.sig", ".pkg.tar.xz.sig",
// écosystèmes Python et Node
".whl", ".tgz",
// métadonnées d'une distribution Python servies à part (PEP 658) : le nom
// est celui de l'archive suivi de « .metadata », sous le même chemin
// d'empreinte. Sans ces suffixes, « .metadata » ne correspond à rien et le
// fichier tombe dans le volatil par défaut, repris à chaque installation.
".whl.metadata", ".tar.gz.metadata", ".zip.metadata",
// images et supports d'installation
".qcow2", ".iso", ".img", ".raw", ".vmdk",
// archives amont
@ -93,6 +98,20 @@ var volatileSuffixes = []string{
var parEmpreinte = regexp.MustCompile(
`/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`)
// parEmpreinteDeDepot reconnaît une métadonnée de dépôt RPM nommée par
// l'empreinte de son contenu : « …/repodata/<hexadécimal>-primary.xml.zck ».
// Seul « repomd.xml » y est volatile : il désigne la version COURANTE de
// chaque métadonnée par ce nom, si bien qu'un contenu nouveau porte un nom
// nouveau. Le fichier nommé est aussi figé qu'un paquet.
//
// Volatile, il est repris en entier à chaque installation — un « primary »
// pèse des dizaines de mégaoctets par dépôt. Et dnf télécharge un « .zck » par
// plages, qu'aucun volatile ne garde : amont coupé, la VM suivante n'avait
// rien. Figé, il sort du disque, et une plage y déclenche la prise du fichier
// entier (voir completer).
var parEmpreinteDeDepot = regexp.MustCompile(
`/repodata/[0-9a-fA-F]{32,128}-[^/]+$`)
// dernierePublication reconnaît « /<propriétaire>/<dépôt>/releases/latest/
// download/<fichier> » : un POINTEUR vers la dernière version publiée, dont
// la cible change à chaque publication.
@ -160,7 +179,7 @@ func Classify(u *url.URL) Class {
if dernierePublication.MatchString(u.Path) {
return ClassVolatile
}
if parEmpreinte.MatchString(u.Path) {
if parEmpreinte.MatchString(u.Path) || parEmpreinteDeDepot.MatchString(u.Path) {
return ClassImmutable
}
for _, n := range volatileNames {
@ -202,7 +221,7 @@ func PortableParChemin(u *url.URL) bool {
// POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une
// somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne
// suffirait donc pas à le rendre portable.
if parEmpreinte.MatchString(u.Path) {
if parEmpreinte.MatchString(u.Path) || parEmpreinteDeDepot.MatchString(u.Path) {
return true
}
name := strings.ToLower(path.Base(u.Path))

View file

@ -24,6 +24,10 @@ func TestClassify(t *testing.T) {
ClassImmutable, "paquet Fedora"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl",
ClassImmutable, "roue Python"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata",
ClassImmutable, "métadonnées d'une roue (PEP 658)"},
{"https://pypi.example/packages/ab/cd/requests-2.33.0.tar.gz.metadata",
ClassImmutable, "métadonnées d'une archive source (PEP 658)"},
{"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2",
ClassImmutable, "image cloud"},
@ -205,6 +209,36 @@ func TestLesIndexParEmpreinteSontImmuables(t *testing.T) {
}
}
// Une métadonnée RPM nommée par sa somme est figée, quelle que soit sa
// compression ; « repomd.xml », qui la désigne, reste volatile, et un nom sans
// somme en tête garde la règle de son suffixe.
func TestLesMetadonneesRPMParEmpreinteSontImmuables(t *testing.T) {
somme := strings.Repeat("0123456789abcdef", 4)
for _, nom := range []string{
somme + "-primary.xml.zck", somme + "-primary.xml.gz",
somme + "-updateinfo.xml.zst", somme + "-comps-BaseOS.x86_64.xml",
} {
u, _ := url.Parse("https://miroir.example/fedora/linux/updates/42/Everything/x86_64/repodata/" + nom)
if got := Classify(u); got != ClassImmutable {
t.Errorf("%s classé « %s », attendu « immutable »", nom, got)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas jugé portable", nom)
}
}
for _, brut := range []string{
"https://miroir.example/fedora/repodata/repomd.xml",
"https://miroir.example/fedora/repodata/primary.xml.gz",
"https://miroir.example/fedora/repodata/pas-une-somme-primary.xml.zck",
"https://miroir.example/fedora/ailleurs/" + somme + "-primary.xml.zck",
} {
u, _ := url.Parse(brut)
if got := Classify(u); got != ClassVolatile {
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
}
}
}
// Le même index par empreinte, servi par deux miroirs. Sans clé portable,
// changer de miroir vide le cache de ses index : une installation hors ligne
// échoue alors sur des octets que le magasin détient pourtant, et le message
@ -247,3 +281,20 @@ func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) {
t.Errorf("une version nommée classée « %s », attendu « immutable »", got)
}
}
// Les métadonnées PEP 658 se rangent sans leur hôte, comme l'archive qu'elles
// décrivent : leur chemin porte déjà l'empreinte du contenu.
func TestLesMetadonneesPythonSontPortables(t *testing.T) {
for _, brut := range []string{
"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl.metadata",
"https://pypi.example/packages/ab/cd/requests-2.33.0.zip.metadata",
} {
u, err := url.Parse(brut)
if err != nil {
t.Fatal(err)
}
if !PortableParChemin(u) {
t.Errorf("%s n'est pas portable", brut)
}
}
}

View file

@ -0,0 +1,139 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"bytes"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"time"
)
// amontAPlages sert un corps fixe et honore « Range » comme un miroir : 206
// et le seul fragment demandé. Il compte séparément les plages et les corps
// entiers envoyés.
type amontAPlages struct {
srv *httptest.Server
plages int64
entiers int64
}
func nouvelAmontAPlages(t *testing.T, corps string) *amontAPlages {
t.Helper()
a := &amontAPlages{}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Range") != "" {
atomic.AddInt64(&a.plages, 1)
} else {
atomic.AddInt64(&a.entiers, 1)
}
http.ServeContent(w, r, "", time.Time{}, bytes.NewReader([]byte(corps)))
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amontAPlages) hote() string {
return strings.TrimPrefix(a.srv.URL, "http://")
}
func demandePlage(t *testing.T, p *Proxy, hote, chemin, plage string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", chemin, nil)
r.Host = hote
r.Header.Set("Range", plage)
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
const metadonneeZck = "/fedora/updates/repodata/" +
"0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef" +
"-primary.xml.zck"
// Une plage sur une métadonnée figée absente fait prendre le fichier entier,
// une fois ; les plages suivantes sortent du disque sans rien demander à
// l'amont.
func TestUnePlageFaitGarderLeFichierEntier(t *testing.T) {
a := nouvelAmontAPlages(t, "en-tete|morceau-1|morceau-2")
p := proxyDeTest(t)
w := demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-6")
if w.Code != http.StatusPartialContent || w.Body.String() != "en-tete" {
t.Fatalf("première plage : %d %q", w.Code, w.Body.String())
}
p.attendreCompletions()
w = demandePlage(t, p, a.hote(), metadonneeZck, "bytes=8-16")
if w.Code != http.StatusPartialContent || w.Body.String() != "morceau-1" {
t.Errorf("seconde plage : %d %q", w.Code, w.Body.String())
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit {
t.Errorf("seconde plage servie « %s », attendu « %s »", got, OutcomeHit)
}
if n := atomic.LoadInt64(&a.entiers); n != 1 {
t.Errorf("%d corps entiers pris à l'amont, attendu 1", n)
}
if n := atomic.LoadInt64(&a.plages); n != 1 {
t.Errorf("%d plages envoyées à l'amont, attendu 1", n)
}
}
// Amont coupé, la plage d'une VM suivante sort du disque : c'est ce qui manquait
// à une installation hors ligne.
func TestHorsLigneLaPlageSortDuDisque(t *testing.T) {
a := nouvelAmontAPlages(t, "en-tete|morceau-1|morceau-2")
p := proxyDeTest(t)
hote := a.hote()
demandePlage(t, p, hote, metadonneeZck, "bytes=0-6")
p.attendreCompletions()
a.srv.Close()
w := demandePlage(t, p, hote, metadonneeZck, "bytes=18-26")
if w.Code != http.StatusPartialContent || w.Body.String() != "morceau-2" {
t.Errorf("hors ligne : %d %q", w.Code, w.Body.String())
}
}
// Un index volatile demandé par plage ne déclenche aucune prise : il n'est
// jamais servi du disque tant que l'amont répond, le garder n'épargnerait rien.
func TestUnePlageSurUnIndexNePrendRien(t *testing.T) {
a := nouvelAmontAPlages(t, "index de dépôt")
p := proxyDeTest(t)
demandePlage(t, p, a.hote(), "/fedora/repodata/repomd.xml", "bytes=0-4")
p.attendreCompletions()
if n := atomic.LoadInt64(&a.entiers); n != 0 {
t.Errorf("%d corps entiers pris pour un index volatile, attendu 0", n)
}
}
// Plusieurs plages simultanées sur le même fichier ne prennent le corps entier
// qu'une fois.
func TestDesPlagesSimultaneesNePrennentQuUneFois(t *testing.T) {
a := nouvelAmontAPlages(t, strings.Repeat("x", 1<<16))
p := proxyDeTest(t)
u, _ := url.Parse("http://" + a.hote() + metadonneeZck)
key := CleDe("GET", u)
p.completions.Store(key, true) // une prise est déjà en cours
for i := 0; i < 5; i++ {
demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-9")
}
p.attendreCompletions()
if n := atomic.LoadInt64(&a.entiers); n != 0 {
t.Errorf("%d prises lancées alors qu'une était en cours, attendu 0", n)
}
p.completions.Delete(key)
demandePlage(t, p, a.hote(), metadonneeZck, "bytes=0-9")
p.attendreCompletions()
if n := atomic.LoadInt64(&a.entiers); n != 1 {
t.Errorf("%d prises après la fin de la première, attendu 1", n)
}
}

View file

@ -97,6 +97,7 @@ func (s *Store) Detenir(methode, brut string) Detention {
return d
}
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
s.corpsDuGET(&d, u)
return d
}
d.Verdict = VerdictAbsent
@ -106,10 +107,24 @@ func (s *Store) Detenir(methode, brut string) Detention {
}
if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() {
d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt
return d
}
if methode == "HEAD" {
s.corpsDuGET(&d, u)
}
return d
}
// corpsDuGET marque « garde » un HEAD dont le GET de la même URL a son corps
// en réserve : hors ligne, le service répond au HEAD avec les en-têtes de ce
// corps, APRÈS le statut gardé du HEAD lui-même. Sans corps, le verdict reste
// celui que l'appelant a posé.
func (s *Store) corpsDuGET(d *Detention, u *url.URL) {
if m, ok := s.lirePresent(CleDe("GET", u)); ok && !m.StatutSeul() {
d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt
}
}
// lirePresent rend le méta d'une clé quand l'objet est complet : méta lisible
// et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié.
func (s *Store) lirePresent(key string) (*Meta, bool) {

View file

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

View file

@ -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")
}

View 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
}

View 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)
}
}

View file

@ -25,75 +25,83 @@ import (
"time"
)
const version = "0.2.4"
const version = "0.2.12"
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)"))
purgeTaille = flag.String("purge-to-size", "",
T("effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)"))
gitList = flag.Bool("git-mirror-list", false,
"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 +113,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 +124,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 +157,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 +215,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 +226,40 @@ 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 *purgeTaille != "" {
plafond, err := LireTaille(*purgeTaille)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
b, err := PurgerJusqua(store, miroir, plafond, *dryRun)
if err != nil {
fmt.Fprintf(os.Stderr, T("purge au plafond : %v\n"), err)
}
prefixe := ""
if *dryRun {
prefixe = T("[à blanc] ")
}
fmt.Printf(T("plafond %s : le cache occupe %s\n"), HumanBytes(plafond), HumanBytes(b.Avant))
fmt.Printf(T("%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n"), prefixe,
b.Objets, HumanBytes(b.OctetsObjets), b.Depots, HumanBytes(b.OctetsDepots),
HumanBytes(b.Apres))
return
}
if *gitList {
depots := miroir.Depots()
if len(depots) == 0 {
fmt.Println("aucun dépôt en miroir")
fmt.Println(T("aucun dépôt en miroir"))
return
}
var total int64
@ -240,7 +268,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 +284,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 +322,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 +336,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 +349,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 +370,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 +395,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 +425,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)
}

View file

@ -26,6 +26,7 @@ import (
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
@ -58,12 +59,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 {
@ -233,8 +234,27 @@ type Refusals struct {
// Seuil : combien de poignées de main de suite doivent échouer avant de
// conclure. Nul, la valeur par défaut s'applique.
Seuil int
// Rafale : deux coupures plus rapprochées que cela ne comptent que pour
// une. Nulle, la valeur par défaut s'applique ; négative, chaque coupure
// compte, ce dont un test se sert pour condamner sans attendre.
Rafale time.Duration
// Maintenant rend l'instant courant. Nulle, time.Now : un test injecte
// son horloge pour espacer des coupures sans dormir.
Maintenant func() time.Time
// dernier : quand a été COMPTÉE la dernière coupure d'un hôte, ce qui
// distingue une rafale d'un échec qui se répète.
dernier map[string]time.Time
}
// RafaleParDefaut : en deçà, deux poignées de main coupées sont le MÊME
// incident.
//
// apt ouvre plusieurs connexions de front vers un dépôt et ferme celles dont
// il ne se sert pas : trois coupures dans la même seconde atteignaient le
// seuil sans que rien n'ait rejeté notre certificat. Un client qui refuse
// vraiment réessaie et échoue encore, à des secondes de là.
const RafaleParDefaut = 2 * time.Second
// SeuilParDefaut : trois échecs de suite avant de renoncer à déchiffrer.
//
// Un client qui ne nous fera jamais confiance échoue à CHAQUE fois ; une
@ -269,8 +289,10 @@ func NewRefusals(static []string) *Refusals {
declares: map[string]bool{},
apprises: map[string]refus{},
echecs: map[string]int{},
dernier: map[string]time.Time{},
Oubli: OubliParDefaut,
Seuil: SeuilParDefaut,
Rafale: RafaleParDefaut,
}
for _, h := range static {
if h = strings.TrimSpace(strings.ToLower(h)); h != "" {
@ -309,7 +331,7 @@ func (r *Refusals) Has(host string) bool {
if vu.alerte {
return true
}
if r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli {
if r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli {
r.oublier(host)
return false
}
@ -322,6 +344,7 @@ func (r *Refusals) Has(host string) bool {
func (r *Refusals) oublier(host string) {
delete(r.apprises, host)
delete(r.echecs, host)
delete(r.dernier, host)
}
// Echec note une poignée de main manquée et dit si l'hôte passe en tunnel.
@ -343,21 +366,50 @@ func (r *Refusals) Echec(host string, raison error) bool {
seuil = SeuilParDefaut
}
alerte := estRefusTLS(raison)
maintenant := r.maintenant()
// Une rafale ne compte qu'une fois. Sans cela, un client qui ouvre
// plusieurs connexions de front et ferme les inutiles atteint le seuil
// seul : l'hôte passe en tunnel, le magasin cesse de le servir, et le
// client qui attend sur ce tunnel n'a plus rien pour s'en sortir.
if !alerte {
if precedent, vu := r.dernier[host]; vu &&
maintenant.Sub(precedent) < r.rafale() {
return false
}
r.dernier[host] = maintenant
}
r.echecs[host]++
if !alerte && r.echecs[host] < seuil {
return false
}
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
// rouvrira. Un seuil atteint sur des coupures reste un soupçon, même
// répété trois fois.
r.apprises[host] = refus{quand: time.Now(), alerte: alerte}
r.apprises[host] = refus{quand: maintenant, alerte: alerte}
return true
}
// maintenant rend l'instant courant, celui de l'horloge injectée s'il y en a.
func (r *Refusals) maintenant() time.Time {
if r.Maintenant != nil {
return r.Maintenant()
}
return time.Now()
}
// rafale rend la distance en deçà de laquelle deux coupures n'en font qu'une.
// Négative, elle les fait toutes compter.
func (r *Refusals) rafale() time.Duration {
if r.Rafale != 0 {
return r.Rafale
}
return RafaleParDefaut
}
// Reussite efface le compte d'un hôte : la coupure d'avant n'était qu'un
// incident, et deux incidents éloignés ne doivent pas s'additionner jusqu'au
// seuil.
@ -365,6 +417,7 @@ func (r *Refusals) Reussite(host string) {
r.mu.Lock()
defer r.mu.Unlock()
delete(r.echecs, host)
delete(r.dernier, host)
}
func (r *Refusals) List() []string {
@ -375,7 +428,7 @@ func (r *Refusals) List() []string {
out = append(out, h)
}
for h, vu := range r.apprises {
if !vu.alerte && r.Oubli > 0 && time.Since(vu.quand) >= r.Oubli {
if !vu.alerte && r.Oubli > 0 && r.maintenant().Sub(vu.quand) >= r.Oubli {
r.oublier(h)
continue
}
@ -476,7 +529,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 +613,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 +621,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 +634,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
}
@ -596,10 +649,28 @@ func (t *TLSFront) tunnel(c net.Conn, host string) bool {
Client: clientDe(c.RemoteAddr().String()),
})
// Un tunnel qui se referme ne laissait AUCUNE trace : le client qui
// attendait dessus paraissait bloqué sans cause, et rien au journal ne
// disait lequel des deux bouts s'était taire. La durée et les octets par
// direction le disent — un tunnel qui meurt sans avoir rien rendu à
// l'invité est l'empreinte d'un amont qui coupe.
debut := time.Now()
var versAmont, versClient atomic.Int64
done := make(chan struct{}, 2)
go func() { io.Copy(up, c); done <- struct{}{} }()
go func() { io.Copy(c, up); done <- struct{}{} }()
go func() {
n, _ := io.Copy(up, c)
versAmont.Store(n)
done <- struct{}{}
}()
go func() {
n, _ := io.Copy(c, up)
versClient.Store(n)
done <- struct{}{}
}()
<-done
log.Printf(T("tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité"),
dst, time.Since(debut).Round(time.Second),
HumanBytes(versAmont.Load()), HumanBytes(versClient.Load()))
return false
}
@ -612,11 +683,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 {

View file

@ -210,6 +210,7 @@ func TestPremierEnregistrementNonTLS(t *testing.T) {
// produit une VM qui démarre et coupe. Seule la répétition les sépare.
func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) {
r := NewRefusals(nil)
r.Rafale = -1 // chaque coupure compte : c'est le SEUIL qu'on éprouve ici
coupure := errors.New("read: connection reset by peer")
for i := 1; i < r.Seuil; i++ {
if r.Echec("miroir.example", coupure) {
@ -223,9 +224,14 @@ func TestUneCoupureIsoleeNeCondamnePas(t *testing.T) {
func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) {
r := NewRefusals(nil)
// Des coupures ESPACÉES, telles qu'un client qui réessaie les produit :
// l'horloge avance entre chacune, sans faire dormir le test.
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
coupure := errors.New("EOF")
for i := 0; i < r.Seuil; i++ {
r.Echec("npm.example", coupure)
horloge = horloge.Add(10 * time.Second)
}
if !r.Has("npm.example") {
t.Error("un client qui échoue à chaque fois n'est jamais mis en" +
@ -244,6 +250,7 @@ func TestUneCoupureRepeteeFinitParCondamner(t *testing.T) {
func TestUnRefusDeTransportSeRouvre(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
r.Rafale = -1
coupure := errors.New("local error: tls: bad record MAC")
for i := 0; i < r.Seuil; i++ {
r.Echec("miroir.example", coupure)
@ -275,6 +282,7 @@ func TestLOubliRemetLeCompteAZero(t *testing.T) {
t.Run(nom, func(t *testing.T) {
r := NewRefusals(nil)
r.Oubli = 50 * time.Millisecond
r.Rafale = -1
for i := 0; i < r.Seuil; i++ {
r.Echec("registre.example", io.EOF)
}
@ -307,6 +315,7 @@ func TestUnTunnelSansAmontSeRabatSurLeDechiffrement(t *testing.T) {
const hote = "registre.example.invalid"
r := NewRefusals(nil)
r.Rafale = -1
for i := 0; i < r.Seuil; i++ {
r.Echec(hote, io.EOF)
}
@ -367,6 +376,7 @@ func TestLOubliEstActifParDefaut(t *testing.T) {
// s'additionner jusqu'au seuil.
func TestUneReussiteEffaceLeCompte(t *testing.T) {
r := NewRefusals(nil)
r.Rafale = -1
coupure := errors.New("EOF")
for i := 1; i < r.Seuil; i++ {
r.Echec("h.example", coupure)
@ -380,6 +390,53 @@ func TestUneReussiteEffaceLeCompte(t *testing.T) {
}
}
// Une RAFALE est un seul incident. apt ouvre plusieurs connexions de front
// vers un dépôt et ferme celles dont il ne se sert pas : trois coupures dans
// la même seconde condamnaient l'hôte sans que rien n'ait rejeté notre
// certificat. Le client repartait alors sur un tunnel opaque, et son
// « apt-get update » y attendait jusqu'au délai — quatre heures.
func TestUneRafaleDeCoupuresNeCondamnePas(t *testing.T) {
r := NewRefusals(nil)
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
coupure := errors.New("read: connection reset by peer")
// Dix connexions fermées dans le même instant : un seul incident.
for i := 0; i < 10; i++ {
if r.Echec("deb.example", coupure) {
t.Fatalf("condamné sur la coupure %d d'une même rafale", i+1)
}
}
if r.Has("deb.example") {
t.Fatal("une rafale simultanée condamne l'hôte")
}
// Le temps passe : les coupures suivantes sont d'autres incidents, et le
// seuil finit par tomber.
for i := 1; i < r.Seuil; i++ {
horloge = horloge.Add(RafaleParDefaut + time.Second)
r.Echec("deb.example", coupure)
}
if !r.Has("deb.example") {
t.Error("des coupures espacées ne condamnent plus : un client qui" +
" rejette vraiment notre autorité ne passerait jamais en tunnel")
}
}
// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat.
// La rafale ne la retient pas : dix alertes d'affilée valent une décision.
func TestUneAlerteTrancheMemeEnRafale(t *testing.T) {
r := NewRefusals(nil)
horloge := time.Now()
r.Maintenant = func() time.Time { return horloge }
if !r.Echec("epingleur.example", errRefus) {
t.Fatal("une alerte ne tranche pas dans le même instant qu'une autre")
}
if !r.Has("epingleur.example") {
t.Error("l'alerte n'est pas retenue")
}
}
// Une ALERTE tranche tout de suite : le client a REGARDÉ notre certificat.
func TestUneAlerteCondamneDesLePremierEchec(t *testing.T) {
r := NewRefusals(nil)

View file

@ -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
}

View file

@ -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"))
}

View file

@ -0,0 +1,142 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
)
// LireTaille lit une taille : un nombre, suivi ou non d'une unité binaire.
//
// « 50G », « 50Gio », « 50GiB », « 1.5 G », « 500M », « 2T » ou un nombre
// d'octets. Les unités valent des puissances de 1024 : c'est ce qu'affiche
// HumanBytes, et un plafond lu autrement que la taille affichée ne se
// comparerait pas à elle.
func LireTaille(s string) (int64, error) {
brut := strings.TrimSpace(s)
if brut == "" {
return 0, fmt.Errorf("%s", T("taille vide"))
}
t := strings.ToUpper(strings.ReplaceAll(brut, " ", ""))
for _, suffixe := range []string{"IB", "IO", "B", "O"} {
if strings.HasSuffix(t, suffixe) && len(t) > len(suffixe) {
t = strings.TrimSuffix(t, suffixe)
break
}
}
facteur := int64(1)
if n := len(t); n > 0 {
switch t[n-1] {
case 'K':
facteur = 1 << 10
case 'M':
facteur = 1 << 20
case 'G':
facteur = 1 << 30
case 'T':
facteur = 1 << 40
}
if facteur > 1 {
t = t[:n-1]
}
}
v, err := strconv.ParseFloat(t, 64)
if err != nil || v <= 0 {
return 0, fmt.Errorf(T("taille illisible %q : essayer 50G, 500M, 2T"), brut)
}
return int64(v * float64(facteur)), nil
}
// BilanPlafond dit ce qu'une purge au plafond a effacé, ou effacerait.
type BilanPlafond struct {
Objets int
OctetsObjets int64
Depots int
OctetsDepots int64
Avant int64
Apres int64
}
// candidat est un objet du magasin ou un dépôt en miroir, avec son dernier
// usage : ce qui décide de l'ordre d'éviction.
type candidat struct {
chemin string
octets int64
usage time.Time
depot bool
}
// PurgerJusqua efface le moins récemment servi, objets et dépôts confondus,
// jusqu'à ce que l'ensemble tienne sous le plafond. aBlanc compte sans rien
// effacer.
//
// Confondus, parce que le plafond porte sur le disque et que les deux s'y
// disputent la place : un dépôt oublié depuis des mois pèse plus que mille
// paquets servis récemment. L'usage d'un objet est la date de son corps, remise à
// jour à chaque service ; celle d'un dépôt, son dernier rafraîchissement.
// Rien ne part quand le cache tient déjà sous le plafond.
func PurgerJusqua(store *Store, miroir *GitMirror, plafond int64, aBlanc bool) (BilanPlafond, error) {
var b BilanPlafond
var tous []candidat
if store != nil && store.Dir != "" {
filepath.Walk(store.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(store.Dir, p, info) {
return filepath.SkipDir
}
if info.IsDir() || filepath.Ext(p) != ".body" {
return nil
}
tous = append(tous, candidat{p, info.Size(), info.ModTime(), false})
return nil
})
}
for _, d := range miroir.Depots() {
tous = append(tous, candidat{d.Chemin, d.Octets, d.Maj, true})
}
for _, c := range tous {
b.Avant += c.octets
}
b.Apres = b.Avant
if b.Avant <= plafond {
return b, nil
}
sort.SliceStable(tous, func(i, j int) bool { return tous[i].usage.Before(tous[j].usage) })
for _, c := range tous {
if b.Apres <= plafond {
break
}
if !aBlanc {
if c.depot {
if err := miroir.Retirer(c.chemin); err != nil {
return b, err
}
} else {
if err := os.Remove(c.chemin); err != nil {
continue
}
// Le méta part AVEC le corps : orphelin, il ferait croire à
// une copie présente.
os.Remove(strings.TrimSuffix(c.chemin, ".body") + ".meta")
}
}
b.Apres -= c.octets
if c.depot {
b.Depots++
b.OctetsDepots += c.octets
} else {
b.Objets++
b.OctetsObjets += c.octets
}
}
return b, nil
}

View file

@ -0,0 +1,136 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
)
func TestLireTaille(t *testing.T) {
cas := map[string]int64{
"50G": 50 << 30,
"50Gio": 50 << 30,
"50GiB": 50 << 30,
"1.5 G": 3 << 29,
"500M": 500 << 20,
"2t": 2 << 40,
"1024": 1024,
"64K": 64 << 10,
}
for brut, veut := range cas {
if got, err := LireTaille(brut); err != nil || got != veut {
t.Errorf("%q : %d (%v), attendu %d", brut, got, err, veut)
}
}
for _, brut := range []string{"", "abc", "5X", "-1G", "0"} {
if _, err := LireTaille(brut); err == nil {
t.Errorf("%q accepté", brut)
}
}
}
// objetGarde range un corps de n octets, daté d'il y a `age`, et rend sa clé.
func objetGarde(t *testing.T, s *Store, nom string, n int, age time.Duration) string {
t.Helper()
cle := Key("GET", "http://miroir.example/"+nom)
w, err := s.NewWriter(cle, Meta{URL: "http://miroir.example/" + nom, Method: "GET", Status: 200})
if err != nil {
t.Fatal(err)
}
w.Write([]byte(strings.Repeat("x", n)))
if err := w.Commit(int64(n)); err != nil {
t.Fatal(err)
}
_, corps := s.paths(cle)
quand := time.Now().Add(-age)
os.Chtimes(corps, quand, quand)
return cle
}
// Le moins récemment servi part d'abord, et la purge s'arrête dès que le
// cache tient sous le plafond : effacer plus rendrait de la place qu'on ne
// demandait pas, au prix de téléchargements repayés.
func TestLePlafondEffaceLePlusAncienDAbord(t *testing.T) {
s := &Store{Dir: t.TempDir()}
vieux := objetGarde(t, s, "vieux", 100, 90*24*time.Hour)
moyen := objetGarde(t, s, "moyen", 100, 30*24*time.Hour)
recent := objetGarde(t, s, "recent", 100, time.Hour)
b, err := PurgerJusqua(s, &GitMirror{}, 250, false)
if err != nil {
t.Fatal(err)
}
if b.Objets != 1 || b.Avant != 300 || b.Apres != 200 {
t.Errorf("bilan %+v, attendu 1 objet, 300 → 200", b)
}
if s.Detient(vieux) {
t.Error("le plus ancien est resté")
}
if !s.Detient(moyen) || !s.Detient(recent) {
t.Error("un objet plus récent est parti")
}
if metaPath, _ := s.paths(vieux); fichierExiste(metaPath) {
t.Error("le méta de l'objet effacé est resté")
}
}
func fichierExiste(p string) bool {
_, err := os.Stat(p)
return err == nil
}
func TestSousLePlafondRienNePart(t *testing.T) {
s := &Store{Dir: t.TempDir()}
cle := objetGarde(t, s, "seul", 100, 90*24*time.Hour)
b, _ := PurgerJusqua(s, &GitMirror{}, 1000, false)
if b.Objets != 0 || !s.Detient(cle) {
t.Errorf("sous le plafond, %d objet(s) effacé(s)", b.Objets)
}
}
func TestABlancRienNEstEfface(t *testing.T) {
s := &Store{Dir: t.TempDir()}
cle := objetGarde(t, s, "vieux", 100, 90*24*time.Hour)
objetGarde(t, s, "recent", 100, time.Hour)
b, _ := PurgerJusqua(s, &GitMirror{}, 150, true)
if b.Objets != 1 || b.Apres != 100 {
t.Errorf("bilan à blanc %+v, attendu 1 objet compté", b)
}
if !s.Detient(cle) {
t.Error("une purge à blanc a effacé un objet")
}
}
// Objets et dépôts se disputent le même disque : un dépôt oublié depuis des
// mois part avant des paquets servis récemment.
func TestUnDepotOublieCedeSaPlaceAvantLesObjets(t *testing.T) {
s := &Store{Dir: t.TempDir()}
objet := objetGarde(t, s, "paquet", 100, time.Hour)
m := &GitMirror{Dir: t.TempDir()}
depot := filepath.Join(m.Dir, "forge.example", "vieux.git")
if err := os.MkdirAll(depot, 0o755); err != nil {
t.Fatal(err)
}
os.WriteFile(filepath.Join(depot, "pack"), []byte(strings.Repeat("y", 500)), 0o644)
quand := time.Now().Add(-120 * 24 * time.Hour)
os.Chtimes(depot, quand, quand)
b, err := PurgerJusqua(s, m, 200, false)
if err != nil {
t.Fatal(err)
}
if b.Depots != 1 || b.Objets != 0 {
t.Errorf("bilan %+v, attendu le seul dépôt", b)
}
if fichierExiste(depot) {
t.Error("le dépôt oublié est resté")
}
if !s.Detient(objet) {
t.Error("l'objet récent est parti avant le dépôt oublié")
}
}

View file

@ -4,8 +4,10 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
@ -35,6 +37,7 @@ const (
// en réserve, et un refus gardé ne l'est pas.
OutcomeStoredStatus = "stored-status" // pris à l'amont, statut seul gardé
OutcomeStaleStatus = "stale-status" // amont muet, statut seul rejoué
OutcomeRevalidated = "revalidated" // l'amont confirme la copie (304), corps servi du disque
)
// AccessLog écrit une ligne JSON par requête. Un format à une ligne par
@ -133,6 +136,11 @@ type Proxy struct {
// vise l'un d'eux sur une adresse de cette machine est une boucle. Vide,
// rien n'est refusé.
Ecoutes []int
// completions tient les clés dont le corps entier est en cours de prise
// (voir completer) ; enCours les compte, pour qui doit les attendre.
completions sync.Map
enCours sync.WaitGroup
}
// NewProxy monte le client amont. Aucun délai GLOBAL n'est posé : une image
@ -201,7 +209,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
@ -243,6 +251,11 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
if p.serveFromStore(w, r, u, key, class, OutcomeHit) {
return
}
// La plage passe à l'amont et ne se garde pas ; le fichier entier est
// pris à part, pour que la plage suivante sorte du disque.
if partial && r.Method == "GET" {
p.completer(r, u, key, class)
}
}
// La négociation git n'est pas relayée quand un miroir peut la servir :
@ -287,6 +300,42 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
amont = sansCondition(r)
}
// Un index déjà gardé part avec SON validateur. L'amont reste juge de
// chaque requête — un index n'est jamais servi du disque sans son accord
// tant qu'il répond — mais une copie qu'il déclare à jour n'est pas
// retéléchargée : « 304 » ne porte aucun corps, et le nôtre sort du
// disque. Sans cela, chaque installation reprenait en entier des index
// de dizaines de mégaoctets qui n'avaient pas changé.
//
// L'ETag seul, jamais la date : sous « Vary: Accept », deux
// représentations d'une même URL partagent leur Last-Modified, et un
// « 304 » accordé sur la date validerait celle qui n'est pas gardée. Pas
// sous une clé portable non plus : partagée par tous les miroirs, elle
// présenterait à l'un le validateur d'un autre. La condition du client,
// quand il en pose une, reste la sienne.
//
// Sous « Vary: Accept », la représentation que CE client accepte a pu être
// rangée à part (voir CleVariante) : c'est son validateur qui part, et
// elle qui sort sur « 304 ».
cleVariante := ""
if cacheable && class == ClassVolatile && r.Method == "GET" && !partial {
cleVariante = CleVariante(key, r.Header.Get("Accept"))
}
varianteTenue := cleVariante != "" && p.Store.Detient(cleVariante)
cleRevalidee := key
if varianteTenue {
cleRevalidee = cleVariante
}
revalide := false
if cacheable && (detient || varianteTenue) && class == ClassVolatile &&
r.Method == "GET" && !partial && !conditionnelle && !PortableParChemin(u) {
if etag := p.etagGarde(cleRevalidee); etag != "" {
amont = r.Clone(r.Context())
amont.Header.Set("If-None-Match", etag)
revalide = true
}
}
// Un statut seul vit sous sa propre clé, que les lecteurs de corps ne
// calculent pas : voir CleStatut.
cleStatut := CleStatut(r.Method, u)
@ -300,6 +349,19 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
(detient || conditionnelle || p.Store.TientStatut(cleStatut))
resp, upErr := p.fetch(amont, u, repli)
if upErr == nil && revalide && resp.StatusCode == http.StatusNotModified {
resp.Body.Close()
// Effacée entre-temps — par un nettoyage —, la copie ne sort plus :
// la requête repart sans condition, et le client reçoit le corps de
// l'amont plutôt qu'un « 304 » qu'il n'a pas demandé.
if p.serveFromStore(w, r, u, cleRevalidee, class, OutcomeRevalidated) {
if cleRevalidee != key {
p.Store.Toucher(key)
}
return
}
resp, upErr = p.fetch(r, u, repli)
}
// Une redirection est SUIVIE quand le nom du fichier demandé porte déjà
// son identité, et le contenu est gardé sous l'URL DEMANDÉE.
//
@ -322,6 +384,13 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
// Un index plus récent que la signature qui l'annonce n'est PAS
// servi : voir indexIncoherent. Le client tombe alors sur le « 304 »
// qui le laisse garder ses listes, ou sur le refus qui suit.
// La représentation de CE client d'abord : la base porte la dernière
// rangée, qui peut être l'autre.
if cacheable && varianteTenue && !p.indexIncoherent(u, key) &&
p.serveFromStore(w, r, u, cleVariante, class, OutcomeStale) {
p.Store.Toucher(key)
return
}
if cacheable && !p.indexIncoherent(u, key) &&
p.serveFromStore(w, r, u, key, class, OutcomeStale) {
return
@ -354,6 +423,19 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) {
return
}
// Un HEAD sans statut gardé demande les en-têtes que rendrait le GET :
// le corps gardé pour ce GET les porte. zypper vérifie ainsi chaque
// dépôt avant de le lire, et un HEAD ne se garde jamais sous une clé
// portable ; sans ce repli, le dépôt dont le magasin tient l'index est
// déclaré invalide hors ligne. ServeContent n'écrit aucun corps pour
// un HEAD.
if cacheable && r.Method == http.MethodHead {
cleCorps := CleDe(http.MethodGet, u)
if !p.indexIncoherent(u, cleCorps) &&
p.serveFromStore(w, r, u, cleCorps, class, OutcomeStale) {
return
}
}
p.offlineMiss(
w, u, class, r.Method, clientDe(r.RemoteAddr), upErr,
)
@ -405,7 +487,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,12 +526,19 @@ 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
} else {
outcome = OutcomeStored
// La base est publiée telle qu'avant ; sa copie sous la variante
// garde cette représentation quand l'autre la remplacera.
if cleVariante != "" && varieSurAccept(resp.Header) {
if verr := p.Store.Copier(key, cleVariante); verr != nil {
log.Printf(T("cache : variante de %s non gardée : %v"), u, verr)
}
}
}
case !cacheable:
outcome = OutcomePassthrough
@ -462,6 +551,112 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
})
}
// varieSurAccept dit si la réponse annonce varier selon l'en-tête Accept.
// « Vary » est une liste de noms d'en-têtes, insensible à la casse, et peut
// être répété.
func varieSurAccept(h http.Header) bool {
for _, v := range h.Values("Vary") {
for _, nom := range strings.Split(v, ",") {
if strings.EqualFold(strings.TrimSpace(nom), "Accept") {
return true
}
}
}
return false
}
// delaiCompletion borne la prise d'un corps entier en arrière-plan. Aucun
// client n'attend cette prise : sans borne, un amont qui cesse d'envoyer au
// milieu du corps la garderait ouverte pour toujours.
const delaiCompletion = time.Hour
// completer prend à l'amont, en arrière-plan, le corps ENTIER d'un fichier
// figé dont un client n'a demandé qu'une plage, et le garde sous sa clé.
//
// dnf télécharge ses métadonnées zchunk par plages, et pacman reprend de même
// un paquet interrompu : une plage ne se garde pas, si bien que sans cette
// prise ces fichiers repartiraient à l'amont à chaque VM — et, amont coupé, ne
// seraient pas là. Une fois gardé, le fichier sert toute plage depuis le
// disque.
//
// Une seule prise par clé à la fois, et la requête du client ne l'attend pas.
// Elle part sans plage ni condition, avec le seul agent de l'invité, et un
// amont connu muet n'est pas recomposé. Une prise manquée se retente à la
// plage suivante.
func (p *Proxy) completer(r *http.Request, u *url.URL, key string, class Class) {
if _, deja := p.completions.LoadOrStore(key, true); deja {
return
}
entete := http.Header{}
if agent := r.Header.Get("User-Agent"); agent != "" {
entete.Set("User-Agent", agent)
}
p.enCours.Add(1)
go func() {
defer p.enCours.Done()
defer p.completions.Delete(key)
p.prendreEntier(u, key, class, entete)
}()
}
// attendreCompletions rend la main quand plus aucune prise n'est en cours.
func (p *Proxy) attendreCompletions() {
p.enCours.Wait()
}
// prendreEntier fait la prise elle-même : un 200 entier, publié sous la clé
// par le même écrivain que le chemin du client, taille annoncée vérifiée. Tout
// autre statut, ou une clé devenue détenue entre-temps, ne publie rien.
func (p *Proxy) prendreEntier(u *url.URL, key string, class Class, entete http.Header) {
ctx, annuler := context.WithTimeout(context.Background(), delaiCompletion)
defer annuler()
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
if err != nil {
return
}
req.Header = entete
resp, err := p.fetch(req, u, true)
if err != nil {
return
}
resp = p.suivreRedirections(req, u, resp)
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK || p.Store.Detient(key) {
return
}
cw, err := p.Store.NewWriter(key, Meta{
URL: u.String(), Method: "GET", Status: resp.StatusCode,
Header: resp.Header.Clone(), Class: class.String(),
})
if err != nil {
log.Printf(T("cache : écriture impossible pour %s : %v"), u, err)
return
}
n, err := io.Copy(cw, resp.Body)
if err != nil {
cw.Abort()
return
}
if err := cw.Commit(resp.ContentLength); err != nil {
log.Printf(T("cache : %s non gardé : %v"), u, err)
return
}
p.record(accessLine{
Method: "GET", URL: u.String(), Class: class.String(),
Outcome: OutcomeStored, Status: resp.StatusCode, Bytes: n, Upstream: true,
})
}
// etagGarde rend l'ETag du corps gardé sous la clé, ou "" : rien de gardé,
// un statut seul, ou une réponse d'amont qui n'en portait pas.
func (p *Proxy) etagGarde(key string) string {
m, err := p.Store.LireMeta(key)
if err != nil || m.StatutSeul() {
return ""
}
return m.Header.Get("ETag")
}
// serveFromStore rend vrai quand la réponse est partie du disque.
func (p *Proxy) serveFromStore(
w http.ResponseWriter, r *http.Request, u *url.URL, key string,
@ -582,13 +777,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 +795,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

View file

@ -161,26 +161,22 @@ func TestDefautHorsLigneNommeLeFichier(t *testing.T) {
}
}
// Une requête partielle ne remplit pas le cache : un fragment ne sert à rien
// à la demande suivante, et le garder comme un corps entier servirait un
// paquet tronqué.
// Une requête partielle ne garde jamais SON fragment : le garder comme un
// corps entier servirait un paquet tronqué. Ce qui entre au cache est le corps
// entier, pris à part (voir completion_test.go).
func TestRequetePartielleNonGardee(t *testing.T) {
a := nouvelAmont(t, "0123456789")
a := nouvelAmontAPlages(t, "0123456789")
p := proxyDeTest(t)
chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst"
r := httptest.NewRequest("GET", chemin, nil)
r.Host = a.hote()
r.Header.Set("Range", "bytes=0-4")
p.serve(httptest.NewRecorder(), r, "http")
// Une demande entière ensuite doit ressortir à l'amont.
w := demande(t, p, a.hote(), chemin)
if got := w.Header().Get("X-ERPLibre-Cache"); got == OutcomeHit {
t.Error("un fragment est entré au cache et a été servi comme un corps entier")
if w := demandePlage(t, p, a.hote(), chemin, "bytes=0-4"); w.Body.String() != "01234" {
t.Fatalf("plage : %q", w.Body.String())
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2", n)
p.attendreCompletions()
w := demande(t, p, a.hote(), chemin)
if w.Body.String() != "0123456789" {
t.Errorf("demande entière servie %q : un fragment a été gardé", w.Body.String())
}
}

View file

@ -0,0 +1,207 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"testing"
)
// amontValidant sert un corps sous un ETag et rend « 304 » à qui présente le
// bon. Il compte les CORPS envoyés, qui sont ce que la revalidation épargne,
// et note la condition reçue à chaque requête.
type amontValidant struct {
srv *httptest.Server
mu sync.Mutex
etag string // vide : aucune ETag, seulement une date
corps string
corpsParti int
requetes int
conditions []string
}
func nouvelAmontValidant(t *testing.T, etag, corps string) *amontValidant {
t.Helper()
a := &amontValidant{etag: etag, corps: corps}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
a.requetes++
cond := r.Header.Get("If-None-Match")
if cond == "" && r.Header.Get("If-Modified-Since") != "" {
cond = "date:" + r.Header.Get("If-Modified-Since")
}
a.conditions = append(a.conditions, cond)
if a.etag == "" {
w.Header().Set("Last-Modified", "Mon, 14 Sep 2026 14:53:55 GMT")
} else {
w.Header().Set("ETag", a.etag)
if cond == a.etag {
w.WriteHeader(http.StatusNotModified)
return
}
}
a.corpsParti++
io.WriteString(w, a.corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amontValidant) changer(etag, corps string) {
a.mu.Lock()
defer a.mu.Unlock()
a.etag, a.corps = etag, corps
}
func (a *amontValidant) releve() (requetes, corps int, conditions []string) {
a.mu.Lock()
defer a.mu.Unlock()
return a.requetes, a.corpsParti, append([]string(nil), a.conditions...)
}
func (a *amontValidant) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
// indexPip est un index volatil rangé avec son hôte : la revalidation lui est
// ouverte. Le test l'éprouve avant de s'en servir, pour ne pas mesurer une
// règle d'exclusion à la place de celle qu'il nomme.
const indexPip = "/simple/requests/"
func exigerIndexRevalidable(t *testing.T, hote string) {
t.Helper()
u := &url.URL{Scheme: "http", Host: hote, Path: indexPip}
if Classify(u) != ClassVolatile {
t.Fatalf("%s n'est plus volatil : le test mesurerait autre chose", indexPip)
}
}
// Un index que l'amont déclare inchangé sort du disque, et son corps ne
// repart pas : c'est tout le gain. L'amont est pourtant interrogé à CHAQUE
// demande — un index n'est jamais servi sans son accord tant qu'il répond.
func TestUnIndexConfirmeParLAmontSortDuDisque(t *testing.T) {
a := nouvelAmontValidant(t, `"v1"`, "index pip, version 1")
exigerIndexRevalidable(t, a.hote())
p := proxyDeTest(t)
for i := 1; i <= 3; i++ {
w := demande(t, p, a.hote(), indexPip)
if w.Code != http.StatusOK || w.Body.String() != "index pip, version 1" {
t.Fatalf("demande %d : %d %q", i, w.Code, w.Body.String())
}
veut := OutcomeRevalidated
if i == 1 {
veut = "miss"
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != veut {
t.Errorf("demande %d servie « %s », attendu « %s »", i, got, veut)
}
}
requetes, corps, conditions := a.releve()
if requetes != 3 {
t.Errorf("l'amont a reçu %d requêtes, attendu 3 : il doit juger chacune", requetes)
}
if corps != 1 {
t.Errorf("%d corps envoyés, attendu 1 : la copie confirmée a été retéléchargée", corps)
}
if conditions[0] != "" || conditions[1] != `"v1"` || conditions[2] != `"v1"` {
t.Errorf("conditions reçues %q : la première sans, les suivantes avec l'ETag gardé", conditions)
}
}
// Un index qui a changé est repris en entier, remplace la copie, et c'est son
// NOUVEL ETag qui part ensuite.
func TestUnIndexChangeEstRepris(t *testing.T) {
a := nouvelAmontValidant(t, `"v1"`, "version 1")
exigerIndexRevalidable(t, a.hote())
p := proxyDeTest(t)
demande(t, p, a.hote(), indexPip)
a.changer(`"v2"`, "version 2")
if w := demande(t, p, a.hote(), indexPip); w.Body.String() != "version 2" {
t.Fatalf("après changement, corps %q", w.Body.String())
}
w := demande(t, p, a.hote(), indexPip)
if w.Body.String() != "version 2" || w.Header().Get("X-ERPLibre-Cache") != OutcomeRevalidated {
t.Errorf("troisième demande : %q servie « %s »", w.Body.String(), w.Header().Get("X-ERPLibre-Cache"))
}
_, corps, conditions := a.releve()
if corps != 2 {
t.Errorf("%d corps envoyés, attendu 2", corps)
}
if conditions[2] != `"v2"` {
t.Errorf("condition de la troisième demande %q, attendu l'ETag de la copie remplacée", conditions[2])
}
}
// Sans ETag, rien ne part : une date seule validerait, sous « Vary: Accept »,
// une représentation qui n'est pas celle gardée.
func TestSansETagLIndexRepartEntier(t *testing.T) {
a := nouvelAmontValidant(t, "", "index sans ETag")
exigerIndexRevalidable(t, a.hote())
p := proxyDeTest(t)
demande(t, p, a.hote(), indexPip)
w := demande(t, p, a.hote(), indexPip)
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("seconde demande servie « %s », attendu « miss »", got)
}
_, corps, conditions := a.releve()
if corps != 2 || conditions[1] != "" {
t.Errorf("%d corps, condition %q : attendu 2 corps et aucune condition", corps, conditions[1])
}
}
// La condition du client reste la sienne : il demande si SA copie a changé,
// et lui substituer l'ETag du cache répondrait à une autre question.
func TestLaConditionDuClientNEstPasRemplacee(t *testing.T) {
a := nouvelAmontValidant(t, `"v1"`, "version 1")
exigerIndexRevalidable(t, a.hote())
p := proxyDeTest(t)
demande(t, p, a.hote(), indexPip)
r := httptest.NewRequest("GET", indexPip, nil)
r.Host = a.hote()
r.Header.Set("If-None-Match", `"copie-du-client"`)
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusOK || w.Body.String() != "version 1" {
t.Errorf("client dont la copie diffère : %d %q", w.Code, w.Body.String())
}
_, _, conditions := a.releve()
if conditions[1] != `"copie-du-client"` {
t.Errorf("condition envoyée %q, attendu celle du client", conditions[1])
}
}
// Une clé sans hôte est partagée par tous les miroirs d'une liste qui tourne :
// présenter à l'un l'ETag gardé depuis un autre ne se fait pas, et l'index
// repart entier comme avant.
func TestUneCleSansHoteNeRevalidePas(t *testing.T) {
a := nouvelAmontValidant(t, `"v1"`, "base de paquets")
const chemin = "/arch/core/os/x86_64/core.db"
u := &url.URL{Scheme: "http", Host: a.hote(), Path: chemin}
if !PortableParChemin(u) || Classify(u) != ClassVolatile {
t.Fatalf("%s n'est plus un index volatil rangé sans hôte", chemin)
}
p := proxyDeTest(t)
demande(t, p, a.hote(), chemin)
w := demande(t, p, a.hote(), chemin)
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("seconde demande servie « %s », attendu « miss »", got)
}
_, corps, conditions := a.releve()
if corps != 2 || conditions[1] != "" {
t.Errorf("%d corps, condition %q : attendu 2 corps et aucune condition", corps, conditions[1])
}
}

View file

@ -151,9 +151,10 @@ func GuestTrustCommand(family string) (dir, cmd, bundle string, ok bool) {
"/etc/pki/tls/certs/ca-bundle.crt", true
case "zypper":
// openSUSE range ses ancres ailleurs que la famille RHEL, tout en
// employant la même commande que Debian.
// employant la même commande que Debian ; son faisceau n'est pas celui
// de Debian, et une variable qui vise un fichier absent casse pip.
return "/etc/pki/trust/anchors", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt", true
"/etc/ssl/ca-bundle.pem", true
}
return "", "", "", false
}

View file

@ -118,7 +118,7 @@ func TestCommandeDeConfiance(t *testing.T) {
},
"zypper": {
"/etc/pki/trust/anchors", "update-ca-certificates",
"/etc/ssl/certs/ca-certificates.crt",
"/etc/ssl/ca-bundle.pem",
},
}
for famille, attendu := range cas {

View file

@ -14,6 +14,7 @@ import (
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"sync/atomic"
@ -397,6 +398,46 @@ func TestLImmuableNeGardePasSonRefus(t *testing.T) {
}
}
// Hors ligne, un HEAD sur un index dont le GET a son corps en réserve reçoit
// les en-têtes de ce corps, sans corps : c'est ainsi que zypper juge un dépôt
// valide avant de le lire. Sous une clé portable, aucun statut de HEAD ne se
// garde, et sans ce repli le dépôt entier était déclaré invalide.
func TestHorsLigneUnHeadSortDuCorpsDuGet(t *testing.T) {
const corps = "<repomd>index</repomd>"
const chemin = "/distribution/leap/16.0/repo/oss/x86_64/repodata/repomd.xml"
a := nouvelAmontScripte(t, servir(corps))
p := proxyDeTest(t)
u, _ := url.Parse("http://" + a.hote() + chemin)
if Classify(u) != ClassVolatile || !PortableParChemin(u) {
t.Fatalf("%s n'est plus un index volatil portable", chemin)
}
joue(t, p, "GET", a.hote(), chemin)
joue(t, p, "HEAD", a.hote(), chemin)
base := "http://" + a.hote()
hote := a.couper()
w := joue(t, p, "HEAD", hote, chemin)
if w.Code != http.StatusOK {
t.Fatalf("HEAD hors ligne : code %d, attendu 200", w.Code)
}
if w.Body.Len() != 0 {
t.Errorf("HEAD hors ligne avec un corps de %d octets", w.Body.Len())
}
if got := w.Header().Get("Content-Length"); got != strconv.Itoa(len(corps)) {
t.Errorf("longueur %q, attendu celle du corps gardé (%d)", got, len(corps))
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictGarde {
t.Errorf("--detient dit %q pour un HEAD que le service sert", got)
}
// Sans GET gardé, rien ne change : le HEAD reste un 504.
if w := joue(t, p, "HEAD", hote, "/distribution/autre/repodata/repomd.xml"); w.Code != http.StatusGatewayTimeout {
t.Errorf("HEAD sans corps gardé : code %d, attendu 504", w.Code)
}
}
// Un HEAD garde son statut : un installateur lit la version publiée dans le
// « Location » d'un HEAD, et n'a besoin de rien d'autre.
//

View file

@ -118,6 +118,60 @@ func CleDe(method string, u *url.URL) string {
return Key(method, u.String())
}
// CleVariante rend la clé d'une REPRÉSENTATION de l'objet rangé sous key,
// celle que choisit l'en-tête Accept de la requête.
//
// Un amont qui répond « Vary: Accept » sert sous une même URL plusieurs corps
// selon ce que le client accepte : une fiche de paquet complète ou abrégée.
// Rangés sous la seule clé de l'URL, ils se remplacent l'un l'autre, si bien
// qu'une installation qui demande les deux tour à tour les retélécharge
// chaque fois. La clé de base reste écrite comme avant ; la variante s'y
// ajoute. Rend "" pour un Accept vide : la requête n'en choisit aucune.
func CleVariante(key, accept string) string {
accept = strings.Join(strings.Fields(strings.ToLower(accept)), " ")
if accept == "" {
return ""
}
sum := sha256.Sum256([]byte(key + " accept " + accept))
return hex.EncodeToString(sum[:])
}
// Copier publie sous dst le corps et les métadonnées rangés sous src, par le
// même temporaire et le même renommage qu'une écriture ordinaire : une copie
// interrompue ne laisse rien de visible. La date d'usage de src n'est pas
// touchée.
func (s *Store) Copier(src, dst string) error {
m, err := s.LireMeta(src)
if err != nil {
return err
}
_, bodyPath := s.paths(src)
f, err := os.Open(bodyPath)
if err != nil {
return err
}
defer f.Close()
w, err := s.NewWriter(dst, *m)
if err != nil {
return err
}
if _, err := io.Copy(w, f); err != nil {
w.Abort()
return err
}
return w.Commit(m.Size)
}
// Toucher remet à maintenant la date d'usage du corps rangé sous key, s'il
// existe. Servir une variante sert aussi l'objet de base : sans cela, un
// nettoyage par âge retirerait la base qu'on sert encore par sa variante, et
// --detient la dirait absente.
func (s *Store) Toucher(key string) {
_, bodyPath := s.paths(key)
maintenant := time.Now()
_ = os.Chtimes(bodyPath, maintenant, maintenant)
}
// CleStatut rend la clé sous laquelle un STATUT SEUL est rangé et cherché.
//
// Un espace de clés à part, et non la clé du corps : un lecteur qui ne
@ -272,7 +326,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 +460,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 +470,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

View file

@ -0,0 +1,200 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"strings"
"sync"
"testing"
"time"
)
// Les deux en-têtes Accept d'un client de registre : la fiche abrégée, et la
// fiche complète.
const (
acceptAbrege = "application/vnd.npm.install-v1+json; q=1.0, application/json; q=0.8, */*"
acceptComplet = "application/json"
)
// amontAVariantes sert deux représentations d'une même URL selon Accept, chacune
// sous son ETag, avec « Vary: Accept » ; « 304 » à qui présente l'ETag de la
// représentation qu'il demande. Il compte les corps envoyés par représentation.
type amontAVariantes struct {
srv *httptest.Server
mu sync.Mutex
vary bool
corps map[string]int
}
func nouvelAmontAVariantes(t *testing.T, vary bool) *amontAVariantes {
t.Helper()
a := &amontAVariantes{vary: vary, corps: map[string]int{}}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
a.mu.Lock()
defer a.mu.Unlock()
rep, etag, corps := "complète", `"complet"`, "fiche complète, très longue"
if strings.Contains(r.Header.Get("Accept"), "install-v1") {
rep, etag, corps = "abrégée", `"abrege"`, "fiche abrégée"
}
if a.vary {
w.Header().Set("Vary", "accept-encoding, accept")
}
w.Header().Set("ETag", etag)
if r.Header.Get("If-None-Match") == etag {
w.WriteHeader(http.StatusNotModified)
return
}
a.corps[rep]++
io.WriteString(w, corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amontAVariantes) envoyes(rep string) int {
a.mu.Lock()
defer a.mu.Unlock()
return a.corps[rep]
}
func (a *amontAVariantes) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
const ficheNpm = "/npm"
func demandeAccept(t *testing.T, p *Proxy, hote, accept string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", ficheNpm, nil)
r.Host = hote
if accept != "" {
r.Header.Set("Accept", accept)
}
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// Deux représentations demandées tour à tour ne repartent qu'une fois chacune :
// rangée à part, l'une ne remplace plus l'autre.
func TestDeuxRepresentationsQuiAlternentNeRepartentQuUneFois(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
u := &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm}
if Classify(u) != ClassVolatile || PortableParChemin(u) {
t.Fatalf("%s n'est plus un volatil rangé avec son hôte", ficheNpm)
}
p := proxyDeTest(t)
ordre := []string{acceptAbrege, acceptComplet, acceptAbrege, acceptComplet, acceptAbrege}
for i, accept := range ordre {
w := demandeAccept(t, p, a.hote(), accept)
veut := "fiche complète, très longue"
if accept == acceptAbrege {
veut = "fiche abrégée"
}
if w.Body.String() != veut {
t.Fatalf("demande %d : corps %q, attendu %q", i+1, w.Body.String(), veut)
}
}
if n := a.envoyes("abrégée"); n != 1 {
t.Errorf("fiche abrégée envoyée %d fois, attendu 1", n)
}
if n := a.envoyes("complète"); n != 1 {
t.Errorf("fiche complète envoyée %d fois, attendu 1", n)
}
}
// La clé de base reste tenue : --detient, le pré-vol et le rattrapage hors
// ligne ne connaissent pas l'en-tête Accept.
func TestLaCleDeBaseResteTenuePourDetient(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
d := p.Store.Detenir("GET", "http://"+a.hote()+ficheNpm)
if d.Verdict != VerdictGarde {
t.Errorf("--detient dit %q pour une fiche gardée", d.Verdict)
}
}
// Sans « Vary: Accept », rien n'est rangé à part : la réponse ne varie pas, et
// la dupliquer ne ferait que doubler le disque.
func TestSansVaryAcceptAucuneVariante(t *testing.T) {
a := nouvelAmontAVariantes(t, false)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm})
if p.Store.Detient(CleVariante(base, acceptAbrege)) {
t.Error("une variante a été rangée pour une réponse sans Vary: Accept")
}
if !p.Store.Detient(base) {
t.Error("la clé de base n'est plus écrite")
}
}
// Amont muet : le client reçoit SA représentation, pas la dernière rangée sous
// la base, qui peut être l'autre.
func TestHorsLigneLaRepresentationDuClientSort(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
hote := a.hote()
demandeAccept(t, p, hote, acceptAbrege)
demandeAccept(t, p, hote, acceptComplet) // la base porte désormais la complète
a.srv.Close()
w := demandeAccept(t, p, hote, acceptAbrege)
if w.Code != http.StatusOK || w.Body.String() != "fiche abrégée" {
t.Errorf("hors ligne : %d %q, attendu la fiche abrégée", w.Code, w.Body.String())
}
}
// Une requête sans Accept ne choisit aucune représentation : elle suit la clé
// de base, comme avant.
func TestSansAcceptLaBaseSeule(t *testing.T) {
if CleVariante("cle", "") != "" || CleVariante("cle", " ") != "" {
t.Error("un Accept vide produit une clé de variante")
}
if CleVariante("cle", "Application/JSON") != CleVariante("cle", "application/json") {
t.Error("la casse d'Accept change la variante")
}
if !varieSurAccept(http.Header{"Vary": {"accept-encoding, Accept"}}) ||
varieSurAccept(http.Header{"Vary": {"accept-encoding"}}) {
t.Error("lecture de Vary erronée")
}
}
// Servir une variante sert aussi l'objet de base : sa date d'usage suit, sans
// quoi un nettoyage par âge retirerait la base qu'on sert encore, et --detient
// la dirait absente.
func TestServirUneVarianteRajeunitLaBase(t *testing.T) {
a := nouvelAmontAVariantes(t, true)
p := proxyDeTest(t)
demandeAccept(t, p, a.hote(), acceptAbrege)
base := CleDe("GET", &url.URL{Scheme: "http", Host: a.hote(), Path: ficheNpm})
_, corpsBase := p.Store.paths(base)
vieux := time.Now().Add(-90 * 24 * time.Hour)
if err := os.Chtimes(corpsBase, vieux, vieux); err != nil {
t.Fatal(err)
}
w := demandeAccept(t, p, a.hote(), acceptAbrege)
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeRevalidated {
t.Fatalf("seconde demande servie « %s », attendu « %s »", got, OutcomeRevalidated)
}
fi, err := os.Stat(corpsBase)
if err != nil {
t.Fatal(err)
}
if time.Since(fi.ModTime()) > time.Hour {
t.Errorf("la base date encore du %s : son usage n'a pas été noté", fi.ModTime())
}
}

View file

@ -1690,9 +1690,12 @@ class ProxmoxMenuMixin:
return ""
return self._qemu_cache_ca_path()
def _pve_set_cache_ca(self, cible, vm, ca):
def _pve_set_cache_ca(self, cible, vm, ca, hors_ligne=False):
"""Pose l'autorité du cache DANS la VM, par ssh.
`hors_ligne` : l'amont du cache est coupé pour ce déploiement ; la VM
reçoit en plus ce que la voie libvirt pose sous « --offline ».
Même source que la voie libvirt — `cache_files` et `cache_commands` de
deploy_qemu — livrée autrement : « qm set » ne sait écrire aucun
fichier, comme pour le guide et pour le fuseau.
@ -1707,7 +1710,10 @@ class ProxmoxMenuMixin:
except Exception: # pragma: no cover - dépend du module
return False
args = types.SimpleNamespace(
distro=vm.get("distro") or "", cache_ca=ca, cache_bypass=False
distro=vm.get("distro") or "",
cache_ca=ca,
cache_bypass=False,
offline=hors_ligne,
)
fichiers = mod.cache_files(args)
if not fichiers:
@ -2122,7 +2128,9 @@ class ProxmoxMenuMixin:
# range ses index sous l'hôte demandé, et une VM qui en
# réclame un autre ne retrouve rien de ce qui est gardé.
self._pve_set_apt_mirror(vm["alias"], vm, mod_qemu)
self._pve_set_cache_ca(vm["alias"], vm, ca_cache)
self._pve_set_cache_ca(
vm["alias"], vm, ca_cache, hors_ligne=bool(coupee)
)
# Après la création, qui a posé l'écran accéléré : l'accès au
# nœud de rendu est une affaire de COMPTE, et il se donne
# dans l'invité.

View file

@ -34,13 +34,42 @@ 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"
# Le nettoyage automatique : une unité ponctuelle et son minuteur quotidien,
# posés par l'installateur, sans effet tant que leurs réglages sont vides.
CACHE_SERVICE_PURGE = "erplibre-go-qemu-cache-purge.service"
CACHE_MINUTEUR_PURGE = "erplibre-go-qemu-cache-purge.timer"
CACHE_CONF = "/etc/erplibre_go_qemu_cache/env"
CACHE_TABLE = "erplibre_qemu_cache"
CACHE_BYPASS = "/etc/erplibre_go_qemu_cache/bypass"
@ -88,6 +117,7 @@ MITM_GO = os.path.join("script", "qemu_cache", "mitm.go")
# nombreuses, faute d'avoir été relue quand elles sont apparues.
ORDRE_ISSUES = (
"hit",
"revalidated",
"mirror",
"stored",
"stale",
@ -356,6 +386,7 @@ class QemuCacheMenuMixin:
{"prompt_description": t("Cache - Fill what offline runs lacked")},
{"prompt_description": t("Cache - Logs")},
{"prompt_description": t("Cache - Copy it to another machine")},
{"prompt_description": t("Cache - Automatic cleanup")},
]
help_info = self.fill_help_info(choices)
while True:
@ -385,6 +416,8 @@ class QemuCacheMenuMixin:
self._cache_journaux()
elif status == "11":
self._cache_transfert()
elif status == "12":
self._cache_nettoyage_auto()
else:
print(t("Command not found !"))
@ -437,14 +470,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 +1371,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 +1385,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 +1397,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 +1473,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 +1850,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):
@ -2040,6 +2082,139 @@ class QemuCacheMenuMixin:
print(f" [{recu}] {etat}")
print()
# ------------------------------------------------------------------
# [12] Nettoyage automatique
# ------------------------------------------------------------------
def _cache_nettoyage_auto(self):
"""Le nettoyage automatique : un minuteur quotidien, sans effet tant
que ses deux réglages sont vides. Le nettoyage reste donc manuel par
défaut, et le régler ici suffit à l'activer, sans réinstaller."""
print(f"\n🧹 {t('Automatic cleanup of the cache')}")
choices = [
{"prompt_description": t("Cleanup - Set the age limit")},
{"prompt_description": t("Cleanup - Set the size ceiling")},
{"prompt_description": t("Cleanup - Preview now (dry run)")},
{"prompt_description": t("Cleanup - Run now")},
]
help_info = self.fill_help_info(choices)
while True:
self._cache_nettoyage_etat()
status = click.prompt(help_info)
print()
if status == "0":
return False
elif status == "1":
self._cache_nettoyage_regler(
"EL_PURGE_AGE",
t("Not served since (e.g. 90j), empty to disable"),
reglage_age_valide,
)
elif status == "2":
self._cache_nettoyage_regler(
"EL_MAX_SIZE",
t("Size ceiling (e.g. 50G), empty to disable"),
reglage_taille_valide,
)
elif status == "3":
self._cache_nettoyage_lancer(a_blanc=True)
elif status == "4":
self._cache_nettoyage_lancer(a_blanc=False)
else:
print(t("Command not found !"))
def _cache_nettoyage_etat(self):
"""Les deux réglages lus dans le fichier du service, et le minuteur."""
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
print(f" · {t('Age limit:')} {age or t('disabled')}")
print(f" · {t('Size ceiling:')} {taille or t('disabled')}")
etat = self._cache_lire(f"systemctl is-enabled {CACHE_MINUTEUR_PURGE}")
etat = etat.strip().splitlines()[0] if etat.strip() else ""
if etat in ("enabled", "static"):
print(f" · {t('Daily timer:')} {etat}")
else:
# Un cache posé avant que l'installateur ne pose le minuteur.
print(
f" ✗ {t('Timer absent: reinstall the cache from entry 1.')}"
)
print()
def _cache_nettoyage_regler(self, cle, invite, valide):
"""Demande une valeur, la vérifie, montre la commande et l'écrit après
confirmation. Une valeur vide désactive le réglage."""
valeur = click.prompt(invite, default="", show_default=False).strip()
if valeur and not valide(valeur):
print(f" ✗ {t('Unreadable value:')} {valeur}\n")
return
cmd = commande_ecrire_reglage(cle, valeur)
print(f"{t('Will execute:')} {cmd}")
if not click.confirm(t("Write this setting?")):
return
self.execute.exec_command_live(cmd, source_erplibre=False)
def _cache_nettoyage_lancer(self, a_blanc):
"""À blanc : ce que chaque réglage effacerait, lu sous le compte du
service. Sinon : l'unité du minuteur, lancée tout de suite."""
age = cache_offline.reglage("EL_PURGE_AGE", CACHE_CONF)
taille = cache_offline.reglage("EL_MAX_SIZE", CACHE_CONF)
if not age and not taille:
print(f" {t('Nothing to do: both settings are empty.')}\n")
return
if not a_blanc:
cmd = (
f"sudo systemctl start {CACHE_SERVICE_PURGE}"
f" && sudo journalctl -u {CACHE_SERVICE_PURGE} -n 20 --no-pager"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
return
for option, valeur in (
("--purge-older-than", age),
("--purge-to-size", taille),
):
if not valeur:
continue
cmd = (
f"sudo -u {cache_offline.SERVICE_USER} {CACHE_BIN}"
f" --cache-dir {CACHE_DIR} --git-mirror-dir {CACHE_MIROIR_GIT}"
f" {option} {shlex.quote(valeur)} --dry-run {option_langue()}"
)
print(f"{t('Will execute:')} {cmd}\n")
self.execute.exec_command_live(cmd, source_erplibre=False)
def reglage_age_valide(valeur):
"""Vrai si le binaire lira ce délai (voir LireDuree) : « 90j », « 12h »,
« 1h30m ». Un délai nul est refusé : il effacerait tout chaque jour."""
v = (valeur or "").strip().lower()
if not re.fullmatch(r"\d+(\.\d+)?[jd]|(\d+(\.\d+)?(ms|h|m|s))+", v):
return False
return any(c in "123456789" for c in v)
def reglage_taille_valide(valeur):
"""Vrai si le binaire lira cette taille (voir LireTaille) : « 50G »,
« 500M », « 1.5 T ». Une taille nulle est refusée."""
v = (valeur or "").strip().lower().replace(" ", "")
m = re.fullmatch(r"(\d+(\.\d+)?)([kmgt])?(io|ib|b|o)?", v)
return bool(m) and float(m.group(1)) > 0
def commande_ecrire_reglage(cle, valeur, fichier=CACHE_CONF):
"""La commande qui pose « cle=valeur » dans le fichier de réglages du
service : la ligne est remplacée si elle existe, ajoutée sinon. Une valeur
vide la laisse présente et vide, ce qui désactive le réglage. La valeur
est déjà validée : elle ne porte ni « | » ni guillemet."""
ligne = f"{cle}={valeur}"
f = shlex.quote(fichier)
script = (
f"if grep -q {shlex.quote('^' + cle + '=')} {f}; then"
f" sed -i {shlex.quote('s|^' + cle + '=.*|' + ligne + '|')} {f};"
f" else echo {shlex.quote(ligne)} >> {f}; fi"
)
return f"sudo sh -c {shlex.quote(script)}"
def exclusions_declarees(racine):
"""Les hôtes que le cache passe d'office en tunnel, lus dans SA source.
@ -2199,6 +2374,7 @@ def miroir_prefetch_cmd(fichier, environnement):
CACHE_BIN,
"--git-mirror-dir",
CACHE_MIROIR_GIT,
option_langue(),
"--git-mirror-prefetch",
"/dev/stdin",
"<",
@ -2217,7 +2393,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 -"
)

View file

@ -1114,6 +1114,7 @@ class QemuDeployMixin:
git_email="",
cache_ca="",
cache_bypass=False,
offline=False,
):
"""Construit la commande deploy_qemu.py d'UNE VM (utilisée pour l'aperçu
dry-run ET le déploiement réel)."""
@ -1163,6 +1164,10 @@ class QemuDeployMixin:
# l'approuve dès son premier démarrage, sans quoi le détournement
# lui présente un certificat qu'elle rejette.
parts += ["--cache-ca", cache_ca]
if offline:
# L'amont du cache sera coupé : ce qu'aucun cache ne rejoue,
# l'audit de npm d'abord, est désactivé dans la VM.
parts.append("--offline")
# L'identité git de la VM. Sans ces options, deploy_qemu recopie celle
# de l'HÔTE : le formulaire la montre et permet de la changer, il ne
# la remplace pas par du vide.
@ -1259,6 +1264,7 @@ class QemuDeployMixin:
self._qemu_cache_ca_path() if self._qemu_cache_active() else ""
),
cache_bypass=bool(spec.get("cache_bypass")),
offline=bool(spec.get("offline")),
)
# Où l'installateur du cache pose son autorité. Un test compare cette

View file

@ -425,7 +425,10 @@ class QemuInstallMixin:
suivante trouvait le verrou pris, échouait jusqu'à sa borne, puis
installait sur un index jamais rafraîchi : « Impossible de trouver le
paquet », un message qui n'accuse personne."""
from script.qemu.deploy_qemu import cache_env_reload
from script.qemu.deploy_qemu import (
attente_cloud_final,
cache_env_reload,
)
return (
"if command -v cloud-init >/dev/null 2>&1; then "
@ -445,6 +448,11 @@ class QemuInstallMixin:
"n=0; while systemctl is-active --quiet erplibre-qga 2>/dev/null; "
"do n=$((n+1)); [ $n -ge 150 ] && break; sleep 2; done; "
"fi; "
# « status --wait » rend la main dès que cloud-init se déclare en
# ERREUR, alors que son étape finale écrit encore l'autorité, les
# variables et le sudoers : attendre l'unité, faute de quoi elles
# arrivent après cette session, qui vivra sans elles.
+ attente_cloud_final() + "; "
# Les variables du cache sont écrites par cloud-init PENDANT
# l'attente : cette session, ouverte avant, ne les a pas reçues.
+ cache_env_reload() + "; "

View file

@ -615,6 +615,7 @@ class TODO(
"_cache_exceptions": "Exceptions",
"_cache_journaux": "Logs",
"_cache_miroir_git": "Git mirrors",
"_cache_nettoyage_auto": "Automatic cleanup",
"prompt_execute_qemu": "QEMU/KVM",
"prompt_execute_proxmox": "Proxmox VE",
"prompt_execute_vpn": "VPN",
@ -1828,6 +1829,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")

View file

@ -6459,6 +6459,70 @@ TRANSLATIONS = {
"fr": "Lire l'erreur ci-dessus, la corriger, et relancer l'entrée 1.",
"en": "Read the error above, fix it, and run entry 1.",
},
"Cache - Automatic cleanup": {
"fr": "🧹 Cache - Nettoyage automatique",
"en": "🧹 Cache - Automatic cleanup",
},
"Automatic cleanup of the cache": {
"fr": "Nettoyage automatique du cache",
"en": "Automatic cleanup of the cache",
},
"Cleanup - Set the age limit": {
"fr": "⏳ Nettoyage - Régler l'âge limite",
"en": "⏳ Cleanup - Set the age limit",
},
"Cleanup - Set the size ceiling": {
"fr": "📏 Nettoyage - Régler le plafond de taille",
"en": "📏 Cleanup - Set the size ceiling",
},
"Cleanup - Preview now (dry run)": {
"fr": "🔍 Nettoyage - Voir ce qui partirait (à blanc)",
"en": "🔍 Cleanup - Preview now (dry run)",
},
"Cleanup - Run now": {
"fr": "🧹 Nettoyage - Lancer maintenant",
"en": "🧹 Cleanup - Run now",
},
"Age limit:": {
"fr": "Âge limite :",
"en": "Age limit:",
},
"Size ceiling:": {
"fr": "Plafond de taille :",
"en": "Size ceiling:",
},
"disabled": {
"fr": "désactivé",
"en": "disabled",
},
"Daily timer:": {
"fr": "Minuteur quotidien :",
"en": "Daily timer:",
},
"Timer absent: reinstall the cache from entry 1.": {
"fr": "Minuteur absent : réinstaller le cache par l'entrée 1.",
"en": "Timer absent: reinstall the cache from entry 1.",
},
"Not served since (e.g. 90j), empty to disable": {
"fr": "Pas servi depuis (ex. 90j), vide pour désactiver",
"en": "Not served since (e.g. 90j), empty to disable",
},
"Size ceiling (e.g. 50G), empty to disable": {
"fr": "Plafond de taille (ex. 50G), vide pour désactiver",
"en": "Size ceiling (e.g. 50G), empty to disable",
},
"Unreadable value:": {
"fr": "Valeur illisible :",
"en": "Unreadable value:",
},
"Write this setting?": {
"fr": "Écrire ce réglage ?",
"en": "Write this setting?",
},
"Nothing to do: both settings are empty.": {
"fr": "Rien à faire : les deux réglages sont vides.",
"en": "Nothing to do: both settings are empty.",
},
"Cache - Copy it to another machine": {
"fr": "📦 Cache - L'emporter sur une autre machine",
"en": "📦 Cache - Copy it to another machine",
@ -13191,9 +13255,7 @@ TRANSLATIONS = {
"Pas de terminal pour saisir le mot de passe du coffre :"
" coffre non ouvert."
),
"en": (
"No terminal to type the vault password: vault left closed."
),
"en": ("No terminal to type the vault password: vault left closed."),
},
"Some gateways demand an embedded browser (SAML): openconnect stops on"
" \u00ab No SSO handler \u00bb and a helper is needed for the web step. It is"

View file

@ -971,7 +971,7 @@ class TransformMenuMixin:
Le processus doit lire la source ET écrire la cible : il lui faut
l'union des bibliothèques. La source seule laissait csv→xlsx
importer openpyxl sous l'interpréteur du CLI, qui ne l'a pas ; la
importer openpyxl sous l'interpréteur du CLI, qui peut ne pas l'avoir ; la
cible seule enverrait un classeur au même interpréteur, qui ne sait
pas le lire.
"""

View file

@ -4,10 +4,11 @@
"""L'environnement de lecture des fichiers externes, posé à la demande.
Excel et Access exigent des bibliothèques qui ne sont dans aucun venv du
dépôt : `.venv.erplibre` porte l'outillage du CLI, le venv Odoo porte celui
d'Odoo, et charger l'un ou l'autre de ces lecteurs les mélangerait à un
sujet qui n'est pas le leur. D'où un venv dédié, bâti au premier besoin.
Excel et Access exigent des bibliothèques qu'aucun venv du dépôt ne porte à
coup sûr : `.venv.erplibre` porte l'outillage du CLI — openpyxl compris,
mais seulement depuis qu'il est dans ses dépendances —, le venv Odoo porte
celui d'Odoo, et le lecteur Access n'est dans aucun. D'où un venv dédié,
bâti au premier besoin.
Les formats en pur stdlib n'en ont PAS besoin, et c'est ce que
`available()` et `engine_python()` tiennent à la place des appelants : un

View file

@ -31,6 +31,7 @@ from __future__ import annotations
import json
import os
import subprocess
import sys
import tempfile
import unittest
@ -323,7 +324,23 @@ class LaFrontiere(unittest.TestCase):
"""Le paquet doit rester importable sans le CLI."""
def test_le_listage_n_importe_pas_todo(self):
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.claude_sessions;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
if __name__ == "__main__":

View file

@ -25,6 +25,7 @@ qu'il porte.
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
@ -344,7 +345,23 @@ class LaFrontiere(unittest.TestCase):
"""Le paquet doit rester importable sans le CLI."""
def test_le_contexte_n_importe_pas_todo(self):
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.context;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
if __name__ == "__main__":

View file

@ -24,6 +24,7 @@ temporaires, et le répertoire personnel est détourné là où il compte.
from __future__ import annotations
import os
import subprocess
import sys
import tempfile
import unittest
@ -394,7 +395,23 @@ class LaFrontiere(unittest.TestCase):
"""Le paquet doit rester importable sans le CLI."""
def test_le_chargeur_n_importe_pas_todo(self):
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.gpt;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
if __name__ == "__main__":

View file

@ -40,6 +40,7 @@ niveau `.invalid` est réservé à cet usage, et « azurite », « obsidienne »
import os
import re
import subprocess
import sys
import unittest
@ -317,7 +318,23 @@ class LaFrontiere(unittest.TestCase):
def test_les_sources_ne_tirent_pas_todo(self):
"""Importer `script.todo.todo` coûte près d'une seconde et imprime
sur la sortie : le paquet doit rester importable seul."""
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.discover;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
def test_aucun_analyseur_de_config_ssh_n_est_reecrit_ici(self):
"""`ssh -G` a raison sur les `Include`, les `Match`, l'héritage des

View file

@ -28,6 +28,7 @@ n'y paraissent nulle part.
"""
import os
import subprocess
import sys
import unittest
from pathlib import Path
@ -285,7 +286,23 @@ class LaFrontiere(unittest.TestCase):
def test_le_registre_ne_tire_pas_todo(self):
"""Importer `script.todo.todo` coûte près d'une seconde et imprime
sur la sortie : le paquet doit rester importable seul."""
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.servers;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
if __name__ == "__main__":

View file

@ -35,6 +35,7 @@ paraissent nulle part ailleurs dans le dépôt.
import os
import re
import subprocess
import sys
import time
import unittest
@ -354,7 +355,23 @@ class LaFrontiere(unittest.TestCase):
def test_le_balayage_ne_tire_pas_todo(self):
"""Importer `script.todo.todo` coûte près d'une seconde et imprime
sur la sortie : le paquet doit rester importable seul."""
self.assertNotIn("script.todo.todo", sys.modules)
# Dans un interpréteur NEUF : la suite complète importe todo par
# ailleurs, et le sys.modules de ce processus en garderait la trace
# quel que soit le module éprouvé ici.
sortie = subprocess.run(
[
sys.executable,
"-c",
"import sys, script.todo.assistant.discover, script.todo.assistant.fingerprint;"
" print('script.todo.todo' in sys.modules)",
],
cwd=os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
capture_output=True,
text=True,
timeout=60,
)
self.assertEqual(sortie.returncode, 0, sortie.stderr)
self.assertEqual(sortie.stdout.strip(), "False")
def test_aucun_asyncio_du_depot_n_est_utilise(self):
"""`AsyncioPool` enveloppe des sous-processus et non des sockets, et

View file

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

View file

@ -1262,7 +1262,7 @@ class TestLeMiroirAptDesVmProxmox(unittest.TestCase):
)
self.assertLess(
src.index('self._pve_set_apt_mirror(vm["alias"]'),
src.index('self._pve_set_cache_ca(vm["alias"]'),
src.index("self._pve_set_cache_ca("),
)
@ -2438,7 +2438,7 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
todo._pve_write_guide = lambda *a, **k: True
todo._pve_set_timezone = lambda *a, **k: True
todo._qemu_import_module = lambda: None
todo._pve_set_cache_ca = lambda cible, vm, ca: ordre.append(
todo._pve_set_cache_ca = lambda cible, vm, ca, **k: ordre.append(
("autorité", cible, ca)
)
todo._qemu_install_erplibre_monitored = lambda *a, **k: ordre.append(
@ -2511,5 +2511,46 @@ class TestLAutoriteDuCacheDansUneVmImbriquee(unittest.TestCase):
self.assertEqual(appels, [])
class TestLAutoriteDUneVmProxmoxHorsLigne(unittest.TestCase):
"""Une VM Proxmox déployée hors ligne reçoit, comme la voie libvirt, de quoi
couper l'audit de npm : aucun cache ne rejoue ce service."""
def test_seule_la_vm_hors_ligne_coupe_l_audit(self):
import contextlib
import io
import sys
import tempfile
sys.argv = ["todo.py"]
from script.todo.todo import TODO
todo = TODO.__new__(TODO)
vu = {}
def faux_ssh(cible, cmd, timeout=120):
vu["cmd"] = cmd
return 0, ""
todo._pve_ssh = faux_ssh
with tempfile.NamedTemporaryFile(
"w", suffix=".crt", delete=False
) as fh:
fh.write("-----BEGIN CERTIFICATE-----\nZm F1eA==\n")
fh.write("-----END CERTIFICATE-----\n")
ca = fh.name
rendus = {}
with contextlib.redirect_stdout(io.StringIO()):
for hors_ligne in (True, False):
todo._pve_set_cache_ca(
"hote+vm-a",
{"name": "vm-a", "distro": "ubuntu"},
ca,
hors_ligne=hors_ligne,
)
rendus[hors_ligne] = vu.get("cmd", "")
self.assertIn("NPM_CONFIG_AUDIT", rendus[True])
self.assertNotIn("NPM_CONFIG_AUDIT", rendus[False])
if __name__ == "__main__":
unittest.main(verbosity=2)

View file

@ -118,6 +118,23 @@ class TestLExceptionEstPoseeAvant(unittest.TestCase):
self.assertTrue(pose, "l'exception n'a pas été posée")
self.assertIn("nft -f -", " ".join(pose[0]))
def test_le_message_du_binaire_suit_la_langue(self):
"""La commande passe par sudo, qui retire EL_LANG : sans l'option, le
binaire répondrait en français à qui déploie en anglais."""
args = args_neufs()
args.lang = "en"
runner = FauxRunner({"is-active": (0, "")})
with mock.patch.object(
deploy_qemu.os.path, "isfile", return_value=True
), mock.patch.object(
deploy_qemu.shutil, "which", return_value="/usr/bin/nft"
), mock.patch.object(
deploy_qemu, "macs_deja_prises", return_value=set()
):
deploy_qemu.cache_bypass_apply(args, runner)
pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)]
self.assertIn("--lang en", " ".join(pose[0]))
def test_sans_cache_installe_rien_nest_pose(self):
"""Exiger un cache pour pouvoir s'en passer n'aurait aucun sens :
sans lui, rien n'intercepte."""

View file

@ -139,6 +139,69 @@ class TestLaCharge(unittest.TestCase):
i = QC.ATTENDRE_CLOUD_INIT.index("status --wait")
self.assertIn(cache_env_reload(), QC.ATTENDRE_CLOUD_INIT[i:])
def test_lattente_suit_l_unite_et_non_le_seul_statut(self):
"""« status --wait » rend la main dès que cloud-init se déclare en
erreur — un module accessoire y suffit — alors que son étape finale
écrit encore l'autorité du cache et les variables. Une session ouverte
à cette seconde-là vit sans elles, et « sudo » n'a rien à conserver :
« sudo npm » rejette alors le certificat du cache."""
from script.qemu.deploy_qemu import (
attente_cloud_final,
cache_env_reload,
)
attente = QC.ATTENDRE_CLOUD_INIT
self.assertIn("cloud-final", attente_cloud_final())
self.assertIn(attente_cloud_final(), attente)
self.assertLess(
attente.index(attente_cloud_final()),
attente.index(cache_env_reload()),
"les variables sont relues avant la fin de l'étape qui les écrit",
)
def test_lattente_de_l_unite_est_bornee_et_sans_effet_ailleurs(self):
"""Une VM sans cloud-init ne doit pas payer l'attente, et une unité
qui ne finit jamais ne doit pas tenir la campagne indéfiniment."""
from script.qemu.deploy_qemu import attente_cloud_final
court = attente_cloud_final(3)
self.assertIn("[ $n -ge 3 ]", court)
self.assertIn("2>/dev/null", court)
def test_lattente_guette_activating_et_non_is_active(self):
"""cloud-final est un « oneshot » qui reste ACTIF une fois terminé :
« is-active » y est vrai pour toujours. Attendre là-dessus paie la
borne entière sur chaque VM — cinq minutes — sans jamais rien
détecter. Seul « activating » dit que l'étape écrit encore."""
from script.qemu.deploy_qemu import attente_cloud_final
attente = attente_cloud_final()
self.assertIn("activating", attente)
self.assertNotIn("is-active", attente)
def test_lattente_de_l_unite_rend_la_main_sur_cette_machine(self):
"""Jouée dans un vrai shell, hors VM : l'hôte n'a pas d'étape finale
en cours, et l'attente doit donc rendre la main tout de suite."""
import subprocess
import time
from script.qemu.deploy_qemu import attente_cloud_final
debut = time.monotonic()
res = subprocess.run(
["sh", "-c", attente_cloud_final()],
capture_output=True,
text=True,
timeout=30,
)
self.assertEqual(0, res.returncode, res.stderr[-200:])
self.assertLess(
time.monotonic() - debut,
5,
"l'attente tourne alors que rien n'écrit : elle guette un état"
" qu'un service oneshot garde pour toujours",
)
def test_la_charge_est_du_shell_valide(self):
"""Une instruction collée sans séparateur casse la commande entière,
et la VM ne dit alors pas pourquoi elle n'a rien installé."""
@ -250,5 +313,251 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase):
self.assertEqual(len(ecrit["vms"]), 1)
class TestLaContreEpreuveNommeSonEtape(unittest.TestCase):
"""Une troisième VM en échec doit nommer son étape dans le rapport, comme
les deux premières : le résumé d'une série n'a sinon qu'un « échec »."""
def boucler(self, echoue):
import argparse
import json
import tempfile
from unittest import mock
args = argparse.Namespace(
dry_run=False,
sans_cache=False,
hors_ligne=True,
distro="debian",
version="12",
charge="minimum",
)
# Les deux premières VM réussissent ; la troisième échoue à l'appel
# nommé, c'est-à-dire au troisième appel de cette fonction.
appels = {"n": 0}
def selon(nom, reussi):
def f(*a, **k):
appels["n"] += nom == echoue
if nom == echoue and appels["n"] == 3:
return "" if nom == "deployer" else False
return reussi
return f
with tempfile.TemporaryDirectory() as rep:
fichier = str(Path(rep) / "rapport.json")
rapport = {"_fichier": fichier, "vms": []}
with mock.patch.object(QC, "dire"), mock.patch.object(
QC, "noter_uuid"
), mock.patch.object(QC, "eteindre"), mock.patch.object(
QC, "verdict", return_value=True
), mock.patch.object(
QC, "couper_lamont", return_value=True
), mock.patch.object(
QC, "rebrancher_lamont", create=True
), mock.patch.object(
QC, "deployer", side_effect=selon("deployer", "10.0.0.1")
), mock.patch.object(
QC, "attendre_ssh", side_effect=selon("attendre_ssh", True)
), mock.patch.object(
QC,
"poser_les_paquets",
side_effect=selon("poser_les_paquets", True),
):
code = QC._boucle(args, rapport, None, "", 0)
with open(fichier, encoding="utf-8") as fh:
return code, json.load(fh)
def test_chaque_etape_de_la_troisieme_vm_est_nommee(self):
for etape, mot in (
("deployer", "-3 : déploiement"),
("attendre_ssh", "-3 : ssh"),
("poser_les_paquets", "-3 : paquets"),
):
with self.subTest(etape=etape):
code, ecrit = self.boucler(etape)
self.assertEqual(code, 1)
self.assertEqual(ecrit.get("verdict"), "échec")
self.assertIn(mot, ecrit.get("etape_en_echec", ""))
class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase):
"""Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long
doit le lui demander, comme le formulaire, sans quoi il mesure une VM que
personne ne déploierait ainsi."""
def commande(self, **kw):
from unittest import mock
with mock.patch.object(QC, "dire") as dit, mock.patch.object(
QC, "cle_publique", return_value="/tmp/cle.pub"
):
QC.deployer("vm", None, dry_run=True, **kw)
return " ".join(str(a) for c in dit.call_args_list for a in c.args)
def test_la_vm_hors_ligne_recoit_offline(self):
self.assertIn("--offline", self.commande(hors_ligne=True))
def test_les_vm_en_ligne_n_en_recoivent_pas(self):
self.assertNotIn("--offline", self.commande())
def test_le_temoin_sans_cache_n_en_recoit_pas(self):
self.assertNotIn(
"--offline", self.commande(avec_cache=False, hors_ligne=True)
)
def test_la_contre_epreuve_le_demande(self):
import inspect
source = inspect.getsource(QC.contre_epreuve)
self.assertIn("hors_ligne=True", source)
class TestPlusieursSystemes(unittest.TestCase):
"""Une série de campagnes, un système après l'autre."""
def test_tous_designe_chaque_systeme_mesurable(self):
self.assertEqual(
QC.systemes_demandes("tous"), sorted(QC.systemes_mesurables())
)
def test_une_liste_garde_son_ordre_sans_doublon(self):
self.assertEqual(
QC.systemes_demandes("fedora, debian,fedora"), ["fedora", "debian"]
)
def test_un_nom_inconnu_est_refuse_avant_toute_machine(self):
with self.assertRaises(ValueError):
QC.systemes_demandes("fedora,haiku")
def lancer(self, argv, codes=None):
from unittest import mock
appels = []
codes = list(codes or [])
def campagne(a):
appels.append((a.distro, a.version))
return (codes.pop(0) if codes else 0), ""
with mock.patch.object(
QC, "une_campagne", side_effect=campagne
), mock.patch.object(
QC,
"detruire",
side_effect=lambda *x, **k: appels.append("détruire"),
), mock.patch(
"builtins.print"
):
code = QC.main(argv)
return code, appels
def test_la_serie_defait_les_machines_entre_deux_systemes(self):
from script.qemu.deploy_qemu import DISTROS
code, appels = self.lancer(
[
"--distro",
"fedora,debian",
"--charge",
"erplibre",
"--hors-ligne",
]
)
self.assertEqual(
appels,
[
("fedora", DISTROS["fedora"][1]),
"détruire",
("debian", DISTROS["debian"][1]),
"détruire",
],
)
self.assertEqual(code, 0)
def test_un_echec_n_arrete_pas_la_serie(self):
code, appels = self.lancer(["--distro", "fedora,debian"], codes=[1, 0])
self.assertEqual(len([a for a in appels if a != "détruire"]), 2)
self.assertEqual(code, 1)
def test_a_blanc_rien_n_est_defait(self):
_, appels = self.lancer(["--distro", "fedora,debian", "--dry-run"])
self.assertNotIn("détruire", appels)
def test_la_version_est_refusee_pour_plusieurs_systemes(self):
from unittest import mock
with mock.patch("sys.stderr"), self.assertRaises(SystemExit):
QC.main(["--distro", "tous", "--version", "12"])
def test_un_seul_systeme_suit_le_chemin_d_avant(self):
from script.qemu.deploy_qemu import DISTROS
_, appels = self.lancer(["--distro", "ubuntu"])
self.assertEqual(appels, [("ubuntu", DISTROS["ubuntu"][1])])
class TestUneVmMesureeEstEteinte(unittest.TestCase):
"""Deux VM de 8 Gio allumées ensemble, puis trois, épuisent la mémoire de
l'hôte en pleine série : chacune s'éteint dès que sa mesure est faite."""
def test_chaque_vm_s_eteint_apres_sa_propre_mesure(self):
import argparse
import tempfile
from unittest import mock
ordre = []
args = argparse.Namespace(
dry_run=False,
sans_cache=False,
hors_ligne=False,
distro="debian",
version="12",
charge="erplibre",
)
with tempfile.TemporaryDirectory() as rep:
rapport = {"_fichier": str(Path(rep) / "r.json"), "vms": []}
with mock.patch.object(QC, "dire"), mock.patch.object(
QC, "noter_uuid"
), mock.patch.object(
QC, "deployer", return_value="10.0.0.1"
), mock.patch.object(
QC, "attendre_ssh", return_value=True
), mock.patch.object(
QC,
"poser_les_paquets",
side_effect=lambda *a, **k: ordre.append("paquets") or True,
), mock.patch.object(
QC,
"eteindre",
side_effect=lambda nom, *a, **k: ordre.append(nom),
), mock.patch.object(
QC, "verdict", return_value=True
):
QC._boucle(args, rapport, None, "", 0)
base = QC.base_des_noms(args)
self.assertEqual(
ordre, ["paquets", f"{base}-1", "paquets", f"{base}-2"]
)
def test_eteindre_n_efface_rien(self):
from unittest import mock
with mock.patch.object(QC, "dire") as dit:
QC.eteindre("vm-essai", None, dry_run=True)
annonce = " ".join(str(a) for c in dit.call_args_list for a in c.args)
self.assertIn("destroy vm-essai", annonce)
self.assertNotIn("undefine", annonce)
def test_a_blanc_rien_n_est_lance(self):
from unittest import mock
with mock.patch.object(QC, "dire"), mock.patch.object(
QC, "executer"
) as ex:
QC.eteindre("vm-essai", None, dry_run=True)
ex.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -121,6 +121,25 @@ class TestCommandeProduite(unittest.TestCase):
todo._qemu_cache_active = lambda: actif and bool(ca)
return todo._qemu_deploy_parts_for(self.vm(), spec, dry_run=True)
def test_hors_ligne_le_drapeau_offline(self):
"""Hors ligne, la VM reçoit de quoi couper l'audit de npm."""
parts = self.parts(
{"install": None, "offline": True}, ca="/tmp/essai-ca.crt"
)
self.assertIn("--offline", parts)
def test_en_ligne_aucun_drapeau_offline(self):
parts = self.parts({"install": None}, ca="/tmp/essai-ca.crt")
self.assertNotIn("--offline", parts)
def test_une_vm_exceptee_n_a_pas_de_drapeau_offline(self):
"""Elle ne traverse pas le cache : rien à couper chez elle."""
parts = self.parts(
{"install": None, "offline": True, "cache_bypass": True},
ca="/tmp/essai-ca.crt",
)
self.assertNotIn("--offline", parts)
def test_service_arrete_aucun_drapeau(self):
"""Sans interception, l'autorité n'a rien à faire dans la VM."""
parts = self.parts({"install": None}, ca="/tmp/ca.crt", actif=False)
@ -261,7 +280,9 @@ class TestAccordAvecLInstallateur(unittest.TestCase):
def test_meme_nom_de_service(self):
texte = INSTALLATEUR.read_text(encoding="utf-8")
m = re.search(r"UNIT=\"/etc/systemd/system/([^\"]+)\"", texte)
# En début de ligne : « PURGE_UNIT=… » contient aussi « UNIT= », et
# désigne l'unité du nettoyage, pas celle du service.
m = re.search(r"^UNIT=\"/etc/systemd/system/([^\"]+)\"", texte, re.M)
self.assertIsNotNone(m, "le nom de l'unité est introuvable")
self.assertEqual(
QemuDeployMixin.QEMU_CACHE_SERVICE,

View file

@ -26,17 +26,27 @@ import sys
import unittest
from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE))
from script.qemu.deploy_qemu import ( # noqa: E402
CACHE_CERT_NAME,
CACHE_ENV_VARS,
CACHE_SUDOERS,
CACHE_TRUST,
OFFLINE_BOOTCMD,
OFFLINE_ENV_VARS,
build_cloud_config,
build_parser,
cache_commands,
cache_env_reload,
cache_family,
cache_files,
cache_runcmd,
commande_sudoers,
langue_des_messages,
)
RULES_GO = RACINE / "script" / "qemu_cache" / "rules.go"
@ -131,6 +141,10 @@ class TestRuncmd(unittest.TestCase):
self.tmp = tempfile.TemporaryDirectory()
self.lignes = cache_runcmd(faux_args(Path(self.tmp.name), "arch"))
# Les écritures de variables, sans la confiance ni le sudoers.
self.variables = [
l for l in self.lignes[1:] if "/etc/environment" in l
]
def tearDown(self):
self.tmp.cleanup()
@ -156,13 +170,14 @@ class TestRuncmd(unittest.TestCase):
def test_les_variables_vont_dans_etc_environment(self):
"""PAM lit /etc/environment pour TOUTE session ssh, non interactive
comprise : c'est la seule voie qui atteint une commande distante."""
for ligne in self.lignes[1:]:
self.assertIn("/etc/environment", ligne)
self.assertEqual(len(self.variables), len(CACHE_ENV_VARS))
for var in CACHE_ENV_VARS:
self.assertTrue(any(f"{var}=" in l for l in self.variables), var)
def test_ecriture_idempotente(self):
"""runcmd ne tourne qu'une fois par instance, mais un opérateur peut
rejouer la commande : elle ne doit pas empiler les doublons."""
for ligne in self.lignes[1:]:
for ligne in self.variables:
self.assertIn("grep -q", ligne)
def test_aucune_commande_ne_peut_faire_echouer_le_boot(self):
@ -228,6 +243,167 @@ class TestLesVariablesRelues(unittest.TestCase):
self.assertEqual(code, 0, err)
class TestLeHorsLigneCoupeLAuditNpm(unittest.TestCase):
"""Hors ligne, l'audit de npm interroge un service qu'aucun cache ne rejoue."""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def commandes(self, offline):
args = faux_args(Path(self.tmp.name), "ubuntu")
args.offline = offline
return "\n".join(cache_commands(args))
def test_une_vm_hors_ligne_recoit_l_audit_coupe(self):
self.assertIn("NPM_CONFIG_AUDIT=false", self.commandes(True))
def test_une_vm_en_ligne_garde_son_audit(self):
self.assertNotIn("NPM_CONFIG_AUDIT", self.commandes(False))
def test_la_variable_est_relue_apres_cloud_init(self):
"""Le « npm install » lancé sans sudo vit dans une session ouverte
avant que cloud-init n'écrive la variable."""
for var, _ in OFFLINE_ENV_VARS:
self.assertIn(var, cache_env_reload())
class TestLesVariablesTraversentSudo(unittest.TestCase):
"""sudo remet l'environnement à zéro : sans « env_keep », une installation
lancée par « sudo npm » rejette l'autorité du cache là où PAM ne relit pas
/etc/environment pour sudo."""
def setUp(self):
import tempfile
self.tmp = tempfile.TemporaryDirectory()
self.addCleanup(self.tmp.cleanup)
def commande(self, offline=False, distro="debian"):
args = faux_args(Path(self.tmp.name), distro)
args.offline = offline
lignes = [c for c in cache_commands(args) if "env_keep" in c]
self.assertEqual(len(lignes), 1, "une seule écriture du sudoers")
return lignes[0]
def test_chaque_variable_du_cache_est_gardee(self):
commande = self.commande()
for var in CACHE_ENV_VARS:
self.assertIn(var, commande)
self.assertNotIn("NPM_CONFIG_AUDIT", commande)
def test_hors_ligne_l_audit_coupe_traverse_aussi(self):
for var, _ in OFFLINE_ENV_VARS:
self.assertIn(var, self.commande(offline=True))
def test_vient_apres_les_variables(self):
"""La confiance reste la première commande ; le sudoers suit."""
args = faux_args(Path(self.tmp.name), "debian")
commandes = cache_commands(args)
self.assertIn("env_keep", commandes[-1])
def test_le_fichier_ecrit_est_celui_que_sudo_lit(self):
"""sudo ignore un nom de sudoers.d qui porte un point : c'est ce qui
rend le temporaire inoffensif, et interdit ce point au nom final."""
nom_final = CACHE_SUDOERS.rsplit("/", 1)[1]
self.assertNotIn(".", nom_final)
self.assertTrue(CACHE_SUDOERS.startswith("/etc/sudoers.d/"))
def test_le_fichier_est_verifie_avant_d_etre_pose(self):
commande = commande_sudoers(["A"], "/etc/sudoers.d/essai")
self.assertLess(
commande.index("visudo -cf"),
commande.index("mv /etc/sudoers.d/.essai /etc/sudoers.d/essai"),
)
self.assertIn("chmod 0440", commande)
self.assertTrue(
commande.rstrip("'").endswith("|| rm -f /etc/sudoers.d/.essai")
)
def test_le_contenu_ecrit_est_une_ligne_par_variable(self):
"""Joué dans un vrai shell, visudo remplacé : c'est le fichier produit
qui compte, et son absence quand la vérification échoue."""
import subprocess
import tempfile
with tempfile.TemporaryDirectory() as rep:
bin_ = Path(rep) / "bin"
bin_.mkdir()
dossier = Path(rep) / "sudoers.d"
dossier.mkdir()
for verdict, attendu in (("0", True), ("1", False)):
faux = bin_ / "visudo"
faux.write_text(f"#!/bin/sh\nexit {verdict}\n")
faux.chmod(0o755)
fichier = dossier / "erplibre-cache"
commande = commande_sudoers(
["PIP_CERT", "NODE_EXTRA_CA_CERTS"], str(fichier)
)
res = subprocess.run(
["sh", "-c", commande],
capture_output=True,
text=True,
env={"PATH": f"{bin_}:/usr/bin:/bin"},
)
with self.subTest(visudo=verdict):
self.assertEqual(res.returncode, 0, res.stderr)
self.assertEqual(fichier.exists(), attendu)
self.assertFalse((dossier / ".erplibre-cache").exists())
if attendu:
self.assertEqual(
fichier.read_text(),
"Defaults env_keep += PIP_CERT\n"
"Defaults env_keep += NODE_EXTRA_CA_CERTS\n",
)
fichier.unlink()
class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase):
"""Une image qui attend la synchronisation NTP avant « cloud-final » ne
démarre jamais son étape finale sans serveur de temps : ni clés d'hôte, ni
ssh. Une VM déployée hors ligne lève cette attente dès bootcmd."""
def config(self, *extra):
args = build_parser().parse_args(
["--distro", "arch", "--hostname", "vm", *extra]
)
return yaml.safe_load(build_cloud_config(args, None, []))
def test_une_vm_hors_ligne_arrete_l_attente(self):
cmd = self.config("--offline").get("bootcmd") or []
self.assertTrue(
any("systemd-time-wait-sync" in c and "stop" in c for c in cmd),
cmd,
)
def test_la_commande_ne_bloque_ni_n_echoue(self):
"""--no-block : bootcmd ne doit pas attendre un travail qui attend
lui-même le réseau ; « || true » : une image sans l'unité continue."""
for ligne in OFFLINE_BOOTCMD[1:]:
self.assertIn("--no-block", ligne)
self.assertTrue(ligne.rstrip().endswith("|| true"))
def test_une_vm_en_ligne_garde_sa_synchronisation(self):
self.assertNotIn("bootcmd", self.config())
class TestLaLangueDesMessagesDuCache(unittest.TestCase):
def test_l_option_du_deploiement_l_emporte(self):
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="en")), "en"
)
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="FR")), "fr"
)
def test_une_valeur_inconnue_rend_le_francais(self):
self.assertEqual(
langue_des_messages(argparse.Namespace(lang="de")), "fr"
)
class TestAccordAvecLeGo(unittest.TestCase):
"""La table Python et la table Go doivent dire la même chose.

View file

@ -199,6 +199,88 @@ class TestUniteSystemd(unittest.TestCase):
self.assertNotIn("/root/", self.unite, "l'unité porte le compte root")
class TestNettoyageAutomatique(unittest.TestCase):
"""Le minuteur de nettoyage, tel qu'il est ÉCRIT, et les réglages qu'une
réinstallation doit garder."""
@classmethod
def setUpClass(cls):
cls.tmp = tempfile.mkdtemp()
unit = Path(cls.tmp) / "purge.service"
timer = Path(cls.tmp) / "purge.timer"
prelude = (
f'export EL_SRC_DIR="{SOURCES}"\n'
f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n'
"systemctl() { :; }\n"
f'PURGE_UNIT="{unit}"\n'
f'PURGE_TIMER="{timer}"\n'
"ecrire_purge\n"
)
r = subprocess.run(
["bash", "-c", prelude], capture_output=True, text=True
)
if r.returncode != 0 or not unit.is_file() or not timer.is_file():
raise AssertionError(f"minuteur non généré : {r.stdout}{r.stderr}")
cls.unite = unit.read_text(encoding="utf-8")
cls.minuteur = timer.read_text(encoding="utf-8")
@classmethod
def tearDownClass(cls):
shutil.rmtree(cls.tmp, ignore_errors=True)
def test_rien_ne_part_tant_que_les_reglages_sont_vides(self):
"""Par défaut, manuel : chaque geste est gardé par son réglage."""
self.assertIn('[ -n "$$EL_PURGE_AGE" ]', self.unite)
self.assertIn('[ -n "$$EL_MAX_SIZE" ]', self.unite)
def test_les_deux_nettoyages_sont_appeles(self):
self.assertIn('--purge-older-than "$$EL_PURGE_AGE"', self.unite)
self.assertIn('--purge-to-size "$$EL_MAX_SIZE"', self.unite)
def test_les_variables_viennent_de_l_environnement_a_l_execution(self):
"""« $$ » dans l'unité : systemd rend un « $ » au shell. Un « $ » seul
serait remplacé par systemd AVANT, à partir de rien."""
self.assertIn("EnvironmentFile=", self.unite)
self.assertNotRegex(self.unite, r"(?<!\$)\$EL_")
def test_sous_le_compte_du_service_et_durci(self):
self.assertRegex(self.unite, re.compile(r"^User=elqcache$", re.M))
for directive in (
"Type=oneshot",
"NoNewPrivileges=true",
"ProtectSystem=strict",
):
self.assertIn(directive, self.unite)
self.assertIn("/var/cache/erplibre_go_qemu_cache", self.unite)
def test_le_minuteur_est_quotidien_et_rattrape_un_arret(self):
self.assertIn("OnCalendar=daily", self.minuteur)
self.assertIn("Persistent=true", self.minuteur)
def test_une_reinstallation_garde_les_reglages(self):
"""Le fichier env est réécrit en entier : sans relecture, les réglages
choisis depuis le menu disparaîtraient en silence."""
conf = Path(self.tmp) / "conf"
conf.mkdir(exist_ok=True)
(conf / "env").write_text(
"EL_PURGE_AGE=90j\nEL_MAX_SIZE=50G\n", encoding="utf-8"
)
def lire(env_extra):
prelude = (
f'export EL_SRC_DIR="{SOURCES}" EL_CONF_DIR="{conf}"\n'
+ env_extra
+ f'source <(sed "/^main \\"\\$@\\"/d" {SCRIPT})\n'
'echo "$EL_PURGE_AGE|$EL_MAX_SIZE"\n'
)
return subprocess.run(
["bash", "-c", prelude], capture_output=True, text=True
).stdout.strip()
self.assertEqual(lire(""), "90j|50G")
self.assertEqual(lire("export EL_PURGE_AGE=\n"), "|50G")
class TestSourcesGo(unittest.TestCase):
def test_module_present(self):
self.assertTrue(

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

View file

@ -241,7 +241,7 @@ class TestSousMenusDuCache(unittest.TestCase):
)
def test_le_menu_du_cache(self):
self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 11)
self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 12)
def test_le_menu_du_service(self):
self.verifier("_cache_service", "_cache_journal_service", 6)
@ -252,6 +252,9 @@ class TestSousMenusDuCache(unittest.TestCase):
def test_le_menu_des_miroirs(self):
self.verifier("_cache_miroir_git", "_cache_miroir_remplir", 5)
def test_le_menu_du_nettoyage(self):
self.verifier("_cache_nettoyage_auto", "_cache_nettoyage_etat", 4)
def test_le_menu_de_lage(self):
self.verifier("_cache_age", "_cache_lancer", 5)
@ -269,6 +272,7 @@ class TestSousMenusDuCache(unittest.TestCase):
("9", "_cache_combler"),
("10", "_cache_journaux"),
("11", "_cache_transfert"),
("12", "_cache_nettoyage_auto"),
):
self.assertRegex(
corps,
@ -1050,5 +1054,75 @@ class TestCeQueChaqueMachineATire(unittest.TestCase):
)
class TestLesReglagesDuNettoyage(unittest.TestCase):
"""Une valeur que le binaire ne lirait pas ferait échouer le minuteur chaque
nuit, sans que personne ne regarde son journal : elle est refusée ici."""
def test_les_delais_que_le_binaire_lit(self):
from script.todo.qemu_cache_menu import reglage_age_valide
for v in ("90j", "30d", "12h", "1h30m", "0.5j"):
self.assertTrue(reglage_age_valide(v), v)
for v in ("", "90", "jour", "0j", "-3j", "90j; rm -rf /"):
self.assertFalse(reglage_age_valide(v), v)
def test_les_tailles_que_le_binaire_lit(self):
from script.todo.qemu_cache_menu import reglage_taille_valide
for v in ("50G", "50Gio", "500M", "1.5 T", "1024"):
self.assertTrue(reglage_taille_valide(v), v)
for v in ("", "0G", "G", "50X", "50G|x"):
self.assertFalse(reglage_taille_valide(v), v)
def ecrire(self, contenu, cle, valeur):
import shlex
import subprocess
import tempfile
from script.todo.qemu_cache_menu import commande_ecrire_reglage
with tempfile.NamedTemporaryFile(
"w", delete=False, suffix=".env"
) as fh:
fh.write(contenu)
chemin = fh.name
cmd = commande_ecrire_reglage(cle, valeur, chemin)
self.assertTrue(cmd.startswith("sudo sh -c "))
script = shlex.split(cmd[len("sudo ") :])[2]
subprocess.run(["sh", "-c", script], check=True)
with open(chemin, encoding="utf-8") as fh:
return fh.read()
def test_une_ligne_existante_est_remplacee(self):
rendu = self.ecrire(
"EL_LANG=fr\nEL_PURGE_AGE=30j\n", "EL_PURGE_AGE", "90j"
)
self.assertEqual(rendu, "EL_LANG=fr\nEL_PURGE_AGE=90j\n")
def test_une_ligne_absente_est_ajoutee(self):
rendu = self.ecrire("EL_LANG=fr\n", "EL_MAX_SIZE", "50G")
self.assertEqual(rendu, "EL_LANG=fr\nEL_MAX_SIZE=50G\n")
def test_une_valeur_vide_desactive(self):
rendu = self.ecrire("EL_MAX_SIZE=50G\n", "EL_MAX_SIZE", "")
self.assertEqual(rendu, "EL_MAX_SIZE=\n")
def test_sans_reglage_rien_n_est_lance(self):
import contextlib
import io
from unittest import mock
from script.todo import qemu_cache_menu as menu
faux = menu.QemuCacheMenuMixin.__new__(menu.QemuCacheMenuMixin)
faux.execute = mock.MagicMock()
with mock.patch.object(
menu.cache_offline, "reglage", return_value=""
), contextlib.redirect_stdout(io.StringIO()):
for a_blanc in (True, False):
faux._cache_nettoyage_lancer(a_blanc=a_blanc)
faux.execute.exec_command_live.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -42,20 +42,25 @@ def charger():
QC = charger()
def ligne(url, upstream, octets=1000, issue="stored", classe="immutable"):
def ligne(
url, upstream, octets=1000, issue="stored", classe="immutable", statut=None
):
"""Une ligne du journal d'accès du cache.
La CLASSE est ce que le filtre lit désormais : c'est le cache qui décide
si un fichier est figé, et la tenir ici en dur revenait à laisser un index
se faire passer pour un paquet.
se faire passer pour un paquet. Le STATUT n'est écrit que s'il est donné.
"""
return {
l = {
"url": url,
"upstream": upstream,
"bytes": octets,
"outcome": issue,
"class": classe,
}
if statut is not None:
l["status"] = statut
return l
PAQUET_A = (
@ -155,6 +160,31 @@ class TestVerdict(unittest.TestCase):
de mesure, donc pas de succès."""
self.assertFalse(QC.verdict([ligne(PAQUET_A, True)], [], None))
def test_un_miroir_qui_refuse_ne_fait_pas_echouer(self):
"""Un miroir rangé sous un autre chemin répond 404 à chaque paquet ; le
client prend le fichier ailleurs, et le cache le sert du disque. Le
404 repart à l'amont aux deux VM sans que rien n'ait manqué."""
mauvais = PAQUET_A.replace("miroir.example", "autre.example/x")
premier = [
ligne(mauvais, True, octets=153, issue="fetched", statut=404),
ligne(PAQUET_A, True, statut=200),
]
second = [
ligne(mauvais, True, octets=153, issue="fetched", statut=404),
ligne(PAQUET_A, False, issue="hit", statut=200),
]
self.assertTrue(
QC.verdict(premier, second, None),
"le 404 d'un miroir a été compté comme un paquet ressorti",
)
def test_un_paquet_livre_deux_fois_reste_une_faute(self):
"""Le refus est écarté, pas la réponse livrée : un 200 déjà vu qui
repart à l'amont fait toujours échouer."""
premier = [ligne(PAQUET_A, True, statut=200)]
second = [ligne(PAQUET_A, True, statut=200)]
self.assertFalse(QC.verdict(premier, second, None))
def test_le_melange(self):
premier = [ligne(PAQUET_A, True), ligne(PAQUET_B, True)]
second = [

View file

@ -1589,6 +1589,23 @@ class TestLeVerrouAptNeCoutePasDesMinutes(unittest.TestCase):
"les variables sont relues avant que cloud-init les ait écrites",
)
def test_lattente_suit_l_unite_et_non_le_seul_statut(self):
"""cloud-init se déclare en erreur pour un module accessoire, et
« status --wait » rend alors la main pendant que son étape finale
écrit encore l'autorité du cache et les variables."""
from script.qemu.deploy_qemu import (
attente_cloud_final,
cache_env_reload,
)
attente = self.todo._qemu_cloud_init_wait()
self.assertIn(attente_cloud_final(), attente)
self.assertLess(
attente.index(attente_cloud_final()),
attente.index(cache_env_reload()),
"les variables sont relues avant la fin de l'étape qui les écrit",
)
def test_le_nom_du_service_est_celui_que_le_deploiement_donne(self):
"""Deux noms qui divergent et l'attente ne trouve jamais rien."""
from script.qemu import deploy_qemu

View file

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

View file

@ -6849,6 +6849,24 @@ class TestFuiteXlsx(unittest.TestCase):
)
def _openpyxl_ici():
"""Vrai si openpyxl s'importe dans CET interpréteur.
Pas `transform_setup.available("xlsx")` : il répond vrai dès que le venv
dédié l'a, alors que ces tests écrivent leur classeur ici même.
"""
try:
import openpyxl # noqa: F401
except ImportError:
return False
return True
@unittest.skipUnless(
_openpyxl_ici(),
"openpyxl absent de cet interpréteur : pip install -r"
" requirement/erplibre_require-ments.txt",
)
class TestHorodatageDeLaCopie(unittest.TestCase):
"""La copie ne porte pas l'instant où elle a été tirée.