Merge branch 'dev/qemu_cache'

[FIX] qemu : clé portable du cache, re-clé du magasin, image reprise

10 commits. Le magasin rangeait un objet sous le chemin ENTIER de son URL : un
miroir qui préfixe ce chemin donnait deux clés pour les mêmes octets, et le
cache retéléchargeait ce qu'il détenait — sur un journal de 7099 noms livrés,
1124 vivaient sous plusieurs chemins, 3,18 Gio repris à l'amont pour rien.
Seuls les six derniers segments comptent désormais, quatre pour la famille
pacman dont les chemins sont plus courts. « --recle » rattrape un magasin
déjà rempli sans rien retélécharger, et une image gardée devenue périmée est
reprise une fois au lieu de perdre la campagne entière.
Vérifié : suites Go et Python vertes, et une campagne hors ligne où la seconde
VM n'a repris aucun octet de paquet à l'amont.

--- EN ---

10 commits. The store filed an object under the WHOLE path of its URL: a mirror
prefixing that path gave two keys for the same bytes, and the cache
re-downloaded what it held — over a log of 7099 delivered names, 1124 lived
under several paths, 3.18 GiB taken upstream for nothing. Only the last six
segments count now, four for the pacman family whose paths are shorter.
« --recle » brings over a store already filled, downloading nothing, and a
kept image gone stale is fetched once more instead of losing a whole campaign.
Checked: Go and Python suites green, and an offline campaign where the second
VM took no package byte from upstream.

Assisted-by: Claude Opus 5
This commit is contained in:
Mathieu Benoit 2026-09-21 05:40:29 -04:00
commit 4ecd9e313b
13 changed files with 816 additions and 31 deletions

View file

@ -80,6 +80,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL
- The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause
- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified
- `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path
<!-- [fr] -->
@ -132,6 +133,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte
- Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause
- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier
- `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin
<!-- [en] -->
## Changed
@ -227,6 +229,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
- The connection guide fits an 80-column terminal even on a VM carrying its tools and a desktop, where it rendered 103 columns wide: the frame overflowed, the terminal wrapped wherever it liked, and the two-column alignment — the only thing making the guide readable at a glance — was lost. A gloss now wraps under its column, and moves below its command when that command leaves no room. The layout carries the rule, not the length of the texts, which would have held only until the next tool
- A VM deployed offline gets its cache variables before its installation starts. « cloud-init status --wait » returns as soon as cloud-init declares itself in error — an accessory module suffices — while its final stage is still writing the authority, `/etc/environment` and the sudoers file; a session opened in that second lived without them, and an install run by sudo then rejected the cache certificate. The deployment now waits for the cloud-final unit, and only its « activating » state, that unit being a oneshot that stays active once finished
- Repository metadata an RPM distribution names after the hash of its content is served from disk like a package, and a range request on a file the cache does not hold fetches the whole file in the background, once per key. Such an index of tens of megabytes was taken whole on every install — about 110 MB per VM on a RHEL family system — and dnf, which fetches its zchunk metadata by ranges, got a « 504 » with upstream cut, no range being stored
- A VM deployed offline no longer waits for a time synchronisation that cannot come. An image that enables `systemd-time-wait-sync` holds `time-sync.target` until the first NTP answer, and cloud-init's final stage is ordered after it: with no reachable time server, that stage never ran, nor the ssh host keys it generates, and the VM reached its login prompt without ever answering ssh
- The download cache no longer re-fetches a package it already holds because a mirror files it under another path. A mirror prefixes the path as it pleases — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — and the whole path gave two keys for the same bytes: over a log of 7099 delivered names, 1124 lived under several paths and 3.18 GiB went back upstream for nothing. Only the last six segments count now, empty ones falling with them. Six is the smallest collision-free bound: a Debian path carries exactly six, so five would serve Ubuntu's package for Debian's, which bears the same name for other bytes. Pacman packages stop at four, their paths being shorter than the common bound, which drops the mirror's prefix and keeps the repository name. A store filled before this change is brought over by `--recle`
- A kept image that has gone stale is fetched once more instead of ending the deployment. A distribution's « latest » directory moves with each point release and the published sum stops describing the image on disk, with no byte corrupted: the check deleted it and exited, losing a whole campaign — three VMs — to a staleness one download repairs. A second mismatch is on freshly downloaded bytes, stops everything and deletes the image; called with no mirror list, and for unreachable sums, nothing changes
- `long_test/qemu_cache.py` no longer fails a campaign where the cache served everything. A URL counts as « already seen » only if the first VM obtained its bytes: one package lives under two paths depending on the mirror, and the first VM can get a « 504 » on one — upstream judged mute — then be served from disk by the other, so nothing was stored under the first path and the second VM's honest download was counted a fault. A missing status counts as delivered, older logs not always writing it, and the new-files line names both of its causes instead of blaming Arch whatever the system measured
<!-- [fr] -->
@ -282,6 +290,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
- Le guide de connexion tient dans un terminal de 80 colonnes même sur une VM qui porte ses outils et un bureau, là où il en rendait 103 : le cadre débordait, le terminal repliait où il voulait, et l'alignement en deux colonnes — seule chose qui rend le guide lisible d'un coup d'œil — se perdait. La glose se replie désormais sous sa colonne, et passe sous sa commande quand celle-ci ne laisse plus de quoi écrire. C'est la mise en page qui porte la règle, non la longueur des textes, qui n'aurait tenu que jusqu'au prochain outil
- Une VM déployée hors ligne reçoit les variables du cache avant que son installation ne commence. « cloud-init status --wait » rend la main dès que cloud-init se déclare en erreur — un module accessoire y suffit — alors que son étape finale écrit encore l'autorité, `/etc/environment` et le fichier sudoers ; une session ouverte dans cette seconde-là vivait sans elles, et une installation lancée par sudo rejetait alors le certificat du cache. Le déploiement attend désormais l'unité cloud-final, et son seul état « activating », ce service étant un oneshot qui reste actif une fois fini
- Une métadonnée de dépôt qu'une distribution RPM nomme d'après l'empreinte de son contenu est servie du disque comme un paquet, et une demande de plage sur un fichier que le cache ne détient pas fait prendre le fichier entier en arrière-plan, une fois par clé. Un tel index de dizaines de mégaoctets était repris en entier à chaque installation — environ 110 Mo par VM sur un système de la famille RHEL — et dnf, qui prend ses métadonnées zchunk par plages, recevait un « 504 » l'amont coupé, aucune plage ne se gardant
- Une VM déployée hors ligne n'attend plus une synchronisation de l'heure qui ne peut pas venir. Une image qui active `systemd-time-wait-sync` retient `time-sync.target` jusqu'à la première réponse NTP, et l'étape finale de cloud-init est ordonnée après elle : sans serveur de temps joignable, cette étape ne démarrait jamais, ni les clés d'hôte ssh qu'elle génère, et la VM atteignait son invite de connexion sans jamais répondre en ssh
- Le cache de téléchargement ne reprend plus à l'amont un paquet qu'il détient déjà parce qu'un miroir le range sous un autre chemin. Un miroir préfixe le chemin à sa guise — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — et le chemin entier donnait deux clés pour les mêmes octets : sur un journal de 7099 noms livrés, 1124 vivaient sous plusieurs chemins et 3,18 Gio repartaient à l'amont pour rien. Seuls les six derniers segments comptent désormais, les segments vides tombant avec eux. Six est la plus petite borne sans collision : un chemin Debian en porte exactement six, si bien que cinq servirait le paquet d'Ubuntu pour celui de Debian, qui porte le même nom pour d'autres octets. Les paquets pacman s'arrêtent à quatre, leurs chemins étant plus courts que la borne commune, ce qui retire le préfixe du miroir et garde le nom du dépôt. Un magasin rempli avant ce changement se rattrape par `--recle`
- Une image gardée devenue périmée est reprise une fois au lieu d'arrêter le déploiement. Le répertoire « latest » d'une distribution avance à chaque version mineure et la somme publiée cesse de décrire l'image du disque, sans qu'un octet soit corrompu : la vérification la supprimait puis sortait, perdant une campagne entière — trois VM — pour une péremption qu'un seul téléchargement répare. Un second écart porte sur des octets fraîchement téléchargés, arrête tout et supprime l'image ; appelée sans liste de miroirs, et pour des sommes injoignables, rien ne change
- `long_test/qemu_cache.py` ne fait plus échouer une campagne où le cache a tout servi. Une URL n'est « déjà vue » que si la première VM en a obtenu les octets : un même paquet vit sous deux chemins selon le miroir, et la première VM peut recevoir « 504 » sur l'un — amont jugé muet — puis être servie du disque par l'autre, si bien que rien n'était rangé sous le premier chemin et que le téléchargement honnête de la seconde y était compté en faute. Un statut absent vaut livré, les journaux d'avant ne l'écrivant pas toujours, et la ligne des fichiers neufs nomme ses deux causes au lieu d'imputer à Arch quelle que soit la distribution mesurée
<!-- [en] -->
## Removed

View file

@ -60,6 +60,7 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Une entrée du cache de téléchargement peut être retirée, par son URL. Une somme qui ne correspond pas n'invalidait rien : le magasin continuait de servir les mêmes octets, et retélécharger ne changeait rien puisque c'est lui qui répond. Aucune des deux purges ne l'atteint — `--purge` efface tout, et `--purge-older-than` saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. `--oublie` est le symétrique de `--detient` : mêmes lignes en entrée, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Les deux échecs de somme le nomment désormais, avec l'URL exacte
- Le journal d'installation porte ce que l'hôte a décidé avant de lancer : l'autorité du cache de téléchargement posée ou refusée, l'exception, le miroir. Ces lignes se disaient sur une console qui défile, pendant que le fichier qu'on rouvre après un échec ne portait que le symptôme — sur un invité sans magasin de confiance, un certificat refusé, des centaines de dérivations à construire et six cents lignes d'erreurs, sans un mot sur la cause
- Une entrée du cache de téléchargement s'oublie depuis le menu, sous « Âge et nettoyage ». Le binaire savait déjà le faire ; le menu n'offrait que les deux purges en gros, dont aucune ne vise un objet — « effacer ce qui n'a plus servi » n'atteint jamais celui que le service rajeunit chaque fois qu'il le rend, et « tout effacer » coûte le cache entier pour un fichier. `--detient` passe d'abord et fait l'aperçu : même ligne, même clé, sans rien modifier
- `erplibre_go_qemu_cache --recle` range à nouveau les objets d'un cache sous la clé courante, sans rien retélécharger, et fond les copies qu'un miroir portait sous plusieurs chemins. Les objets écrits sous l'ancienne règle de clé restent sur le disque mais deviennent INTROUVABLES, si bien que le service les redemande à l'amont et que la place qu'ils tiennent ne sert plus personne : sur un magasin de 12 764 objets, 5 419 étaient dans ce cas — 9,11 Gio — et la fusion des doublons a rendu environ 3,37 Gio. Le service doit être arrêté, le corps étant renommé avant son méta, et `--dry-run` ne fait que compter ce qui bougerait. Un statut seul n'est pas touché, sa clé portant l'hôte et non le chemin
## Modifié
@ -131,6 +132,12 @@ au [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- Une image téléchargée est vérifiée contre la somme que son éditeur publie, pour toute distribution qui en publie une et SANS le demander. La vérification existait sous un drapeau et pour Ubuntu seulement, si bien que les autres images arrivaient sans que rien ne les regarde. Six y entrent, relevées sur les dépôts plutôt que devinées : Debian publie du sha512 quand tout le reste est en sha256, les familles RHEL nomment le fichier « CHECKSUM », Rocky l'écrit en forme BSD, et Arch comme openSUSE posent une somme par image. Un fichier de sommes injoignable n'arrête plus un déploiement — c'est une panne de disponibilité — quand un écart arrête toujours tout et supprime l'image
- La locale qu'une VM déployée reçoit s'applique sur NixOS. cloud-init l'applique par locale-gen et update-locale, absents là-bas, et la VM gardait le défaut de la distribution — « fr_CA.UTF-8 » demandé, « en_US.UTF-8 » obtenu. Elle et le fuseau sont désormais déclarés par le module, et ni l'un ni l'autre n'est imposé à une NixOS qu'on avait déjà
- Le guide de connexion tient dans un terminal de 80 colonnes même sur une VM qui porte ses outils et un bureau, là où il en rendait 103 : le cadre débordait, le terminal repliait où il voulait, et l'alignement en deux colonnes — seule chose qui rend le guide lisible d'un coup d'œil — se perdait. La glose se replie désormais sous sa colonne, et passe sous sa commande quand celle-ci ne laisse plus de quoi écrire. C'est la mise en page qui porte la règle, non la longueur des textes, qui n'aurait tenu que jusqu'au prochain outil
- Une VM déployée hors ligne reçoit les variables du cache avant que son installation ne commence. « cloud-init status --wait » rend la main dès que cloud-init se déclare en erreur — un module accessoire y suffit — alors que son étape finale écrit encore l'autorité, `/etc/environment` et le fichier sudoers ; une session ouverte dans cette seconde-là vivait sans elles, et une installation lancée par sudo rejetait alors le certificat du cache. Le déploiement attend désormais l'unité cloud-final, et son seul état « activating », ce service étant un oneshot qui reste actif une fois fini
- Une métadonnée de dépôt qu'une distribution RPM nomme d'après l'empreinte de son contenu est servie du disque comme un paquet, et une demande de plage sur un fichier que le cache ne détient pas fait prendre le fichier entier en arrière-plan, une fois par clé. Un tel index de dizaines de mégaoctets était repris en entier à chaque installation — environ 110 Mo par VM sur un système de la famille RHEL — et dnf, qui prend ses métadonnées zchunk par plages, recevait un « 504 » l'amont coupé, aucune plage ne se gardant
- Une VM déployée hors ligne n'attend plus une synchronisation de l'heure qui ne peut pas venir. Une image qui active `systemd-time-wait-sync` retient `time-sync.target` jusqu'à la première réponse NTP, et l'étape finale de cloud-init est ordonnée après elle : sans serveur de temps joignable, cette étape ne démarrait jamais, ni les clés d'hôte ssh qu'elle génère, et la VM atteignait son invite de connexion sans jamais répondre en ssh
- Le cache de téléchargement ne reprend plus à l'amont un paquet qu'il détient déjà parce qu'un miroir le range sous un autre chemin. Un miroir préfixe le chemin à sa guise — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — et le chemin entier donnait deux clés pour les mêmes octets : sur un journal de 7099 noms livrés, 1124 vivaient sous plusieurs chemins et 3,18 Gio repartaient à l'amont pour rien. Seuls les six derniers segments comptent désormais, les segments vides tombant avec eux. Six est la plus petite borne sans collision : un chemin Debian en porte exactement six, si bien que cinq servirait le paquet d'Ubuntu pour celui de Debian, qui porte le même nom pour d'autres octets. Les paquets pacman s'arrêtent à quatre, leurs chemins étant plus courts que la borne commune, ce qui retire le préfixe du miroir et garde le nom du dépôt. Un magasin rempli avant ce changement se rattrape par `--recle`
- Une image gardée devenue périmée est reprise une fois au lieu d'arrêter le déploiement. Le répertoire « latest » d'une distribution avance à chaque version mineure et la somme publiée cesse de décrire l'image du disque, sans qu'un octet soit corrompu : la vérification la supprimait puis sortait, perdant une campagne entière — trois VM — pour une péremption qu'un seul téléchargement répare. Un second écart porte sur des octets fraîchement téléchargés, arrête tout et supprime l'image ; appelée sans liste de miroirs, et pour des sommes injoignables, rien ne change
- `long_test/qemu_cache.py` ne fait plus échouer une campagne où le cache a tout servi. Une URL n'est « déjà vue » que si la première VM en a obtenu les octets : un même paquet vit sous deux chemins selon le miroir, et la première VM peut recevoir « 504 » sur l'un — amont jugé muet — puis être servie du disque par l'autre, si bien que rien n'était rangé sous le premier chemin et que le téléchargement honnête de la seconde y était compté en faute. Un statut absent vaut livré, les journaux d'avant ne l'écrivant pas toujours, et la ligne des fichiers neufs nomme ses deux causes au lieu d'imputer à Arch quelle que soit la distribution mesurée
## Retiré

View file

@ -60,6 +60,7 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- One entry of the download cache can be removed, by URL. A checksum that does not match invalidated nothing: the store kept serving the same bytes, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — `--purge` erases everything, and `--purge-older-than` skips what is recent while every service resets that date, so a poisoned object that keeps being served never ages. `--oublie` is symmetric to `--detient`: same input lines, same key functions, same refusals, and a present object that resists is reported as a refusal rather than a forget. Both checksum failures now name it, with the exact URL
- The install log carries what the host decided before launching: the download cache authority placed or refused, the bypass, the mirror. Those lines were said on a console that scrolls away, while the file reopened after a failure held only the symptom — on a guest with no trust store, a refused certificate, hundreds of derivations to build and six hundred lines of errors, without a word on the cause
- One entry of the download cache can be forgotten from the menu, under « Age and cleanup ». The binary could already do it; the menu offered only the two bulk purges, neither of which reaches a single object — « erase what has not served » never reaches one the service rejuvenates each time it serves it, and « erase everything » costs the whole cache for one file. `--detient` runs first and is the preview: same line, same key, nothing modified
- `erplibre_go_qemu_cache --recle` stores a cache's objects again under the current key, without downloading anything, and merges the copies a mirror carried under several paths. Objects written under the former key rule stay on disk but become UNREACHABLE, so the service asks upstream for them again and the space they hold serves no one: on a store of 12 764 objects, 5 419 were in that case — 9.11 GiB — and merging the duplicates returned about 3.37 GiB. The service must be stopped, the body being renamed before its meta, and `--dry-run` only counts what would move. A status-only entry is left alone, its key carrying the host rather than the path
## Changed
@ -131,6 +132,12 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- A downloaded image is checked against the sum its publisher ships, for every distribution that publishes one and WITHOUT asking. The check existed behind a flag and for Ubuntu only, so the other images arrived with nothing looking at them. Six now enter, read off the repositories rather than guessed: Debian publishes sha512 where everything else is sha256, the RHEL families name the file « CHECKSUM », Rocky writes the BSD form, and Arch and openSUSE ship a sum per image. An unreachable sums file no longer stops a deployment — that is an availability failure — while a mismatch still stops everything and removes the image
- The locale a deployed VM is given applies on NixOS. cloud-init applies it through locale-gen and update-locale, absent there, and the VM kept the distribution's default — « fr_CA.UTF-8 » asked for, « en_US.UTF-8 » obtained. Both it and the timezone are declared by the module now, and neither is imposed on a NixOS one already had
- The connection guide fits an 80-column terminal even on a VM carrying its tools and a desktop, where it rendered 103 columns wide: the frame overflowed, the terminal wrapped wherever it liked, and the two-column alignment — the only thing making the guide readable at a glance — was lost. A gloss now wraps under its column, and moves below its command when that command leaves no room. The layout carries the rule, not the length of the texts, which would have held only until the next tool
- A VM deployed offline gets its cache variables before its installation starts. « cloud-init status --wait » returns as soon as cloud-init declares itself in error — an accessory module suffices — while its final stage is still writing the authority, `/etc/environment` and the sudoers file; a session opened in that second lived without them, and an install run by sudo then rejected the cache certificate. The deployment now waits for the cloud-final unit, and only its « activating » state, that unit being a oneshot that stays active once finished
- Repository metadata an RPM distribution names after the hash of its content is served from disk like a package, and a range request on a file the cache does not hold fetches the whole file in the background, once per key. Such an index of tens of megabytes was taken whole on every install — about 110 MB per VM on a RHEL family system — and dnf, which fetches its zchunk metadata by ranges, got a « 504 » with upstream cut, no range being stored
- A VM deployed offline no longer waits for a time synchronisation that cannot come. An image that enables `systemd-time-wait-sync` holds `time-sync.target` until the first NTP answer, and cloud-init's final stage is ordered after it: with no reachable time server, that stage never ran, nor the ssh host keys it generates, and the VM reached its login prompt without ever answering ssh
- The download cache no longer re-fetches a package it already holds because a mirror files it under another path. A mirror prefixes the path as it pleases — « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… » — and the whole path gave two keys for the same bytes: over a log of 7099 delivered names, 1124 lived under several paths and 3.18 GiB went back upstream for nothing. Only the last six segments count now, empty ones falling with them. Six is the smallest collision-free bound: a Debian path carries exactly six, so five would serve Ubuntu's package for Debian's, which bears the same name for other bytes. Pacman packages stop at four, their paths being shorter than the common bound, which drops the mirror's prefix and keeps the repository name. A store filled before this change is brought over by `--recle`
- A kept image that has gone stale is fetched once more instead of ending the deployment. A distribution's « latest » directory moves with each point release and the published sum stops describing the image on disk, with no byte corrupted: the check deleted it and exited, losing a whole campaign — three VMs — to a staleness one download repairs. A second mismatch is on freshly downloaded bytes, stops everything and deletes the image; called with no mirror list, and for unreachable sums, nothing changes
- `long_test/qemu_cache.py` no longer fails a campaign where the cache served everything. A URL counts as « already seen » only if the first VM obtained its bytes: one package lives under two paths depending on the mirror, and the first VM can get a « 504 » on one — upstream judged mute — then be served from disk by the other, so nothing was stored under the first path and the second VM's honest download was counted a fault. A missing status counts as delivered, older logs not always writing it, and the new-files line names both of its causes instead of blaming Arch whatever the system measured
## Removed

View file

@ -826,11 +826,28 @@ def paquets_seulement(lignes):
return [l for l in lignes if l.get("class") == "immutable"]
def livre(ligne):
"""La réponse a-t-elle livré le fichier ?
Un statut absent vaut oui : les journaux d'avant ne l'écrivaient pas
toujours, et le prendre pour un échec ferait disparaître des mesures
entières. Tout ce qui n'est pas une livraison — refus d'un miroir, « 504 »
d'un amont jugé muet — ne compte ni comme faute ni comme fichier déjà vu.
"""
statut = ligne.get("status")
return not isinstance(statut, int) or 200 <= statut < 400
def verdict(premier, second, journal):
"""Le critère, puis la manchette. Rend True si le cache a servi."""
p1 = paquets_seulement(premier)
p2 = paquets_seulement(second)
vues1 = {l["url"] for l in p1}
# « Déjà vue » suppose que la PREMIÈRE VM en a obtenu les octets. Un même
# paquet vit sous deux chemins selon le miroir, et la clé porte le chemin :
# la première peut recevoir « 504 » sur l'un — amont jugé muet — puis être
# servie du disque par l'autre. Le téléchargement honnête de la seconde sur
# le premier chemin n'est pas une faute : rien n'était rangé sous cette clé.
vues1 = {l["url"] for l in p1 if livre(l)}
# Le critère : ce que les DEUX ont demandé ne doit pas être ressorti.
#
@ -839,13 +856,7 @@ def verdict(premier, second, journal):
# 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
]
refus = [l for l in p2 if l.get("upstream") and not livre(l)]
fautes = [
l
for l in p2
@ -917,8 +928,8 @@ def verdict(premier, second, journal):
dire(f" tirés de l'amont : {octets_amont} octets", journal)
if neufs:
dire(
f" ({len(neufs)} fichiers neufs : le miroir a publié entre les"
" deux déploiements, ce qui est normal sur Arch)",
f" ({len(neufs)} fichiers neufs : publiés entre les deux"
" déploiements, ou rangés par un miroir sous un autre chemin)",
journal,
)
for l in neufs[:5]:

View file

@ -1776,7 +1776,11 @@ def expected_sum(sums: str, filename: str) -> str:
def verify_sha256(
url: str, image: Path, dry_run: bool, distro: str = "ubuntu"
url: str,
image: Path,
dry_run: bool,
distro: str = "ubuntu",
urls: tuple[str, ...] = (),
) -> None:
"""Vérifie l'empreinte via les sommes que la distribution publie.
@ -1810,17 +1814,32 @@ def verify_sha256(
print(f" ⚠ empreinte absente pour {filename}, image NON vérifiée")
return
h = hashlib.new(algo)
with image.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
if h.hexdigest() != expected:
def empreinte() -> str:
h = hashlib.new(algo)
with image.open("rb") as fh:
for chunk in iter(lambda: fh.read(1 << 20), b""):
h.update(chunk)
return h.hexdigest()
obtenu = empreinte()
# Un écart sur une image GARDÉE ne prouve pas une substitution : le
# répertoire « latest » d'une distribution avance à chaque version
# mineure, et la somme publiée cesse alors de décrire celle du disque.
# Supprimer puis abandonner coûtait toute une campagne pour une simple
# péremption. L'image est reprise UNE fois ; un second écart, lui, porte
# sur des octets fraîchement téléchargés et arrête tout.
if obtenu != expected and urls:
print(f" Somme {algo} NON conforme : image reprise une fois.")
image.unlink(missing_ok=True)
download_image(list(urls), image, dry_run)
obtenu = empreinte()
if obtenu != expected:
image.unlink(
missing_ok=True
) # évite la réutilisation du cache corrompu
sys.exit(
f"Somme {algo} NON conforme ! Image supprimée : {image}\n"
f" attendu : {expected}\n obtenu : {h.hexdigest()}"
f" attendu : {expected}\n obtenu : {obtenu}"
)
print(f" Somme {algo} conforme.")
@ -5731,7 +5750,13 @@ def main() -> None:
args.distro, args.image_path, args.dry_run, tuple(urls)
)
if do_verify:
verify_sha256(url, args.image_path, args.dry_run, args.distro)
verify_sha256(
url,
args.image_path,
args.dry_run,
args.distro,
tuple(urls),
)
print("\nTerminé (téléchargement seul).")
return
@ -5848,7 +5873,13 @@ def main() -> None:
args.distro, args.image_path, args.dry_run, tuple(urls)
)
if do_verify:
verify_sha256(url, args.image_path, args.dry_run, args.distro)
verify_sha256(
url,
args.image_path,
args.dry_run,
args.distro,
tuple(urls),
)
print(f"\n== 2-3/5 Disque de travail {disk} ({args.disk_size}) ==")
prepare_disk(args.image_path, disk, args.disk_size, runner, args.force)

View file

@ -135,16 +135,18 @@ var anglais = map[string]string{
"tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)",
"tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener",
"tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead",
"tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest",
"tunnel vers %s : %v": "tunnel to %s: %v",
"ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)",
"longueur d'enregistrement invraisemblable : %d": "implausible record length: %d",
"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",
"ranger à nouveau les objets du magasin sous la clé courante, et fondre les copies qu'un miroir portait sous plusieurs chemins ; service arrêté, et --dry-run pour ne que compter": "store the objects again under the current key, and merge the copies a mirror carried under several paths; service stopped, and --dry-run to only count",
"re-clé : %v\n": "re-key: %v\n",
"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",

View file

@ -239,6 +239,93 @@ func TestLesMetadonneesRPMParEmpreinteSontImmuables(t *testing.T) {
}
}
// Un miroir préfixe le chemin à sa guise, et le chemin entier donne alors
// deux clés pour un même octet : le cache retélécharge ce qu'il détient.
func TestLesPrefixesDeMiroirNeFontQuUneCle(t *testing.T) {
for _, cas := range [][2]string{
{
"https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm",
"https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm",
},
{
"https://a.example/pub/archive/fedora/linux/updates/42/Everything/x86_64/Packages/n/nodejs-libs-22.22.0-2.fc42.x86_64.rpm",
"https://b.example/pub/fedora-archive/fedora/linux/updates/42/Everything/x86_64/Packages/n/nodejs-libs-22.22.0-2.fc42.x86_64.rpm",
},
{
"https://a.example/pub/rocky//10.2/BaseOS/x86_64/os/Packages/a/avahi-0.9-2.el10.x86_64.rpm",
"https://a.example/pub/rocky/10.2/BaseOS/x86_64/os/Packages/a/avahi-0.9-2.el10.x86_64.rpm",
},
} {
a, _ := url.Parse(cas[0])
b, _ := url.Parse(cas[1])
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Errorf("deux chemins du même fichier donnent deux clés :\n %s\n %s",
cas[0], cas[1])
}
}
}
// Deux distributions publient le même NOM pour d'autres octets : les confondre
// servirait le paquet de l'une à l'autre. Six segments gardent ce qui les
// sépare, cinq l'effaceraient — 38 noms en collision sur le journal relevé.
func TestDeuxDistributionsNePartagentPasLaCle(t *testing.T) {
deb, _ := url.Parse("https://deb.example/debian/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
ubu, _ := url.Parse("https://ubu.example/ubuntu/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
if KeySansHote("GET", deb) == KeySansHote("GET", ubu) {
t.Error("deux distributions partagent une clé : l'une serait servie" +
" avec le paquet de l'autre")
}
}
// Un miroir d'Arch préfixe « archlinux/ », l'autre non : quatre et cinq
// segments, trop courts pour la borne commune, qui les garde entiers et en
// fait deux clés.
func TestLesDeuxFormesDUnMiroirArchNeFontQuUneCle(t *testing.T) {
for _, cas := range [][2]string{
{
"https://geo.example/archlinux/core/os/x86_64/linux-7.2.4.arch1-2-x86_64.pkg.tar.zst",
"https://fastly.example/core/os/x86_64/linux-7.2.4.arch1-2-x86_64.pkg.tar.zst",
},
{
"https://geo.example/archlinux/extra/os/x86_64/llvm-libs-22.1.8-2-x86_64.pkg.tar.zst",
"https://fastly.example/extra/os/x86_64/llvm-libs-22.1.8-2-x86_64.pkg.tar.zst",
},
} {
a, _ := url.Parse(cas[0])
b, _ := url.Parse(cas[1])
if KeySansHote("GET", a) != KeySansHote("GET", b) {
t.Errorf("deux formes du même paquet donnent deux clés :\n %s\n %s",
cas[0], cas[1])
}
}
}
// La borne d'Arch s'arrête à quatre pour GARDER le nom du dépôt : trois ou
// deux passeraient sur les données relevées, mais confondraient un paquet de
// « core » avec celui de « extra » si les deux portaient un jour le même nom.
func TestLaCleDArchGardeLeNomDuDepot(t *testing.T) {
a, _ := url.Parse("https://m.example/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst")
b, _ := url.Parse("https://m.example/extra/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst")
if KeySansHote("GET", a) == KeySansHote("GET", b) {
t.Error("deux dépôts d'Arch partagent une clé : le nom du dépôt est perdu")
}
}
// La borne courte ne vaut QUE pour la famille pacman : un « .deb » garde la
// borne haute, faute de quoi le paquet de Debian et celui d'Ubuntu, qui
// portent le même nom pour d'autres octets, se confondraient.
func TestLaBorneCourteNeVautQuePourPacman(t *testing.T) {
deb, _ := url.Parse("https://deb.example/debian/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
ubu, _ := url.Parse("https://ubu.example/ubuntu/pool/main/p/poppler-data/poppler-data_0.4.12-1_all.deb")
if KeySansHote("GET", deb) == KeySansHote("GET", ubu) {
t.Error("la borne courte a débordé sur la famille deb")
}
if !estPaquetArch("linux-7.2.4.arch1-2-x86_64.pkg.tar.zst") ||
estPaquetArch("poppler-data_0.4.12-1_all.deb") {
t.Error("la reconnaissance de la famille pacman est fausse")
}
}
// 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

View file

@ -25,7 +25,7 @@ import (
"time"
)
const version = "0.2.12"
const version = "0.2.16"
func main() {
var (
@ -95,6 +95,10 @@ func main() {
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
" --cache-dir suffit, sans privilège, et l'âge des objets"+
" n'est pas touché"))
recle = flag.Bool("recle", false,
T("ranger à nouveau les objets du magasin sous la clé courante,"+
" et fondre les copies qu'un miroir portait sous plusieurs"+
" chemins ; service arrêté, et --dry-run pour ne que compter"))
oublie = flag.Bool("oublie", false,
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+
" EFFACER du magasin ce que chacune désigne : le corps ET le"+
@ -137,6 +141,21 @@ func main() {
}
return
}
// Même endroit, même raison : la passe ne touche que le magasin, et le
// service ne doit pas tourner pendant qu'elle renomme ce qu'il sert.
if *recle {
store := &Store{Dir: *cacheDir}
bilan, err := store.Recler(*dryRun)
if err != nil {
fmt.Fprintf(os.Stderr, T("re-clé : %v\n"), err)
os.Exit(1)
}
if *dryRun {
fmt.Printf("%s", T("[à blanc] "))
}
fmt.Println(bilan.Ligne())
return
}
bypass := BypassFile{Path: *bypassFile}
// Les exceptions entrent dans les règles dès leur RENDU : le service les

191
script/qemu_cache/recle.go Normal file
View file

@ -0,0 +1,191 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
)
// « --recle » range à nouveau les objets sous la clé que le service calcule
// AUJOURD'HUI.
//
// LE MANQUE QU'IL COMBLE. La clé d'un fichier portable a changé : seule la fin
// du chemin compte désormais (voir SegmentsDeCle), là où le chemin entier
// comptait. Les objets écrits sous l'ancienne règle restent sur le disque mais
// deviennent introuvables : le service les redemande à l'amont, et l'espace
// qu'ils occupent ne sert plus personne. La passe les range sous la clé
// courante, sans rien retélécharger.
//
// ELLE FOND AUSSI LES COPIES. Un même fichier vivait sous plusieurs chemins de
// miroir, donc sous plusieurs clés ; la clé courante les réunit, et deux
// objets tombent alors au même endroit. Le plus récemment rangé est gardé —
// les octets sont les mêmes, seule la date de service les sépare — et l'autre
// rend sa place.
//
// CE QU'ELLE NE TOUCHE PAS. Un statut seul est rangé sous CleStatut, qui hache
// l'URL ENTIÈRE : la règle du chemin ne l'atteint pas, et recalculer sa clé
// avec CleDe la déplacerait là où personne ne la lit.
//
// QUAND LA LANCER. Le service doit être arrêté : la passe renomme des fichiers
// qu'il sert, et un objet déplacé entre la lecture du méta et celle du corps
// ressort en défaut de cache. Rien n'est perdu dans ce cas, mais la mesure
// d'une campagne qui tournerait en même temps ne voudrait rien dire.
// Reclassement compte ce qu'une passe a fait.
//
// Ce sont des ÉVÉNEMENTS, non des objets, et leur somme dépasse donc le nombre
// d'objets lus : une copie déjà rangée sous la clé courante est comptée
// « inchangée » quand le parcours la visite, puis « fondue » si une copie plus
// récente vient prendre sa place. Les additionner pour retrouver « lus » est
// une erreur de lecture, pas une erreur de compte.
type Reclassement struct {
Lus int
Deplaces int
Fondus int
Inchanges int
Illisibles int
Refus int
// OctetsRendus : ce que les copies fondues rendent au disque.
OctetsRendus int64
}
// Ligne rend le compte en une ligne lisible.
func (r Reclassement) Ligne() string {
return fmt.Sprintf(
"lus %d, déplacés %d, fondus %d (%s rendus), inchangés %d,"+
" illisibles %d, refus %d",
r.Lus, r.Deplaces, r.Fondus, HumanBytes(r.OctetsRendus),
r.Inchanges, r.Illisibles, r.Refus)
}
// Recler range chaque objet sous la clé courante. À blanc, rien n'est écrit et
// le compte dit ce qui bougerait.
func (s *Store) Recler(aBlanc bool) (Reclassement, error) {
var r Reclassement
// La liste est faite AVANT de renommer : déplacer un fichier sous un
// répertoire que le parcours n'a pas encore visité le lui ferait
// rencontrer deux fois.
var metas []string
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
if err != nil || info == nil {
return nil
}
if horsCasier(s.Dir, p, info) {
return filepath.SkipDir
}
if !info.IsDir() && filepath.Ext(p) == ".meta" {
metas = append(metas, p)
}
return nil
})
if err != nil && !os.IsNotExist(err) {
return r, err
}
for _, chemin := range metas {
r.Lus++
ancienne := strings.TrimSuffix(filepath.Base(chemin), ".meta")
m, err := s.LireMeta(ancienne)
if err != nil {
r.Illisibles++
continue
}
// Un statut seul vit sous une clé qui porte l'hôte : la règle du
// chemin ne le concerne pas.
if m.StatutSeul() {
r.Inchanges++
continue
}
u, err := url.Parse(m.URL)
if err != nil || !u.IsAbs() || u.Host == "" {
r.Illisibles++
continue
}
nouvelle := CleDe(m.Method, u)
if nouvelle == ancienne {
r.Inchanges++
continue
}
s.deplacer(&r, ancienne, nouvelle, m, aBlanc)
}
return r, nil
}
// deplacer range un objet sous sa nouvelle clé, ou le fond dans la copie qui
// s'y trouve déjà.
//
// Le CORPS part en premier et le méta ensuite : c'est l'ordre qui publie un
// objet, et l'interrompre laisse au pire un corps sans méta — que le magasin
// ignore — plutôt qu'un méta qui promet un corps absent.
func (s *Store) deplacer(
r *Reclassement, ancienne, nouvelle string, m *Meta, aBlanc bool,
) {
ancienMeta, ancienCorps := s.paths(ancienne)
nouveauMeta, nouveauCorps := s.paths(nouvelle)
if autre, err := s.LireMeta(nouvelle); err == nil {
// Deux chemins de miroir pour le même fichier. Le plus récemment
// rangé reste : les octets sont les mêmes, seule leur date diffère.
garde, rendus := autre.StoredAt, m.Size
if m.StoredAt.After(garde) {
rendus = autre.Size
if !aBlanc {
if err := s.retirerPaire(nouveauMeta, nouveauCorps); err != nil {
r.Refus++
return
}
if err := s.renommerPaire(
ancienMeta, ancienCorps, nouveauMeta, nouveauCorps,
); err != nil {
r.Refus++
return
}
}
r.Deplaces++
} else if !aBlanc {
if err := s.retirerPaire(ancienMeta, ancienCorps); err != nil {
r.Refus++
return
}
}
r.Fondus++
r.OctetsRendus += rendus
return
}
if !aBlanc {
if err := os.MkdirAll(filepath.Dir(nouveauMeta), 0o755); err != nil {
r.Refus++
return
}
if err := s.renommerPaire(
ancienMeta, ancienCorps, nouveauMeta, nouveauCorps,
); err != nil {
r.Refus++
return
}
}
r.Deplaces++
}
func (s *Store) renommerPaire(ancienMeta, ancienCorps, nouveauMeta, nouveauCorps string) error {
if err := os.Rename(ancienCorps, nouveauCorps); err != nil {
return err
}
return os.Rename(ancienMeta, nouveauMeta)
}
func (s *Store) retirerPaire(meta, corps string) error {
if err := os.Remove(corps); err != nil && !os.IsNotExist(err) {
return err
}
if err := os.Remove(meta); err != nil && !os.IsNotExist(err) {
return err
}
return nil
}

View file

@ -0,0 +1,231 @@
// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"crypto/sha256"
"encoding/hex"
"io"
"net/url"
"os"
"path/filepath"
"strings"
"testing"
)
// ancienneCle reproduit la règle d'AVANT : le chemin entier, tel que le
// magasin le hachait jusqu'à la normalisation. Les objets déjà rangés sur un
// disque portent cette clé-là, et c'est d'eux que la passe s'occupe.
func ancienneCle(methode string, u *url.URL) string {
sum := sha256.Sum256([]byte(methode + " path " + u.Path))
return hex.EncodeToString(sum[:])
}
// planter écrit un objet sous la clé donnée, comme le magasin l'aurait fait.
func planter(t *testing.T, s *Store, cle, brut, corps string) {
t.Helper()
w, err := s.NewWriter(cle, Meta{
URL: brut, Method: "GET", Status: 200, Class: "immutable",
})
if err != nil {
t.Fatalf("écriture de %s : %v", brut, err)
}
if _, err := w.Write([]byte(corps)); err != nil {
t.Fatal(err)
}
if err := w.Commit(int64(len(corps))); err != nil {
t.Fatalf("publication de %s : %v", brut, err)
}
}
const (
rockyA = "https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
rockyB = "https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm"
)
// Un objet rangé sous l'ancienne clé est introuvable : le service le
// redemande à l'amont alors qu'il est sur le disque. La passe le range sous la
// clé courante, sans rien retélécharger.
func TestReclerRendTrouvableUnObjetAncien(t *testing.T) {
s := &Store{Dir: t.TempDir()}
u, _ := url.Parse(rockyA)
planter(t, s, ancienneCle("GET", u), rockyA, "le paquet")
if s.Detient(CleDe("GET", u)) {
t.Fatal("l'objet est déjà trouvable : le test ne mesure rien")
}
bilan, err := s.Recler(false)
if err != nil {
t.Fatal(err)
}
if bilan.Deplaces != 1 || bilan.Fondus != 0 {
t.Errorf("bilan %+v, attendu un seul déplacement", bilan)
}
if !s.Detient(CleDe("GET", u)) {
t.Error("l'objet reste introuvable après la passe")
}
m, f, err := s.Get(CleDe("GET", u))
if err != nil {
t.Fatalf("lecture après passe : %v", err)
}
defer f.Close()
if m.URL != rockyA {
t.Errorf("méta perdu en route : %q", m.URL)
}
}
// Deux chemins de miroir pour le même fichier : la clé courante les réunit, et
// la passe n'en garde qu'un — le plus récemment rangé.
//
// Les deux DIRECTIONS sont éprouvées, et l'ordre est fixé en plantant d'abord
// l'objet de destination : laisser le parcours décider laquelle des deux
// copies arrive la première ne couvrait qu'une branche sur deux, au gré du
// hachage, et la branche non couverte est passée au travers d'une mutation.
func TestReclerFondLesCopiesDeMiroir(t *testing.T) {
ua, _ := url.Parse(rockyA)
t.Run("entrante plus ancienne", func(t *testing.T) {
s := &Store{Dir: t.TempDir()}
planter(t, s, ancienneCle("GET", ua), rockyA, "copie ancienne")
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, plus recente")
bilan, err := s.Recler(false)
if err != nil {
t.Fatal(err)
}
if bilan.Fondus != 1 || bilan.OctetsRendus == 0 {
t.Errorf("bilan %+v, attendu une fusion qui rend des octets", bilan)
}
if corps := corpsDuMagasin(t, s); corps != 1 {
t.Errorf("%d corps sur le disque, attendu 1", corps)
}
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie en place, plus recente" {
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
}
})
t.Run("entrante plus récente", func(t *testing.T) {
s := &Store{Dir: t.TempDir()}
planter(t, s, CleDe("GET", ua), rockyB, "copie en place, ancienne")
planter(t, s, ancienneCle("GET", ua), rockyA, "copie entrante, recente")
bilan, err := s.Recler(false)
if err != nil {
t.Fatal(err)
}
if bilan.Fondus != 1 || bilan.Deplaces != 1 {
t.Errorf("bilan %+v, attendu une fusion et un déplacement", bilan)
}
if corps := corpsDuMagasin(t, s); corps != 1 {
t.Errorf("%d corps sur le disque, attendu 1", corps)
}
if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie entrante, recente" {
t.Errorf("la copie gardée est %q : la plus récente devait rester", lu)
}
})
}
// corpsDuMagasin compte les corps réellement sur le disque : une copie qui
// n'est plus référencée mais reste écrite occupe la place qu'on croyait rendue.
func corpsDuMagasin(t *testing.T, s *Store) int {
t.Helper()
n := 0
err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error {
if err == nil && info != nil && !info.IsDir() &&
strings.HasSuffix(p, ".body") {
n++
}
return nil
})
if err != nil {
t.Fatal(err)
}
return n
}
func lireCorps(t *testing.T, s *Store, cle string) string {
t.Helper()
_, f, err := s.Get(cle)
if err != nil {
t.Fatalf("lecture de %s : %v", cle, err)
}
defer f.Close()
b, err := io.ReadAll(f)
if err != nil {
t.Fatal(err)
}
return string(b)
}
// Un chemin court tient tout entier dans la clé : rien ne doit bouger.
func TestReclerNeTouchePasUnCheminCourt(t *testing.T) {
s := &Store{Dir: t.TempDir()}
brut := "https://a.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst"
u, _ := url.Parse(brut)
planter(t, s, CleDe("GET", u), brut, "paquet arch")
bilan, err := s.Recler(false)
if err != nil {
t.Fatal(err)
}
if bilan.Deplaces != 0 || bilan.Inchanges != 1 {
t.Errorf("bilan %+v, attendu un objet inchangé", bilan)
}
if !s.Detient(CleDe("GET", u)) {
t.Error("un objet qui ne devait pas bouger a disparu")
}
}
// Un statut seul est rangé sous une clé qui porte l'HÔTE : la règle du chemin
// ne le concerne pas, et le déplacer l'enverrait là où personne ne le lit.
func TestReclerLaisseLesStatutsSeuls(t *testing.T) {
s := &Store{Dir: t.TempDir()}
brut := "https://a.example/depot/releases/latest/download/outil.tar.gz"
u, _ := url.Parse(brut)
cle := CleStatut("GET", u)
w, err := s.NewWriter(cle, Meta{
URL: brut, Method: "GET", Status: 302, Class: "volatile",
StatusOnly: true,
})
if err != nil {
t.Fatal(err)
}
if err := w.Commit(0); err != nil {
t.Fatal(err)
}
bilan, err := s.Recler(false)
if err != nil {
t.Fatal(err)
}
if bilan.Deplaces != 0 {
t.Errorf("bilan %+v : un statut seul a été déplacé", bilan)
}
if _, err := s.LireMeta(cle); err != nil {
t.Error("le statut seul n'est plus sous sa clé")
}
}
// À blanc, la passe compte sans rien écrire : c'est ce qui permet de la lancer
// avant de décider.
func TestReclerABlancNeDeplaceRien(t *testing.T) {
s := &Store{Dir: t.TempDir()}
u, _ := url.Parse(rockyA)
ancienne := ancienneCle("GET", u)
planter(t, s, ancienne, rockyA, "le paquet")
bilan, err := s.Recler(true)
if err != nil {
t.Fatal(err)
}
if bilan.Deplaces != 1 {
t.Errorf("bilan %+v, attendu un déplacement annoncé", bilan)
}
if _, err := s.LireMeta(ancienne); err != nil {
t.Error("l'objet a bougé alors que la passe était à blanc")
}
if s.Detient(CleDe("GET", u)) {
t.Error("un objet est apparu sous la clé courante, à blanc")
}
}

View file

@ -95,8 +95,61 @@ func Key(method, rawURL string) string {
// Réservé aux fichiers dont le NOM porte l'identité — paquets, index de
// dépôt. L'appliquer à tout ferait entrer en collision les « /index.html » de
// deux sites sans rapport.
//
// Le chemin ENTIER ne suffisait pas : un miroir le préfixe à sa guise —
// « /rocky/10.2/… », « /mirror/rocky-linux/10.2/… », « /pub/archive/fedora/… »
// — et le même octet prenait alors deux clés. Seule la FIN du chemin est
// retenue (voir SegmentsDeCle), ce qui réunit ces copies sans jamais
// confondre deux distributions.
// SegmentsDeCle : combien de segments de FIN de chemin identifient un fichier.
//
// Six, et pas moins : un chemin Debian en porte exactement six —
// « debian/pool/main/p/<paquet>/<fichier>.deb » — si bien que cinq
// effaceraient le segment de distribution et donneraient la même clé au
// paquet d'Ubuntu, qui porte le même nom pour d'autres octets.
const SegmentsDeCle = 6
// SegmentsDeCleArch : les paquets d'Arch en demandent moins.
//
// Un miroir d'Arch sert « archlinux/core/os/x86_64/<paquet> » là où un autre
// sert « core/os/x86_64/<paquet> » : quatre et cinq segments, donc plus COURTS
// que la borne commune, qui les garde alors entiers et en fait deux clés.
//
// Quatre, et non moins : la clé garde ainsi le nom du dépôt — « core »,
// « extra » — et ne perd que le segment décoratif du miroir. Deux ou trois
// réuniraient les mêmes copies, mais effaceraient cette distinction sans
// nécessité.
//
// Le cas est sûr là où celui de Debian ne l'est pas : Ubuntu reprend les
// paquets de Debian en gardant leur version, si bien qu'un même nom « .deb »
// porte deux contenus selon la distribution. Un espace de noms partagé exige
// la borne haute ; celui d'Arch n'appartient qu'à lui.
const SegmentsDeCleArch = 4
// estPaquetArch reconnaît un paquet de la famille pacman à son nom.
func estPaquetArch(nom string) bool {
nom = strings.ToLower(nom)
return strings.HasSuffix(nom, ".pkg.tar.zst") ||
strings.HasSuffix(nom, ".pkg.tar.xz")
}
func KeySansHote(method string, u *url.URL) string {
chemin := u.Path
segments := make([]string, 0, SegmentsDeCle+2)
for _, s := range strings.Split(u.Path, "/") {
// Les segments vides tombent : un miroir écrit « /pub/rocky//10.2 »,
// et deux écritures d'un même chemin feraient sinon deux clés.
if s != "" {
segments = append(segments, s)
}
}
borne := SegmentsDeCle
if len(segments) > 0 && estPaquetArch(segments[len(segments)-1]) {
borne = SegmentsDeCleArch
}
if len(segments) > borne {
segments = segments[len(segments)-borne:]
}
chemin := strings.Join(segments, "/")
if u.RawQuery != "" {
chemin += "?" + u.RawQuery
}

View file

@ -620,5 +620,42 @@ class TestLeGainSeCalculeParCondition(unittest.TestCase):
self.assertNotIn("gain", texte)
class UneUrlQueLaPremiereNaJamaisObtenue(unittest.TestCase):
"""Deux miroirs rangent le même paquet sous deux chemins, et la clé porte
le chemin. La première VM reçoit « 504 » sur l'un — amont jugé muet — puis
est servie du disque par l'autre ; rien n'entre au magasin sous le premier
chemin. Le téléchargement de la seconde n'y est donc pas une faute.
"""
AUTRE = PAQUET_A.replace("miroir.example", "autre-miroir.example")
def test_un_504_chez_la_premiere_ne_rend_pas_la_seconde_fautive(self):
premier = [
ligne(
self.AUTRE, True, issue="offline-miss", octets=0, statut=504
),
ligne(PAQUET_A, False, issue="hit", statut=200),
]
second = [ligne(self.AUTRE, True, issue="stored", statut=200)]
self.assertTrue(
QC.verdict(premier, second, None),
"un 504 de la première VM fait accuser le cache d'un fichier"
" qu'il n'a jamais eu à garder",
)
def test_un_fichier_livre_deux_fois_reste_une_faute(self):
premier = [ligne(PAQUET_A, True, statut=200)]
second = [ligne(PAQUET_A, True, statut=200)]
self.assertFalse(QC.verdict(premier, second, None))
def test_un_statut_absent_vaut_livre(self):
"""Les journaux d'avant ne l'écrivaient pas toujours : le prendre pour
un échec effacerait des mesures entières."""
self.assertTrue(QC.livre({"url": PAQUET_A}))
self.assertTrue(QC.livre({"url": PAQUET_A, "status": 206}))
self.assertFalse(QC.livre({"url": PAQUET_A, "status": 504}))
self.assertFalse(QC.livre({"url": PAQUET_A, "status": 404}))
if __name__ == "__main__":
unittest.main()

View file

@ -160,5 +160,100 @@ class LaVerificationEstLeDefaut(unittest.TestCase):
self.assertIn("image.unlink", corps)
class UneImageGardeeQuiAVieilli(unittest.TestCase):
"""« latest » avance à chaque version mineure : l'image gardée cesse de
correspondre à la somme publiée sans que rien ne soit corrompu.
La supprimer puis abandonner coûtait la campagne entière — trente minutes
et trois VM — là où un seul téléchargement suffit. Un second écart, lui,
porte sur des octets neufs : c'est une panne d'intégrité, et elle arrête.
"""
NOM = "ubuntu-24.04-server-cloudimg-amd64.img"
URL = f"https://miroir.invalid/d/{NOM}"
def jouer(
self,
sur_disque,
retelecharge=None,
publie=b"publie",
urls=("https://m/i",),
):
"""Rend (sorti, nombre de téléchargements, contenu final).
« publie » est ce que le fichier de sommes DÉCLARE ; « retelecharge »
ce qu'un nouveau téléchargement pose vraiment. Les confondre rend le
second écart impossible à éprouver : les octets repris concordent
alors toujours, et le cas « faux deux fois » n'existe plus.
"""
import hashlib
import tempfile
from unittest import mock
pose = retelecharge if retelecharge is not None else publie
somme = hashlib.sha256(publie).hexdigest()
sums = f"{somme} {self.NOM}\n".encode()
class Reponse:
def __enter__(self_inner):
return self_inner
def __exit__(self_inner, *a):
return False
def read(self_inner):
return sums
with tempfile.TemporaryDirectory() as d:
image = Path(d) / self.NOM
image.write_bytes(sur_disque)
appels = []
def faux_telechargement(urls_, dest, dry_run, timeout=None):
appels.append(tuple(urls_))
Path(dest).write_bytes(pose)
sorti = False
with mock.patch.object(
DQ.urllib.request, "urlopen", lambda *a, **k: Reponse()
), mock.patch.object(DQ, "download_image", faux_telechargement):
try:
DQ.verify_sha256(self.URL, image, False, "ubuntu", urls)
except SystemExit:
sorti = True
final = image.read_bytes() if image.exists() else b""
return sorti, len(appels), final
def test_une_image_perimee_est_reprise_une_fois(self):
sorti, n, final = self.jouer(b"vieille", b"publie")
self.assertFalse(sorti, "une péremption arrête encore le déploiement")
self.assertEqual(
1, n, "l'image n'a pas été reprise exactement une fois"
)
self.assertEqual(b"publie", final)
def test_un_second_ecart_arrete_tout(self):
"""Des octets fraîchement téléchargés qui ne concordent pas ne sont
plus une péremption : la vérification doit alors refuser."""
sorti, n, final = self.jouer(b"vieille", b"faux-aussi")
self.assertTrue(sorti, "une image fausse deux fois passe")
self.assertEqual(1, n, "la reprise boucle au lieu d'arrêter")
self.assertEqual(b"", final, "l'image fausse est restée sur le disque")
def test_sans_miroir_le_comportement_ne_change_pas(self):
"""Appelée sans liste de miroirs — le mode épinglé, un appelant tiers
— la vérification garde sa forme d'avant : supprimer et sortir."""
sorti, n, final = self.jouer(b"vieille", b"publie", urls=())
self.assertTrue(sorti)
self.assertEqual(0, n)
self.assertEqual(b"", final)
def test_une_image_conforme_ne_declenche_rien(self):
sorti, n, final = self.jouer(b"publie", b"publie")
self.assertFalse(sorti)
self.assertEqual(0, n, "une image conforme a été retéléchargée")
self.assertEqual(b"publie", final)
if __name__ == "__main__":
unittest.main()