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:
commit
f317401881
63 changed files with 3737 additions and 324 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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é
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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=""):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
------------------
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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é."))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -109,9 +109,9 @@ func (p *Proxy) boucle(
|
|||
w http.ResponseWriter, u *url.URL, class Class, method, client string,
|
||||
) {
|
||||
msg := enCommentaire(fmt.Sprintf(
|
||||
"erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+
|
||||
T("erplibre_go_qemu_cache : requête adressée au cache lui-même, refusée.\n"+
|
||||
" demandé : %s\n"+
|
||||
"La relayer la renverrait vers cette même écoute, sans fin.\n",
|
||||
"La relayer la renverrait vers cette même écoute, sans fin.\n"),
|
||||
u))
|
||||
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
||||
w.Header().Set("X-ERPLibre-Cache", OutcomeError)
|
||||
|
|
@ -123,7 +123,7 @@ func (p *Proxy) boucle(
|
|||
Outcome: OutcomeError, Status: http.StatusLoopDetected,
|
||||
Client: client,
|
||||
})
|
||||
log.Printf("boucle refusée : %s vise le cache lui-même", u)
|
||||
log.Printf(T("boucle refusée : %s vise le cache lui-même"), u)
|
||||
}
|
||||
|
||||
// memeAdresse compare deux « hôte:port » par leur VALEUR : « ::ffff:a.b.c.d »
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ type BypassEntry struct {
|
|||
func NormalizeMAC(s string) (string, error) {
|
||||
brut := strings.TrimSpace(s)
|
||||
if brut == "" {
|
||||
return "", fmt.Errorf("adresse MAC vide")
|
||||
return "", fmt.Errorf("%s", T("adresse MAC vide"))
|
||||
}
|
||||
// Douze caractères hexadécimaux sans séparateur : la forme que rendent
|
||||
// plusieurs outils, que net.ParseMAC ne lit pas.
|
||||
|
|
@ -68,14 +68,14 @@ func NormalizeMAC(s string) (string, error) {
|
|||
}
|
||||
adr, err := net.ParseMAC(brut)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("adresse MAC illisible %q", s)
|
||||
return "", fmt.Errorf(T("adresse MAC illisible %q"), s)
|
||||
}
|
||||
if len(adr) != 6 {
|
||||
return "", fmt.Errorf("adresse MAC de %d octets, six attendus", len(adr))
|
||||
return "", fmt.Errorf(T("adresse MAC de %d octets, six attendus"), len(adr))
|
||||
}
|
||||
if adr[0]&1 == 1 {
|
||||
return "", fmt.Errorf(
|
||||
"%s est une adresse de groupe : jamais une adresse source", adr)
|
||||
T("%s est une adresse de groupe : jamais une adresse source"), adr)
|
||||
}
|
||||
nulle := true
|
||||
for _, o := range adr {
|
||||
|
|
@ -85,7 +85,7 @@ func NormalizeMAC(s string) (string, error) {
|
|||
}
|
||||
}
|
||||
if nulle {
|
||||
return "", fmt.Errorf("l'adresse nulle n'identifie aucune machine")
|
||||
return "", fmt.Errorf("%s", T("l'adresse nulle n'identifie aucune machine"))
|
||||
}
|
||||
return strings.ToLower(adr.String()), nil
|
||||
}
|
||||
|
|
|
|||
164
script/qemu_cache/catalogue_en.go
Normal file
164
script/qemu_cache/catalogue_en.go
Normal 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",
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
139
script/qemu_cache/completion_test.go
Normal file
139
script/qemu_cache/completion_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -165,17 +165,17 @@ func DepotDeURL(u *url.URL) (string, string, bool) {
|
|||
func (g *GitMirror) CheminMiroir(depot string) (string, error) {
|
||||
u, err := url.Parse(depot)
|
||||
if err != nil || u.Host == "" {
|
||||
return "", fmt.Errorf("dépôt illisible %q", depot)
|
||||
return "", fmt.Errorf(T("dépôt illisible %q"), depot)
|
||||
}
|
||||
chemin := strings.Trim(u.Path, "/")
|
||||
chemin = strings.TrimSuffix(chemin, ".git")
|
||||
if chemin == "" {
|
||||
return "", fmt.Errorf("dépôt sans chemin %q", depot)
|
||||
return "", fmt.Errorf(T("dépôt sans chemin %q"), depot)
|
||||
}
|
||||
// Un « .. » dans le chemin ferait écrire hors de la racine.
|
||||
for _, seg := range strings.Split(chemin, "/") {
|
||||
if seg == "." || seg == ".." || seg == "" {
|
||||
return "", fmt.Errorf("chemin de dépôt refusé %q", depot)
|
||||
return "", fmt.Errorf(T("chemin de dépôt refusé %q"), depot)
|
||||
}
|
||||
}
|
||||
return filepath.Join(g.Dir, u.Host, chemin+".git"), nil
|
||||
|
|
@ -218,7 +218,7 @@ func (g *GitMirror) Assurer(ctx context.Context, depot string) (string, bool) {
|
|||
if !existe {
|
||||
if !g.placeSuffisante() {
|
||||
log.Printf(
|
||||
"miroir refusé pour %s : moins de %s libres sur le disque",
|
||||
T("miroir refusé pour %s : moins de %s libres sur le disque"),
|
||||
depot, HumanBytes(g.PlancherLibre))
|
||||
return "", false
|
||||
}
|
||||
|
|
@ -342,8 +342,8 @@ func (g *GitMirror) sonderAmont(
|
|||
g.figes = map[string]bool{}
|
||||
}
|
||||
g.figes[adresse] = true
|
||||
log.Printf("miroir git : sonde de %s en échec (%v) ; ses miroirs"+
|
||||
" sont servis sans rafraîchissement jusqu'à ce qu'il réponde",
|
||||
log.Printf(T("miroir git : sonde de %s en échec (%v) ; ses miroirs"+
|
||||
" sont servis sans rafraîchissement jusqu'à ce qu'il réponde"),
|
||||
adresse, err)
|
||||
}
|
||||
return true
|
||||
|
|
@ -420,7 +420,7 @@ func (g *GitMirror) gitBorne(
|
|||
)
|
||||
sortie, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
return fmt.Errorf("git %s : %v : %s",
|
||||
return fmt.Errorf(T("git %s : %v : %s"),
|
||||
strings.Join(args, " "), err, court(string(sortie)))
|
||||
}
|
||||
return nil
|
||||
|
|
@ -684,7 +684,7 @@ func (g *GitMirror) Depots() []Depot {
|
|||
// mal formé ne doit pas pouvoir effacer autre chose.
|
||||
func (g *GitMirror) Retirer(chemin string) error {
|
||||
if g == nil || g.Dir == "" {
|
||||
return fmt.Errorf("miroir éteint")
|
||||
return fmt.Errorf("%s", T("miroir éteint"))
|
||||
}
|
||||
abs, err := filepath.Abs(chemin)
|
||||
if err != nil {
|
||||
|
|
@ -696,7 +696,7 @@ func (g *GitMirror) Retirer(chemin string) error {
|
|||
}
|
||||
if !strings.HasPrefix(abs, racine+string(os.PathSeparator)) ||
|
||||
!strings.HasSuffix(abs, ".git") {
|
||||
return fmt.Errorf("hors des miroirs : %s", chemin)
|
||||
return fmt.Errorf(T("hors des miroirs : %s"), chemin)
|
||||
}
|
||||
v := g.verrou(abs)
|
||||
v.Lock()
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ const SentinelleAmonts = ".amonts-oublies"
|
|||
// errAmontConnuMuet dit qu'aucune connexion n'a été tentée : l'établissement
|
||||
// vers cet amont a échoué il y a moins d'une fenêtre.
|
||||
var errAmontConnuMuet = errors.New(
|
||||
"amont connu muet : l'établissement vient d'échouer, il n'est pas retenté")
|
||||
T("amont connu muet : l'établissement vient d'échouer, il n'est pas retenté"))
|
||||
|
||||
// Joignabilite retient, par « hôte:port », l'instant du dernier
|
||||
// établissement de connexion manqué.
|
||||
|
|
@ -216,6 +216,6 @@ func decrireMuet(cause error) string {
|
|||
if !errors.Is(cause, errAmontConnuMuet) {
|
||||
return ""
|
||||
}
|
||||
return "L'établissement vers cet amont vient d'échouer : aucune connexion\n" +
|
||||
"n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n"
|
||||
return T("L'établissement vers cet amont vient d'échouer : aucune connexion\n" +
|
||||
"n'a été tentée pour cette requête, l'amont sera retenté sous peu.\n")
|
||||
}
|
||||
|
|
|
|||
95
script/qemu_cache/langue.go
Normal file
95
script/qemu_cache/langue.go
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// Langues servies. Le français est la langue du code source : chaque message y
|
||||
// est écrit tel quel et sert de clé au catalogue anglais (catalogue_en.go).
|
||||
const (
|
||||
LangueFr = "fr"
|
||||
LangueEn = "en"
|
||||
)
|
||||
|
||||
var (
|
||||
langueUne sync.Once
|
||||
langue string
|
||||
)
|
||||
|
||||
// T rend un message humain dans la langue du binaire.
|
||||
//
|
||||
// La clé EST le texte français : le message reste lisible à l'endroit où il
|
||||
// est écrit, et un texte absent du catalogue sort en français plutôt que vide.
|
||||
// Seuls les textes destinés à une personne passent par ici — journaux,
|
||||
// sorties affichées, aide des options, erreurs montrées, corps d'une réponse
|
||||
// d'erreur. Une règle nft, une ligne de protocole, un code de verdict ou une
|
||||
// clé JSON sont lus par des programmes et ne se traduisent jamais.
|
||||
func T(fr string) string {
|
||||
if Langue() == LangueEn {
|
||||
if en, ok := anglais[fr]; ok {
|
||||
return en
|
||||
}
|
||||
}
|
||||
return fr
|
||||
}
|
||||
|
||||
// Langue rend la langue retenue, résolue une seule fois à la première demande.
|
||||
//
|
||||
// Paresseuse, et non posée par main après l'analyse des options : des erreurs
|
||||
// sont construites à l'initialisation du paquet, et l'aide des options est
|
||||
// écrite avant flag.Parse. L'option --lang passe avant EL_LANG, parce que sudo
|
||||
// retire l'environnement : un binaire lancé par « sudo » ne verrait jamais la
|
||||
// langue de qui l'appelle.
|
||||
func Langue() string {
|
||||
langueUne.Do(func() {
|
||||
langue = langueDemandee(os.Args[1:], os.Getenv)
|
||||
})
|
||||
return langue
|
||||
}
|
||||
|
||||
// definirLangue impose une langue, en court-circuitant la résolution.
|
||||
func definirLangue(l string) {
|
||||
langueUne.Do(func() {})
|
||||
langue = normaliserLangue(l)
|
||||
}
|
||||
|
||||
// langueDemandee lit « --lang X », « --lang=X », « -lang X » ou « -lang=X »
|
||||
// dans les arguments, puis EL_LANG. Rend « fr » par défaut, et pour toute
|
||||
// valeur qui n'est pas de l'anglais. La lecture s'arrête à « -- », au-delà
|
||||
// duquel un argument n'est plus une option.
|
||||
func langueDemandee(args []string, env func(string) string) string {
|
||||
for i, a := range args {
|
||||
if a == "--" {
|
||||
break
|
||||
}
|
||||
if !strings.HasPrefix(a, "-") {
|
||||
continue
|
||||
}
|
||||
nom, valeur, egal := strings.Cut(strings.TrimLeft(a, "-"), "=")
|
||||
if nom != "lang" {
|
||||
continue
|
||||
}
|
||||
if !egal {
|
||||
if i+1 >= len(args) {
|
||||
break
|
||||
}
|
||||
valeur = args[i+1]
|
||||
}
|
||||
return normaliserLangue(valeur)
|
||||
}
|
||||
return normaliserLangue(env("EL_LANG"))
|
||||
}
|
||||
|
||||
// normaliserLangue ramène une valeur libre à une langue servie : « en »,
|
||||
// « EN », « en_CA.UTF-8 » donnent l'anglais, tout le reste le français.
|
||||
func normaliserLangue(s string) string {
|
||||
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(s)), LangueEn) {
|
||||
return LangueEn
|
||||
}
|
||||
return LangueFr
|
||||
}
|
||||
238
script/qemu_cache/langue_test.go
Normal file
238
script/qemu_cache/langue_test.go
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Les tests lisent les messages en français, langue du code source : une
|
||||
// langue héritée de l'environnement de qui les lance les ferait échouer sur
|
||||
// une chaîne traduite.
|
||||
func TestMain(m *testing.M) {
|
||||
definirLangue(LangueFr)
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// clesT rend le texte de chaque appel T("…") des fichiers du paquet, tests
|
||||
// exclus, avec sa position. Un argument qui n'est pas un littéral est une
|
||||
// erreur : le catalogue ne pourrait pas le traduire.
|
||||
func clesT(t *testing.T) map[string]string {
|
||||
t.Helper()
|
||||
fichiers, err := filepath.Glob("*.go")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
fs := token.NewFileSet()
|
||||
cles := map[string]string{}
|
||||
for _, f := range fichiers {
|
||||
if strings.HasSuffix(f, "_test.go") {
|
||||
continue
|
||||
}
|
||||
af, err := parser.ParseFile(fs, f, nil, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ast.Inspect(af, func(n ast.Node) bool {
|
||||
c, ok := n.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
id, ok := c.Fun.(*ast.Ident)
|
||||
if !ok || id.Name != "T" || len(c.Args) != 1 {
|
||||
return true
|
||||
}
|
||||
texte, ok := litteralT(c.Args[0])
|
||||
pos := fs.Position(c.Pos()).String()
|
||||
if !ok {
|
||||
// Une conversion de valeur connue — T(string(gran)) — se
|
||||
// vérifie par son propre test ; tout autre argument non
|
||||
// littéral est refusé.
|
||||
if conv, estConv := c.Args[0].(*ast.CallExpr); estConv {
|
||||
if fn, ok := conv.Fun.(*ast.Ident); ok && fn.Name == "string" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
t.Errorf("%s : T() sur un texte non littéral", pos)
|
||||
return true
|
||||
}
|
||||
cles[texte] = pos
|
||||
return true
|
||||
})
|
||||
}
|
||||
return cles
|
||||
}
|
||||
|
||||
func litteralT(e ast.Expr) (string, bool) {
|
||||
switch v := e.(type) {
|
||||
case *ast.BasicLit:
|
||||
if v.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
s, err := strconv.Unquote(v.Value)
|
||||
return s, err == nil
|
||||
case *ast.BinaryExpr:
|
||||
if v.Op != token.ADD {
|
||||
return "", false
|
||||
}
|
||||
a, ok1 := litteralT(v.X)
|
||||
b, ok2 := litteralT(v.Y)
|
||||
return a + b, ok1 && ok2
|
||||
case *ast.ParenExpr:
|
||||
return litteralT(v.X)
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// Un message enveloppé sans traduction sortirait en français dans un binaire
|
||||
// réglé en anglais, sans que rien ne le signale.
|
||||
func TestChaqueMessageATraductionAnglaise(t *testing.T) {
|
||||
cles := clesT(t)
|
||||
if len(cles) < 100 {
|
||||
t.Fatalf("%d appels T() trouvés : le contrôle ne lit pas le code", len(cles))
|
||||
}
|
||||
var manquants []string
|
||||
for texte, pos := range cles {
|
||||
if _, ok := anglais[texte]; !ok {
|
||||
manquants = append(manquants, pos+" "+strconv.Quote(texte))
|
||||
}
|
||||
}
|
||||
sort.Strings(manquants)
|
||||
for _, m := range manquants {
|
||||
t.Errorf("sans traduction : %s", m)
|
||||
}
|
||||
}
|
||||
|
||||
// Une entrée qu'aucun appel n'emploie est un message retiré, ou une clé qui
|
||||
// ne correspond plus au texte réécrit : sa traduction ne sortirait jamais.
|
||||
func TestAucuneTraductionOrpheline(t *testing.T) {
|
||||
cles := clesT(t)
|
||||
// Clés employées par conversion : les granularités affichées.
|
||||
for _, g := range []Granularite{ParJour, ParSemaine, ParMois} {
|
||||
cles[string(g)] = "granularité"
|
||||
}
|
||||
var orphelines []string
|
||||
for texte := range anglais {
|
||||
if _, ok := cles[texte]; !ok {
|
||||
orphelines = append(orphelines, strconv.Quote(texte))
|
||||
}
|
||||
}
|
||||
sort.Strings(orphelines)
|
||||
for _, o := range orphelines {
|
||||
t.Errorf("traduction sans appel : %s", o)
|
||||
}
|
||||
}
|
||||
|
||||
var verbeFormat = regexp.MustCompile(`%[-+# 0]*(?:\d+|\*)?(?:\.(?:\d+|\*))?[a-zA-Z%]`)
|
||||
|
||||
// Une traduction qui perd, ajoute ou déplace un verbe de format décale les
|
||||
// valeurs : « %d VM » rendu « %s VM » écrit « %!s(int=3) ».
|
||||
func TestLesTraductionsGardentLeursVerbes(t *testing.T) {
|
||||
for fr, en := range anglais {
|
||||
a, b := verbeFormat.FindAllString(fr, -1), verbeFormat.FindAllString(en, -1)
|
||||
if strings.Join(a, " ") != strings.Join(b, " ") {
|
||||
t.Errorf("verbes %v ≠ %v pour %q", a, b, fr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLaLangueSeResoutDansLOrdre(t *testing.T) {
|
||||
env := func(v string) func(string) string {
|
||||
return func(k string) string {
|
||||
if k == "EL_LANG" {
|
||||
return v
|
||||
}
|
||||
return ""
|
||||
}
|
||||
}
|
||||
cas := []struct {
|
||||
nom string
|
||||
args []string
|
||||
elLang string
|
||||
veut string
|
||||
}{
|
||||
{"défaut", nil, "", LangueFr},
|
||||
{"EL_LANG anglais", nil, "en", LangueEn},
|
||||
{"EL_LANG régional", nil, "en_CA.UTF-8", LangueEn},
|
||||
{"langue inconnue", nil, "de", LangueFr},
|
||||
{"--lang espacé", []string{"--status", "--lang", "en"}, "", LangueEn},
|
||||
{"-lang=", []string{"-lang=en"}, "", LangueEn},
|
||||
{"--lang= l'emporte sur EL_LANG", []string{"--lang=fr"}, "en", LangueFr},
|
||||
{"--lang sans valeur", []string{"--lang"}, "en", LangueEn},
|
||||
{"après --, ce n'est plus une option", []string{"--", "--lang", "en"}, "", LangueFr},
|
||||
{"une valeur n'est pas une option", []string{"--age-by", "lang"}, "", LangueFr},
|
||||
}
|
||||
for _, c := range cas {
|
||||
if got := langueDemandee(c.args, env(c.elLang)); got != c.veut {
|
||||
t.Errorf("%s : %q, attendu %q", c.nom, got, c.veut)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnMessageSortEnAnglais(t *testing.T) {
|
||||
defer definirLangue(LangueFr)
|
||||
definirLangue(LangueEn)
|
||||
if got := T("écoutes : http %d, tls %d"); got != "listeners: http %d, tls %d" {
|
||||
t.Errorf("anglais : %q", got)
|
||||
}
|
||||
if got := T("texte que le catalogue ignore"); got != "texte que le catalogue ignore" {
|
||||
t.Errorf("un texte inconnu doit sortir tel quel, pas %q", got)
|
||||
}
|
||||
definirLangue(LangueFr)
|
||||
if got := T("écoutes : http %d, tls %d"); got != "écoutes : http %d, tls %d" {
|
||||
t.Errorf("français : %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Le Python du menu lit ces libellés de --status et cette phrase du journal
|
||||
// dans les DEUX langues : une traduction qui s'en écarte le rend aveugle.
|
||||
func TestLesLibellesLusParLeMenuRestentReconnaissables(t *testing.T) {
|
||||
attendus := map[string]string{
|
||||
"autorité : %s\n": "authority",
|
||||
"empreinte : %s\n": "fingerprint",
|
||||
"exceptions : aucune\n": "exceptions",
|
||||
"dépôts git : %d en miroir, %s\n": "git repos",
|
||||
"tunnel opaque retenu pour %s (%d échec(s)) : %v": "opaque tunnel kept for %s (",
|
||||
}
|
||||
for fr, prefixe := range attendus {
|
||||
if !strings.HasPrefix(anglais[fr], prefixe) {
|
||||
t.Errorf("%q traduit en %q, qui ne commence plus par %q",
|
||||
fr, anglais[fr], prefixe)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// L'option doit être DÉCLARÉE : flag.Parse refuse une option inconnue, et
|
||||
// chaque appel du menu qui la passe échouerait alors avant de rien dire.
|
||||
// Le binaire est construit et lancé pour de vrai, sur un cache vide.
|
||||
func TestLeBinaireAccepteLangEtParleAnglais(t *testing.T) {
|
||||
if _, err := exec.LookPath("go"); err != nil {
|
||||
t.Skip("go absent du PATH")
|
||||
}
|
||||
dir := t.TempDir()
|
||||
bin := filepath.Join(dir, "cache")
|
||||
if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil {
|
||||
t.Fatalf("construction : %v\n%s", err, out)
|
||||
}
|
||||
cmd := exec.Command(bin, "--lang", "en", "--status", "--cache-dir", filepath.Join(dir, "vide"),
|
||||
"--ca-dir", filepath.Join(dir, "ca"))
|
||||
cmd.Env = append(os.Environ(), "EL_LANG=fr")
|
||||
out, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
t.Fatalf("--lang en --status : %v\n%s", err, out)
|
||||
}
|
||||
if !strings.Contains(string(out), "directory :") {
|
||||
t.Errorf("sortie non anglaise malgré --lang en :\n%s", out)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ func originalDst(c net.Conn) (string, error) {
|
|||
}
|
||||
tcp, ok := c.(*net.TCPConn)
|
||||
if !ok {
|
||||
return "", errors.New("la connexion n'est pas une socket TCP")
|
||||
return "", errors.New(T("la connexion n'est pas une socket TCP"))
|
||||
}
|
||||
raw, err := tcp.SyscallConn()
|
||||
if err != nil {
|
||||
|
|
@ -66,7 +66,7 @@ func originalDst(c net.Conn) (string, error) {
|
|||
return "", inner
|
||||
}
|
||||
if addr == "" || addr == "0.0.0.0:0" {
|
||||
return "", errors.New("aucune destination d'origine : la connexion n'est pas redirigée")
|
||||
return "", errors.New(T("aucune destination d'origine : la connexion n'est pas redirigée"))
|
||||
}
|
||||
return addr, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,5 +14,5 @@ import (
|
|||
// connexion redirigée. Ailleurs, le tunnel opaque est impossible et le dire
|
||||
// vaut mieux que de deviner une adresse.
|
||||
func originalDst(net.Conn) (string, error) {
|
||||
return "", errors.New("interception transparente : Linux seulement")
|
||||
return "", errors.New(T("interception transparente : Linux seulement"))
|
||||
}
|
||||
|
|
|
|||
142
script/qemu_cache/plafond.go
Normal file
142
script/qemu_cache/plafond.go
Normal 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
|
||||
}
|
||||
136
script/qemu_cache/plafond_test.go
Normal file
136
script/qemu_cache/plafond_test.go
Normal 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é")
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
207
script/qemu_cache/revalidation_test.go
Normal file
207
script/qemu_cache/revalidation_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
200
script/qemu_cache/variante_test.go
Normal file
200
script/qemu_cache/variante_test.go
Normal 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())
|
||||
}
|
||||
}
|
||||
|
|
@ -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é.
|
||||
|
|
|
|||
|
|
@ -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 -"
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() + "; "
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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__":
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
103
test/test_qemu_cache_langue.py
Normal file
103
test/test_qemu_cache_langue.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
#!/usr/bin/env python3
|
||||
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
"""La langue du binaire du cache, vue du menu.
|
||||
|
||||
Le binaire traduit ses messages humains selon --lang. Le menu doit donc dire
|
||||
laquelle il veut, et la bonne : la langue de todo.py pour ce qu'il AFFICHE,
|
||||
le français pour ce qu'il LIT — il ne connaît les libellés que dans cette
|
||||
langue. Et ce qu'il lit dans un journal écrit par le service, dont la langue
|
||||
est fixée à l'installation, il doit le reconnaître dans les deux.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
RACINE = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(RACINE))
|
||||
|
||||
from script.todo import qemu_cache_menu as menu # noqa: E402
|
||||
|
||||
CATALOGUE = RACINE / "script" / "qemu_cache" / "catalogue_en.go"
|
||||
|
||||
|
||||
class TestLOptionDeLangue(unittest.TestCase):
|
||||
def test_une_sortie_lue_est_demandee_en_francais(self):
|
||||
with mock.patch.object(menu, "get_lang", return_value="en"):
|
||||
self.assertEqual(menu.option_langue(lue=True), "--lang fr")
|
||||
|
||||
def test_une_sortie_affichee_suit_la_langue_de_todo(self):
|
||||
for langue in ("fr", "en"):
|
||||
with mock.patch.object(menu, "get_lang", return_value=langue):
|
||||
self.assertEqual(menu.option_langue(), f"--lang {langue}")
|
||||
|
||||
def test_le_pre_remplissage_parle_la_langue_de_todo(self):
|
||||
"""Lancé par sudo -u : EL_LANG n'y arriverait pas, l'option si."""
|
||||
with mock.patch.object(menu, "get_lang", return_value="en"):
|
||||
cmd = menu.miroir_prefetch_cmd("/tmp/liste", [])
|
||||
self.assertIn("--lang en", cmd)
|
||||
|
||||
def test_le_retrait_d_exception_garde_nft_en_bout_de_tube(self):
|
||||
"""Le message humain va à l'erreur standard ; la sortie standard
|
||||
porte les règles que nft lit, et ne se traduit jamais."""
|
||||
with mock.patch.object(menu, "get_lang", return_value="en"):
|
||||
cmd = menu.bypass_retrait_cmd("52:54:00:00:00:04")
|
||||
self.assertIn("--lang en", cmd)
|
||||
self.assertTrue(cmd.endswith("| sudo nft -f -"))
|
||||
|
||||
|
||||
class TestCeQueLeMenuLit(unittest.TestCase):
|
||||
def test_l_occupation_du_miroir_est_lue_en_francais(self):
|
||||
vu = {}
|
||||
|
||||
def faux_lire(cmd, delai=15):
|
||||
vu["cmd"] = cmd
|
||||
return "objets : 3\ndépôts git : 12 en miroir, 4.2 Gio\n"
|
||||
|
||||
with mock.patch.object(
|
||||
menu.QemuCacheMenuMixin, "_cache_lire", staticmethod(faux_lire)
|
||||
), mock.patch.object(menu, "get_lang", return_value="en"):
|
||||
rendu = menu.QemuCacheMenuMixin._cache_miroir_occupation()
|
||||
self.assertIn("--lang fr", vu["cmd"])
|
||||
self.assertEqual(rendu, ("12", "4.2 Gio"))
|
||||
|
||||
def test_le_journal_est_lu_dans_les_deux_langues(self):
|
||||
"""La langue du service est celle de son installation, pas celle de
|
||||
qui ouvre le menu : un refus appris doit être reconnu dans les deux."""
|
||||
journal = (
|
||||
"tunnel opaque retenu pour npm.example (3 échec(s)) : EOF\n"
|
||||
"opaque tunnel kept for pypi.example (3 failure(s)): EOF\n"
|
||||
)
|
||||
commandes = []
|
||||
|
||||
def faux_lire(cmd, delai=15):
|
||||
commandes.append(cmd)
|
||||
return journal
|
||||
|
||||
with mock.patch.object(
|
||||
menu.QemuCacheMenuMixin, "_cache_lire", staticmethod(faux_lire)
|
||||
):
|
||||
hotes = menu.QemuCacheMenuMixin._cache_refus_appris()
|
||||
self.assertEqual(hotes, ["npm.example", "pypi.example"])
|
||||
for motif in menu.REFUS_APPRIS:
|
||||
self.assertIn(motif, commandes[0])
|
||||
|
||||
def test_le_catalogue_porte_ce_que_le_menu_lit(self):
|
||||
"""Une traduction qui s'écarte de ces libellés rendrait le menu
|
||||
aveugle sans qu'aucun test du binaire ne le voie."""
|
||||
texte = CATALOGUE.read_text(encoding="utf-8")
|
||||
self.assertIn('"opaque tunnel kept for %s (', texte)
|
||||
for libelle in ("authority : %s", "fingerprint : %s"):
|
||||
self.assertIn(f'"{libelle}', texte)
|
||||
for prefixe in menu.LIBELLES_TUS:
|
||||
self.assertTrue(
|
||||
prefixe in texte or prefixe in ("autorité", "empreinte"),
|
||||
prefixe,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue