erplibre/script/qemu_cache/proxy_test.go
Mathieu Benoit 6a4bd546fc [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 05:36:13 -04:00

506 lines
17 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"io"
"net/http"
"net/http/httptest"
"net/url"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
)
// amont monte un serveur qui compte ce qu'on lui demande. Le compteur EST la
// mesure de tous ces tests : le cache n'a d'intérêt que s'il fait baisser le
// nombre de requêtes qui sortent.
type amont struct {
srv *httptest.Server
appel int64
corps string
}
func nouvelAmont(t *testing.T, corps string) *amont {
t.Helper()
a := &amont{corps: corps}
a.srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&a.appel, 1)
w.Header().Set("Content-Type", "application/octet-stream")
io.WriteString(w, a.corps)
}))
t.Cleanup(a.srv.Close)
return a
}
func (a *amont) appels() int64 { return atomic.LoadInt64(&a.appel) }
func (a *amont) hote() string {
u, _ := url.Parse(a.srv.URL)
return u.Host
}
func proxyDeTest(t *testing.T) *Proxy {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
return NewProxy(&Store{Dir: t.TempDir()}, alog)
}
// demande joue une requête telle que l'interception transparente la présente :
// une ligne de requête sans hôte, l'hôte venant de l'en-tête.
func demande(t *testing.T, p *Proxy, hote, chemin string) *httptest.ResponseRecorder {
t.Helper()
r := httptest.NewRequest("GET", chemin, nil)
r.Host = hote
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
// Invariant 1 : un index n'est JAMAIS servi du cache tant que l'amont répond.
//
// C'est la règle qui empêche le cache de provoquer le « failed retrieving
// file … 404 » sur un paquet retiré des miroirs : servir un index périmé
// promet des fichiers qui n'existent plus.
func TestIndexJamaisServiQuandAmontRepond(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
for i := 1; i <= 3; i++ {
w := demande(t, p, a.hote(), "/arch/core/os/x86_64/core.db")
if w.Code != 200 {
t.Fatalf("requête %d : code %d", i, w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != "miss" {
t.Errorf("requête %d : servie « %s », attendu « miss »", i, got)
}
}
if n := a.appels(); n != 3 {
t.Errorf("l'amont a reçu %d requêtes, attendu 3 : un index a été servi du cache", n)
}
}
// Invariant 2 : un fichier de paquet est servi du cache dès la seconde
// demande. C'est le gain que l'outil existe pour produire.
func TestPaquetServiDuCache(t *testing.T) {
a := nouvelAmont(t, "contenu du paquet")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
w1 := demande(t, p, a.hote(), chemin)
if w1.Header().Get("X-ERPLibre-Cache") != "miss" {
t.Fatalf("première demande servie du cache : %s",
w1.Header().Get("X-ERPLibre-Cache"))
}
w2 := demande(t, p, a.hote(), chemin)
if got := w2.Header().Get("X-ERPLibre-Cache"); got != OutcomeHit {
t.Errorf("seconde demande servie « %s », attendu « %s »", got, OutcomeHit)
}
if w2.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w2.Body.String(), a.corps)
}
if n := a.appels(); n != 1 {
t.Errorf("l'amont a reçu %d requêtes, attendu 1", n)
}
}
// Invariant 3 : l'amont muet fait sortir la copie stockée, index compris.
// C'est ce qui rend un déploiement possible sans réseau du tout.
func TestIndexServiQuandAmontMuet(t *testing.T) {
a := nouvelAmont(t, "base de paquets")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/core.db"
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("remplissage : code %d", w.Code)
}
hote := a.hote()
a.srv.Close() // l'amont devient injoignable
w := demande(t, p, hote, chemin)
if w.Code != 200 {
t.Fatalf("hors ligne : code %d, attendu 200", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale {
t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale)
}
if w.Header().Get("X-ERPLibre-Cache-Date") == "" {
t.Error("la date de l'instantané manque : l'opérateur ne peut pas savoir de quand datent ses octets")
}
if w.Body.String() != a.corps {
t.Errorf("corps servi %q, attendu %q", w.Body.String(), a.corps)
}
}
// Invariant 4 : hors ligne et sans copie, l'erreur NOMME le fichier.
//
// Un 404 nu ferait accuser le miroir : le client n'a aucun moyen de savoir
// qu'un cache s'est interposé.
func TestDefautHorsLigneNommeLeFichier(t *testing.T) {
a := nouvelAmont(t, "peu importe")
hote := a.hote()
a.srv.Close()
p := proxyDeTest(t)
w := demande(t, p, hote, "/arch/core/os/x86_64/jamais-vu.pkg.tar.zst")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("code %d, attendu %d", w.Code, http.StatusGatewayTimeout)
}
corps := w.Body.String()
for _, attendu := range []string{"jamais-vu.pkg.tar.zst", hote, "erplibre_go_qemu_cache"} {
if !strings.Contains(corps, attendu) {
t.Errorf("le message ne dit pas %q :\n%s", attendu, corps)
}
}
}
// Une requête partielle ne garde jamais SON fragment : le garder comme un
// corps entier servirait un paquet tronqué. Ce qui entre au cache est le corps
// entier, pris à part (voir completion_test.go).
func TestRequetePartielleNonGardee(t *testing.T) {
a := nouvelAmontAPlages(t, "0123456789")
p := proxyDeTest(t)
chemin := "/x/paquet-1-1-x86_64.pkg.tar.zst"
if w := demandePlage(t, p, a.hote(), chemin, "bytes=0-4"); w.Body.String() != "01234" {
t.Fatalf("plage : %q", w.Body.String())
}
p.attendreCompletions()
w := demande(t, p, a.hote(), chemin)
if w.Body.String() != "0123456789" {
t.Errorf("demande entière servie %q : un fragment a été gardé", w.Body.String())
}
}
// Un POST ne se cache pas : il change un état à l'amont.
func TestPostNonCache(t *testing.T) {
a := nouvelAmont(t, "réponse")
p := proxyDeTest(t)
for i := 0; i < 2; i++ {
r := httptest.NewRequest("POST", "/api/quelque-chose.whl", strings.NewReader("x"))
r.Host = a.hote()
p.serve(httptest.NewRecorder(), r, "http")
}
if n := a.appels(); n != 2 {
t.Errorf("l'amont a reçu %d requêtes, attendu 2 : un POST a été caché", n)
}
}
// Un transport qui note le client par lequel la requête est passée.
type transportTemoin struct {
nom string
vues *[]string
}
func (t transportTemoin) RoundTrip(r *http.Request) (*http.Response, error) {
*t.vues = append(*t.vues, t.nom)
return &http.Response{
StatusCode: 200,
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader("")),
Request: r,
}, nil
}
// Le délai court existe pour que le repli hors ligne arrive AVANT que le
// client renonce. Il prenait le calcul d'un serveur git pour un amont
// injoignable — mesuré à seize secondes avant le premier octet — et rendait un
// 504 sur un dépôt parfaitement joignable, faisant échouer « repo sync ».
func TestGitPasseParLeClientPatient(t *testing.T) {
var vues []string
p := &Proxy{
Client: &http.Client{
Transport: transportTemoin{"court", &vues},
},
ClientPatient: &http.Client{
Transport: transportTemoin{"patient", &vues},
},
}
cas := []struct {
brut string
attendu string
}{
{"https://git.example/o/d.git/info/refs?service=git-upload-pack",
"patient"},
{"https://git.example/o/d.git/git-upload-pack", "patient"},
{"https://miroir.example/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst",
"court"},
{"https://miroir.example/core/os/x86_64/core.db", "court"},
}
for _, c := range cas {
vues = nil
u, _ := url.Parse(c.brut)
r, _ := http.NewRequest("GET", c.brut, nil)
if _, err := p.fetch(r, u, false); err != nil {
t.Fatalf("%s : %v", c.brut, err)
}
if len(vues) != 1 || vues[0] != c.attendu {
t.Errorf("%s passé par %v, attendu « %s »",
c.brut, vues, c.attendu)
}
}
}
// Les deux clients ne se distinguent que par leur patience : un amont
// injoignable reste détecté à l'établissement, en quatre secondes, dans les
// deux cas.
func TestLeClientPatientLestUniquementSurLesEnTetes(t *testing.T) {
p := NewProxy(&Store{Dir: t.TempDir()}, nil)
court := p.Client.Transport.(*http.Transport)
patient := p.ClientPatient.Transport.(*http.Transport)
if patient.ResponseHeaderTimeout <= court.ResponseHeaderTimeout {
t.Errorf("le client patient n'attend pas plus : %v contre %v",
patient.ResponseHeaderTimeout, court.ResponseHeaderTimeout)
}
if patient.TLSHandshakeTimeout != court.TLSHandshakeTimeout {
t.Error("le délai de poignée de main ne doit pas changer")
}
}
// Un paquet publié derrière une redirection vers une URL SIGNÉE.
//
// La cible change à chaque requête — c'est ce que fait une signature à
// péremption. Rendre la redirection au client, en comptant qu'il la
// redemandera au travers du cache, suppose une cible stable : ici la machine
// suivante ne retrouve rien et retélécharge le paquet en entier.
func TestPaquetDerriereUneRedirectionSignee(t *testing.T) {
var telechargements, signature int64
contenu := strings.Repeat("charge utile", 64)
stockage := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&telechargements, 1)
io.WriteString(w, contenu)
}))
t.Cleanup(stockage.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
n := atomic.AddInt64(&signature, 1)
http.Redirect(w, r,
stockage.URL+"/objet?signature="+string(rune('a'+n)),
http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
p := proxyDeTest(t)
chemin := "/paquets/releases/download/1.0/outil_1.0_amd64.deb"
for i := 1; i <= 3; i++ {
w := demande(t, p, u.Host, chemin)
if w.Code != 200 {
t.Fatalf("requête %d : code %d, attendu 200", i, w.Code)
}
if w.Body.String() != contenu {
t.Fatalf("requête %d : corps de %d octets", i, w.Body.Len())
}
}
if n := atomic.LoadInt64(&telechargements); n != 1 {
t.Errorf("le paquet est descendu %d fois, attendu 1 :"+
" la redirection empêche le cache de servir", n)
}
}
// Un index, lui, garde l'ancien comportement : la redirection lui est rendue,
// et il la redemandera. Rien n'est gardé d'un index tant que l'amont répond,
// donc suivre la chaîne n'apporterait rien et masquerait au client l'hôte qui
// lui a réellement répondu.
func TestUnIndexRecoitSaRedirection(t *testing.T) {
cible := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
io.WriteString(w, "base")
}))
t.Cleanup(cible.Close)
publication := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, cible.URL+"/vraie.db", http.StatusFound)
}))
t.Cleanup(publication.Close)
u, _ := url.Parse(publication.URL)
w := demande(t, proxyDeTest(t), u.Host, "/arch/core/os/x86_64/core.db")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 : la redirection a été suivie", w.Code)
}
}
// Une boucle de redirections doit rendre la main plutôt que tourner.
func TestUneBoucleDeRedirectionsSarrete(t *testing.T) {
var vues int64
var srv *httptest.Server
srv = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
atomic.AddInt64(&vues, 1)
http.Redirect(w, r, srv.URL+"/tourne.deb", http.StatusFound)
}))
t.Cleanup(srv.Close)
u, _ := url.Parse(srv.URL)
w := demande(t, proxyDeTest(t), u.Host, "/tourne.deb")
if w.Code != http.StatusFound {
t.Errorf("code %d, attendu 302 au bout de la chaîne", w.Code)
}
if n := atomic.LoadInt64(&vues); n > maxRedirections+1 {
t.Errorf("%d requêtes pour une boucle, borne %d",
n, maxRedirections+1)
}
}
// proxyEtCasier rend un proxy et le répertoire de son casier : les contrôles
// qui suivent ont besoin de constater ce qui a été GARDÉ, pas seulement ce
// qui a été rendu.
func proxyEtCasier(t *testing.T) (*Proxy, string) {
t.Helper()
alog, err := OpenAccessLog("")
if err != nil {
t.Fatalf("journal : %v", err)
}
dir := t.TempDir()
return NewProxy(&Store{Dir: dir}, alog), dir
}
func corpsGardes(t *testing.T, dir string) int {
t.Helper()
n := 0
_ = filepath.Walk(dir, func(p string, fi os.FileInfo, err error) error {
if err == nil && !fi.IsDir() && strings.HasSuffix(p, ".body") {
n++
}
return nil
})
return n
}
// Une image cloud livre déjà l'index de sa suite de base. Son apt le REVALIDE
// au lieu de le télécharger, l'amont rend « 304 », et le cache n'a donc
// jamais de corps à garder pour cette ressource — aussi longtemps que ses
// clients en détiennent une copie, c'est-à-dire toujours.
//
// Hors ligne, la suite de base est alors la seule à manquer : ses voisines
// « -updates », « -security » et « -backports » sortent périmées du cache, et
// apt ne trouve plus un paquet de la base pendant que le reste marche.
func TestUneConditionnelleNeLaissePasLeCasierVide(t *testing.T) {
var recues []string
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
recues = append(recues, r.Header.Get("If-Modified-Since"))
if r.Header.Get("If-Modified-Since") != "" {
w.WriteHeader(http.StatusNotModified)
return
}
_, _ = w.Write([]byte("Origin: Ubuntu\nSuite: base\n"))
}))
defer srv.Close()
hote, _ := url.Parse(srv.URL)
p, dir := proxyEtCasier(t)
joue := func() *httptest.ResponseRecorder {
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = hote.Host
r.Header.Set("If-Modified-Since", "Wed, 01 Jan 2020 00:00:00 GMT")
w := httptest.NewRecorder()
p.serve(w, r, "http")
return w
}
if w := joue(); w.Code != http.StatusOK {
t.Fatalf("statut rendu %d, attendu 200", w.Code)
}
if len(recues) != 1 || recues[0] != "" {
t.Fatalf("la condition est partie à l'amont : %q", recues)
}
if corpsGardes(t, dir) == 0 {
t.Fatal("rien n'est gardé : le cache restera vide pour cette suite")
}
// Le corps est là : la condition peut repartir, et un « 304 » ne coûte
// alors plus rien — c'est ce qui économise la bande passante.
recues = nil
joue()
if len(recues) != 1 || recues[0] == "" {
t.Fatalf("la condition a encore été retirée : %q", recues)
}
}
// Amont muet et client qui détient déjà sa copie : lui rendre « 504 » le
// prive d'une suite entière qu'il pouvait lire. « 304 » le laisse garder la
// sienne, ce que « stale-if-error » veut dire pour une conditionnelle.
func TestAmontMuetEtClientQuiDetientDejaSaCopie(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
// Rien n'écoute : l'amont est injoignable.
r.Host = "127.0.0.1:1"
r.Header.Set("If-None-Match", `"abc"`)
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusNotModified {
t.Fatalf("statut rendu %d, attendu 304", w.Code)
}
if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeKeep {
t.Fatalf("issue %q, attendue %q", got, OutcomeKeep)
}
}
// Sans condition, le client n'a rien à garder : le 504 reste la réponse
// juste, et il dit pourquoi.
func TestAmontMuetSansCopieChezLeClient(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/ubuntu/dists/base/InRelease", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
}
// Un corps d'erreur finit régulièrement dans un shell : « curl … | bash »
// est l'idiome de la moitié des installateurs, et « curl » sans « -f » lui
// livre le corps d'un 504 comme s'il l'avait demandé.
//
// Le message doit donc ne RIEN faire une fois exécuté. Sans cela, l'échec
// se lit « bash: line 1: erplibre_go_qemu_cache: command not found » et la
// cause véritable, elle, ne se lit plus.
func TestLeMessageHorsLigneEstInerteDansUnShell(t *testing.T) {
p, _ := proxyEtCasier(t)
r := httptest.NewRequest("GET", "/pyenv-installer", nil)
r.Host = "127.0.0.1:1"
w := httptest.NewRecorder()
p.serve(w, r, "http")
if w.Code != http.StatusGatewayTimeout {
t.Fatalf("statut rendu %d, attendu 504", w.Code)
}
for i, l := range strings.Split(strings.TrimRight(w.Body.String(), "\n"), "\n") {
if !strings.HasPrefix(l, "#") {
t.Fatalf("ligne %d exécutable par un shell : %q", i+1, l)
}
}
// Inerte ne veut pas dire muet : la cause reste lisible.
if !strings.Contains(w.Body.String(), "amont injoignable") {
t.Fatal("le message ne dit plus pourquoi")
}
}
// Une cause qui porte plusieurs lignes ne doit pas sortir du commentaire :
// il suffirait de la première pour rendre la suite exécutable.
func TestUneCauseMultiligneResteCommentee(t *testing.T) {
got := enCommentaire("premier\nseconde\ntroisième\n")
for _, l := range strings.Split(strings.TrimRight(got, "\n"), "\n") {
if !strings.HasPrefix(l, "# ") {
t.Fatalf("ligne non commentée : %q", l)
}
}
}