[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/url"
|
|
|
|
|
"strings"
|
|
|
|
|
"testing"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func TestClassify(t *testing.T) {
|
|
|
|
|
cas := []struct {
|
|
|
|
|
brut string
|
|
|
|
|
attendue Class
|
|
|
|
|
pourquoi string
|
|
|
|
|
}{
|
|
|
|
|
// Le nom porte la version : le contenu ne changera jamais.
|
|
|
|
|
{"https://miroir.example/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
|
|
|
|
|
ClassImmutable, "paquet Arch"},
|
|
|
|
|
{"https://miroir.example/debian/pool/main/b/bash/bash_5.2-1_amd64.deb",
|
|
|
|
|
ClassImmutable, "paquet Debian"},
|
|
|
|
|
{"https://miroir.example/fedora/Packages/b/bash-5.2-1.fc43.x86_64.rpm",
|
|
|
|
|
ClassImmutable, "paquet Fedora"},
|
|
|
|
|
{"https://pypi.example/packages/ab/cd/requests-2.33.0-py3-none-any.whl",
|
|
|
|
|
ClassImmutable, "roue Python"},
|
[ADD] cache qemu : revalider les index, ranger chaque représentation
Sur une installation complète, 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 déjà gardé part désormais avec son ETag : l'amont juge chaque
requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept »,
chaque représentation est rangée à part, faute de quoi deux clients qui
alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip,
nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent.
--- EN ---
On a full install the pip indexes, npm metadata and repo bundle weighed about
110 MB per VM, taken whole every time. An index already kept now leaves with
its ETag: upstream judges every request, and a « 304 » serves the body from
disk. Under « Vary: Accept » each representation is stored apart, without
which two clients alternating between them re-download each in turn. The
PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the
archive they describe.
Assisted-by: Claude Opus 5
2026-09-16 02:59:02 -04:00
|
|
|
{"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)"},
|
[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
{"https://images.example/arch/Arch-Linux-x86_64-cloudimg.qcow2",
|
|
|
|
|
ClassImmutable, "image cloud"},
|
|
|
|
|
|
|
|
|
|
// L'index décrit l'état COURANT du miroir.
|
|
|
|
|
{"https://miroir.example/arch/core/os/x86_64/core.db",
|
|
|
|
|
ClassVolatile, "base Arch"},
|
|
|
|
|
{"https://miroir.example/arch/core/os/x86_64/core.db.sig",
|
|
|
|
|
ClassVolatile, "signature de la base"},
|
|
|
|
|
{"https://miroir.example/debian/dists/trixie/InRelease",
|
|
|
|
|
ClassVolatile, "index Debian"},
|
|
|
|
|
{"https://miroir.example/debian/dists/trixie/main/binary-amd64/Packages.gz",
|
|
|
|
|
ClassVolatile, "liste Debian"},
|
|
|
|
|
{"https://miroir.example/fedora/repodata/repomd.xml",
|
|
|
|
|
ClassVolatile, "index Fedora"},
|
|
|
|
|
{"https://pypi.example/simple/requests/",
|
|
|
|
|
ClassVolatile, "index simple de PyPI"},
|
|
|
|
|
|
|
|
|
|
// Le doute profite au volatile : une requête paramétrée décrit un
|
|
|
|
|
// appel d'API, pas un fichier.
|
|
|
|
|
{"https://api.example/v1/paquet?nom=bash&version=5.2",
|
|
|
|
|
ClassVolatile, "chaîne de requête"},
|
|
|
|
|
{"https://exemple.example/page-quelconque",
|
|
|
|
|
ClassVolatile, "inconnu"},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
for _, c := range cas {
|
|
|
|
|
u, err := url.Parse(c.brut)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatalf("URL de test invalide %q : %v", c.brut, err)
|
|
|
|
|
}
|
|
|
|
|
if got := Classify(u); got != c.attendue {
|
|
|
|
|
t.Errorf("%s : %s classé « %s », attendu « %s »",
|
|
|
|
|
c.pourquoi, c.brut, got, c.attendue)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Une extension immuable portée par une chaîne de requête ne suffit pas : le
|
|
|
|
|
// paramètre peut changer la réponse.
|
|
|
|
|
func TestChaineDeRequeteEmportreSurExtension(t *testing.T) {
|
|
|
|
|
u, _ := url.Parse("https://m.example/bash-5.2-1-x86_64.pkg.tar.zst?mirror=2")
|
|
|
|
|
if got := Classify(u); got != ClassVolatile {
|
|
|
|
|
t.Errorf("classé « %s », attendu « volatile »", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestCacheableMethod(t *testing.T) {
|
|
|
|
|
for _, m := range []string{"GET", "HEAD"} {
|
|
|
|
|
if !CacheableMethod(m) {
|
|
|
|
|
t.Errorf("%s devrait être cachable", m)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, m := range []string{"POST", "PUT", "DELETE", "PATCH", "CONNECT"} {
|
|
|
|
|
if CacheableMethod(m) {
|
|
|
|
|
t.Errorf("%s ne doit pas être cachable", m)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestClassifyURLNulle(t *testing.T) {
|
|
|
|
|
if got := Classify(nil); got != ClassNoStore {
|
|
|
|
|
t.Errorf("une URL absente vaut « %s », attendu « no-store »", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Un même fichier, deux miroirs : le cache doit le reconnaître.
|
|
|
|
|
//
|
|
|
|
|
// Une liste de miroirs tourne, et pacman a réellement tiré « extra.db » de
|
|
|
|
|
// « geo » alors que le cache ne détenait que la copie de « fastly » — même
|
|
|
|
|
// fichier, autre nom d'hôte, défaut de cache et 504 hors ligne.
|
|
|
|
|
func TestMemeCheminSurDeuxMiroirs(t *testing.T) {
|
|
|
|
|
a, _ := url.Parse("https://fastly.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
|
|
|
|
|
b, _ := url.Parse("https://geo.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst")
|
|
|
|
|
if !PortableParChemin(a) {
|
|
|
|
|
t.Fatal("un paquet n'est pas reconnu portable")
|
|
|
|
|
}
|
|
|
|
|
if KeySansHote("GET", a) != KeySansHote("GET", b) {
|
|
|
|
|
t.Error("deux miroirs du même fichier donnent deux clés")
|
|
|
|
|
}
|
|
|
|
|
if Key("GET", a.String()) == Key("GET", b.String()) {
|
|
|
|
|
t.Error("la clé complète devrait, elle, distinguer les deux URL")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// L'index aussi : c'est lui qui a échoué hors ligne.
|
|
|
|
|
func TestIndexPortableEntreMiroirs(t *testing.T) {
|
|
|
|
|
a, _ := url.Parse("https://fastly.example/extra/os/x86_64/extra.db")
|
|
|
|
|
b, _ := url.Parse("https://geo.example/extra/os/x86_64/extra.db")
|
|
|
|
|
if !PortableParChemin(a) {
|
|
|
|
|
t.Fatal("un index de dépôt n'est pas reconnu portable")
|
|
|
|
|
}
|
|
|
|
|
if KeySansHote("GET", a) != KeySansHote("GET", b) {
|
|
|
|
|
t.Error("deux miroirs du même index donnent deux clés")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Mais pas n'importe quoi : « /index.html » ne nomme rien, et deux sites sans
|
|
|
|
|
// rapport en portent un.
|
|
|
|
|
func TestUnePageQuelconqueNestPasPortable(t *testing.T) {
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://a.example/index.html",
|
|
|
|
|
"https://a.example/quelque-chose",
|
|
|
|
|
"https://a.example/simple/requests/",
|
|
|
|
|
"https://a.example/core.db?miroir=2",
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if PortableParChemin(u) {
|
|
|
|
|
t.Errorf("%s est jugé portable, à tort", brut)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Le protocole « smart » de git est une NÉGOCIATION : le serveur calcule sa
|
|
|
|
|
// réponse d'après ce que le client détient déjà. Rien n'y est réutilisable, et
|
|
|
|
|
// servir une réponse gardée ferait croire à des références disparues.
|
|
|
|
|
func TestGitSmartNestJamaisGarde(t *testing.T) {
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://git.example/org/depot.git/info/refs?service=git-upload-pack",
|
|
|
|
|
"https://git.example/org/depot.git/git-upload-pack",
|
|
|
|
|
"https://git.example/org/depot.git/git-receive-pack",
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if !EstGitSmart(u) {
|
|
|
|
|
t.Errorf("%s n'est pas reconnu comme négociation git", brut)
|
|
|
|
|
}
|
|
|
|
|
if got := Classify(u); got != ClassNoStore {
|
|
|
|
|
t.Errorf("%s classé « %s », attendu « no-store »", brut, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Le protocole « dumb », lui, sert des fichiers : un objet porte son empreinte
|
|
|
|
|
// dans son nom et se garde comme n'importe quel fichier figé.
|
|
|
|
|
func TestLesObjetsGitRestentCachables(t *testing.T) {
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://git.example/org/depot.git/objects/ab/cdef0123456789",
|
|
|
|
|
"https://git.example/org/depot.git/objects/pack/pack-abc.pack",
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if EstGitSmart(u) {
|
|
|
|
|
t.Errorf("%s pris pour une négociation, à tort", brut)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func TestEstGitSmartURLNulle(t *testing.T) {
|
|
|
|
|
if EstGitSmart(nil) {
|
|
|
|
|
t.Error("une URL absente est prise pour du git")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Un index Debian publié sous l'empreinte de son contenu est aussi figé
|
|
|
|
|
// qu'un paquet : son nom EST sa somme. Il se range donc SANS son hôte, un
|
|
|
|
|
// contenu différent portant forcément un autre nom.
|
|
|
|
|
func TestLesIndexParEmpreinteSontImmuables(t *testing.T) {
|
|
|
|
|
sha256 := strings.Repeat("0123456789abcdef", 4)
|
|
|
|
|
md5 := strings.Repeat("0123456789abcdef", 2)
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://miroir.example/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + sha256,
|
|
|
|
|
"https://miroir.example/debian/dists/trixie/main/i18n/by-hash/MD5Sum/" + md5,
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if got := Classify(u); got != ClassImmutable {
|
|
|
|
|
t.Errorf("%s classé « %s », attendu « immutable »", brut, got)
|
|
|
|
|
}
|
|
|
|
|
if !PortableParChemin(u) {
|
|
|
|
|
t.Errorf("%s n'est pas jugé portable", brut)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Ce qui n'est pas une empreinte garde la règle d'avant.
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/pas-une-somme",
|
|
|
|
|
"https://miroir.example/debian/dists/trixie/by-hash/SHA256/" + sha256 + "?v=2",
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if got := Classify(u); got != ClassVolatile {
|
|
|
|
|
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[FIX] cache qemu : réunir les préfixes de miroir sous une seule clé
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 un même octet : le cache retéléchargeait ce
qu'il détenait. Relevé 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 le plus petit nombre sans collision : un chemin Debian en porte six, si
bien que cinq confondraient son paquet avec celui d'Ubuntu, qui porte le même
nom pour d'autres octets — 38 cas mesurés. Vérifié : deux mutations attrapées.
--- EN ---
A mirror prefixes the path as it pleases, and the whole path gave two keys for
one byte: the cache re-downloaded what it held. Measured 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 now count, empty ones falling
with them. Six is the smallest number without collision: a Debian path carries
six, so five would confuse its package with Ubuntu's, which bears the same
name for other bytes — 38 measured cases. Checked: two mutations caught.
Assisted-by: Claude Opus 5
2026-09-17 09:26:56 -04:00
|
|
|
// Un miroir préfixe le chemin à sa guise, et le chemin entier donnait deux
|
|
|
|
|
// clés pour un même octet. Relevé sur un journal réel : 1124 noms de paquets
|
|
|
|
|
// vivaient sous plusieurs chemins, et 3,18 Gio repartaient à l'amont pour des
|
|
|
|
|
// fichiers que le magasin détenait déjà.
|
|
|
|
|
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")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[ADD] cache qemu : miroir des téléchargements des VM, hors ligne compris
VMs of one host pulled the same packages again and again, and an install
could not be proven to work without the network. A Go service intercepts the
bridge transparently: a package file is served from disk, an index is always
revalidated upstream and replayed only when upstream is mute, git is mirrored.
Deployment poses the cache authority in QEMU and Proxmox VE guests, and can cut
the network for a whole install. The TODO menu installs, diagnoses, fills,
cleans and copies the store; long_test/qemu_cache.py measures that a second VM
pulls no package upstream. Covered by Go tests and test/test_qemu_cache_*.py.
--- FR ---
Les VM d'un hôte tiraient sans cesse les mêmes paquets, et rien ne prouvait
qu'une installation marche sans réseau. Un service Go intercepte le pont de
façon transparente : un fichier de paquet est servi du disque, un index est
toujours revalidé à l'amont et rejoué seulement quand l'amont est muet, git
est mis en miroir. Le déploiement pose l'autorité du cache dans les invités
QEMU et Proxmox VE, et peut couper le réseau pour toute une installation. Le
menu TODO installe, diagnostique, remplit, nettoie et emporte le magasin ;
long_test/qemu_cache.py mesure qu'une seconde VM ne tire aucun paquet de
l'amont. Couvert par les tests Go et test/test_qemu_cache_*.py.
Assisted-by: Claude Opus 5
2026-09-14 14:41:19 -04:00
|
|
|
// 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
|
|
|
|
|
// accuse le dépôt.
|
|
|
|
|
func TestUnIndexParEmpreinteVautSurDeuxMiroirs(t *testing.T) {
|
|
|
|
|
somme := strings.Repeat("0123456789abcdef", 4)
|
|
|
|
|
chemin := "/debian/dists/trixie/main/binary-amd64/by-hash/SHA256/" + somme
|
|
|
|
|
a, _ := url.Parse("https://miroir-a.example" + chemin)
|
|
|
|
|
b, _ := url.Parse("https://miroir-b.example" + chemin)
|
|
|
|
|
if CleDe("GET", a) != CleDe("GET", b) {
|
|
|
|
|
t.Error("deux miroirs du même index donnent deux clés")
|
|
|
|
|
}
|
|
|
|
|
// La clé complète doit, elle, continuer de les distinguer : c'est ce qui
|
|
|
|
|
// prouve que le rangement passe bien par la clé SANS hôte, et non que les
|
|
|
|
|
// deux fonctions se sont mises à rendre la même chose.
|
|
|
|
|
if Key("GET", a.String()) == Key("GET", b.String()) {
|
|
|
|
|
t.Error("la clé complète ne distingue plus les deux URL")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Un pointeur vers la dernière version publiée change de cible à chaque
|
|
|
|
|
// publication : son suffixe d'archive ne doit pas le figer sur le disque, ni
|
|
|
|
|
// le ranger sans son hôte.
|
|
|
|
|
func TestUnPointeurDeDerniereVersionEstVolatile(t *testing.T) {
|
|
|
|
|
for _, brut := range []string{
|
|
|
|
|
"https://forge.example/o/d/releases/latest/download/outil-x86_64-linux.tar.gz",
|
|
|
|
|
"https://forge.example/o/d/releases/latest/download/outil.zip",
|
|
|
|
|
} {
|
|
|
|
|
u, _ := url.Parse(brut)
|
|
|
|
|
if got := Classify(u); got != ClassVolatile {
|
|
|
|
|
t.Errorf("%s classé « %s », attendu « volatile »", brut, got)
|
|
|
|
|
}
|
|
|
|
|
if PortableParChemin(u) {
|
|
|
|
|
t.Errorf("%s jugé portable", brut)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// Une version NOMMÉE reste figée par son suffixe.
|
|
|
|
|
u, _ := url.Parse("https://forge.example/o/d/releases/download/v1.2.3/outil.tar.gz")
|
|
|
|
|
if got := Classify(u); got != ClassImmutable {
|
|
|
|
|
t.Errorf("une version nommée classée « %s », attendu « immutable »", got)
|
|
|
|
|
}
|
|
|
|
|
}
|
[ADD] cache qemu : revalider les index, ranger chaque représentation
Sur une installation complète, 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 déjà gardé part désormais avec son ETag : l'amont juge chaque
requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept »,
chaque représentation est rangée à part, faute de quoi deux clients qui
alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip,
nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent.
--- EN ---
On a full install the pip indexes, npm metadata and repo bundle weighed about
110 MB per VM, taken whole every time. An index already kept now leaves with
its ETag: upstream judges every request, and a « 304 » serves the body from
disk. Under « Vary: Accept » each representation is stored apart, without
which two clients alternating between them re-download each in turn. The
PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the
archive they describe.
Assisted-by: Claude Opus 5
2026-09-16 02:59:02 -04:00
|
|
|
|
|
|
|
|
// 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)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
[FIX] cache qemu : classer les objets de nix comme immuables
Le classeur ne connaissait pas nix. Un « <empreinte>.narinfo » et les
« nar/<empreinte>.nar.zst » tombaient dans le volatil par défaut, donc
revalidés un par un contre l'amont — relevé sur un déploiement réel :
346 narinfo, 249 nar.zst, 54 nar.xz, tous volatils, pour des fichiers dont
le NOM est l'empreinte du chemin de store et qui ne changeront jamais.
Une dérivation modifiée porte une autre empreinte, donc un autre nom. La
clé écarte l'hôte du même coup : le nom identifie le fichier sur tout
miroir. « nix-cache-info » reste volatile, il décrit le magasin amont.
--- EN ---
The classifier did not know nix. A "<hash>.narinfo" and the
"nar/<hash>.nar.zst" fell into the volatile default, so were revalidated
one by one against upstream — read off a real deployment: 346 narinfo,
249 nar.zst, 54 nar.xz, all volatile, for files whose NAME is the store
path hash and which will never change.
A modified derivation carries another hash, hence another name. The key
drops the host at the same time: the name identifies the file on any
mirror. "nix-cache-info" stays volatile, it describes the upstream store.
Assisted-by: Claude Opus 5
2026-09-16 07:02:08 -04:00
|
|
|
|
|
|
|
|
// Les objets de nix sont adressés par l'empreinte du chemin de store : leur
|
|
|
|
|
// NOM est cette empreinte. Ils tombaient dans le volatil par défaut, donc
|
|
|
|
|
// revalidés un par un contre l'amont — une installation NixOS en demande des
|
|
|
|
|
// centaines, et aucun ne changera jamais.
|
|
|
|
|
func TestLesObjetsDeNixSontImmuables(t *testing.T) {
|
|
|
|
|
for _, chemin := range []string{
|
|
|
|
|
"/kdb6ag6vg2vfkxy3k47zqy7jm0266dhn.narinfo",
|
|
|
|
|
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.zst",
|
|
|
|
|
"/nar/1abcd2efgh3ijkl4mnop5qrst6uvwx7y.nar.xz",
|
|
|
|
|
} {
|
|
|
|
|
u, err := url.Parse("https://cache.nixos.org" + chemin)
|
|
|
|
|
if err != nil {
|
|
|
|
|
t.Fatal(err)
|
|
|
|
|
}
|
|
|
|
|
if c := Classify(u); c != ClassImmutable {
|
|
|
|
|
t.Errorf("%s : classe %v, attendu immuable", chemin, c)
|
|
|
|
|
}
|
|
|
|
|
// Le nom identifie le fichier sur TOUT miroir : la clé écarte l'hôte.
|
|
|
|
|
if !PortableParChemin(u) {
|
|
|
|
|
t.Errorf("%s : devrait se ranger sans son hôte", chemin)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// « nix-cache-info » n'est PAS adressé par une empreinte : c'est la
|
|
|
|
|
// description du magasin amont, et elle peut changer. Elle reste volatile.
|
|
|
|
|
func TestLaDescriptionDuMagasinNixResteVolatile(t *testing.T) {
|
|
|
|
|
u, _ := url.Parse("https://cache.nixos.org/nix-cache-info")
|
|
|
|
|
if c := Classify(u); c == ClassImmutable {
|
|
|
|
|
t.Errorf("nix-cache-info tenu pour immuable : %v", c)
|
|
|
|
|
}
|
|
|
|
|
}
|