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
189 lines
7 KiB
Go
189 lines
7 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// « --detient » dit ce que le magasin tient, une ligne par question, avec la
|
|
// clé que le service calcule. Le magasin est rempli ici PAR le service, pour
|
|
// que toute divergence entre les deux calculs de clé se voie.
|
|
func TestDetientDitCeQueLeMagasinTient(t *testing.T) {
|
|
paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst"
|
|
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
|
switch r.URL.Path {
|
|
case "/installer.sh":
|
|
rediriger(http.StatusFound, "https://ailleurs.example/bootstrap.sh")(w, r)
|
|
case "/depot/etat":
|
|
servir("état courant")(w, r)
|
|
default:
|
|
servir("contenu du paquet")(w, r)
|
|
}
|
|
})
|
|
p := proxyDeTest(t)
|
|
for _, c := range []string{"/installer.sh", "/depot/etat", paquet} {
|
|
joue(t, p, "GET", a.hote(), c)
|
|
}
|
|
joue(t, p, "HEAD", a.hote(), "/installer.sh")
|
|
|
|
base := "http://" + a.hote()
|
|
questions := []string{
|
|
"GET " + base + "/depot/etat",
|
|
"GET " + base + "/installer.sh",
|
|
"HEAD " + base + "/installer.sh",
|
|
"get " + base + "/jamais-vu.sh",
|
|
"POST " + base + "/depot/etat",
|
|
"GET " + base + "/o/d.git/info/refs?service=git-upload-pack",
|
|
// Un paquet est portable : le même chemin sur un autre miroir est
|
|
// le même fichier.
|
|
"GET http://autre-miroir.example.invalid" + paquet,
|
|
"",
|
|
"# une ligne de commentaire",
|
|
base + "/depot/etat",
|
|
"GET pas-une-url",
|
|
}
|
|
var sortie bytes.Buffer
|
|
if err := EcrireDetentions(
|
|
p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie,
|
|
); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
attendus := []struct {
|
|
verdict, statut, classe, methode, url string
|
|
date bool
|
|
}{
|
|
{"garde", "200", "volatile", "GET", base + "/depot/etat", true},
|
|
{"statut", "302", "volatile", "GET", base + "/installer.sh", true},
|
|
{"statut", "302", "volatile", "HEAD", base + "/installer.sh", true},
|
|
{"absent", "-", "volatile", "GET", base + "/jamais-vu.sh", false},
|
|
{"non-cachable", "-", "volatile", "POST", base + "/depot/etat", false},
|
|
{"non-cachable", "-", "no-store", "GET",
|
|
base + "/o/d.git/info/refs?service=git-upload-pack", false},
|
|
{"garde", "200", "immutable", "GET",
|
|
"http://autre-miroir.example.invalid" + paquet, true},
|
|
{"garde", "200", "volatile", "GET", base + "/depot/etat", true},
|
|
{"non-cachable", "-", "-", "GET", "pas-une-url", false},
|
|
}
|
|
lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n")
|
|
if len(lignes) != len(attendus) {
|
|
t.Fatalf("%d lignes, attendu %d :\n%s", len(lignes), len(attendus), sortie.String())
|
|
}
|
|
for i, a := range attendus {
|
|
champs := strings.Split(lignes[i], "\t")
|
|
if len(champs) != 6 {
|
|
t.Errorf("ligne %d : %d champs, attendu 6 : %q", i+1, len(champs), lignes[i])
|
|
continue
|
|
}
|
|
if champs[0] != a.verdict || champs[1] != a.statut ||
|
|
champs[3] != a.classe || champs[4] != a.methode || champs[5] != a.url {
|
|
t.Errorf("ligne %d : %q, attendu %s %s … %s %s %s",
|
|
i+1, lignes[i], a.verdict, a.statut, a.classe, a.methode, a.url)
|
|
}
|
|
if a.date {
|
|
if _, err := time.Parse(time.RFC3339, champs[2]); err != nil {
|
|
t.Errorf("ligne %d : date %q illisible", i+1, champs[2])
|
|
}
|
|
} else if champs[2] != "-" {
|
|
t.Errorf("ligne %d : date %q pour un objet absent", i+1, champs[2])
|
|
}
|
|
}
|
|
}
|
|
|
|
// Le relevé ne rajeunit rien : l'âge d'un objet est celui de son dernier
|
|
// SERVICE, et une question posée au magasin n'en est pas un.
|
|
func TestDetientNeRajeunitPas(t *testing.T) {
|
|
a := nouvelAmontScripte(t, servir("état courant"))
|
|
p, _ := proxyEtCasier(t)
|
|
joue(t, p, "GET", a.hote(), "/depot/etat")
|
|
|
|
u, _ := url.Parse("http://" + a.hote() + "/depot/etat")
|
|
_, corps := p.Store.paths(CleDe("GET", u))
|
|
ancien := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC)
|
|
if err := os.Chtimes(corps, ancien, ancien); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var sortie bytes.Buffer
|
|
EcrireDetentions(p.Store, strings.NewReader("GET "+u.String()+"\n"), &sortie)
|
|
if !strings.HasPrefix(sortie.String(), VerdictGarde+"\t") {
|
|
t.Fatalf("l'objet n'a pas été vu : %q", sortie.String())
|
|
}
|
|
fi, err := os.Stat(corps)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !fi.ModTime().Equal(ancien) {
|
|
t.Errorf("le relevé a rajeuni l'objet : %v au lieu de %v", fi.ModTime(), ancien)
|
|
}
|
|
}
|
|
|
|
// Un HEAD que le service ne gardera jamais est non-cachable, pas absent :
|
|
// l'appelant le croirait sinon à remplir, et le rejouerait pour rien. Le
|
|
// magasin est rempli ici PAR le service, l'amont répondant 200 à chaque
|
|
// HEAD : seul celui dont la classe garde un statut seul doit y entrer, et
|
|
// c'est le seul que « --detient » ne dit pas non-cachable.
|
|
func TestDetientUnHeadQueLeMagasinNeGardeJamais(t *testing.T) {
|
|
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Length", "1234")
|
|
w.WriteHeader(http.StatusOK)
|
|
})
|
|
p, dir := proxyEtCasier(t)
|
|
base := "http://" + a.hote()
|
|
attendus := map[string]string{
|
|
// Immuable et portable : un paquet.
|
|
"/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst": VerdictNonCachable,
|
|
// Immuable attaché à son hôte : un index par empreinte.
|
|
"/debian/dists/trixie/by-hash/SHA256/" + strings.Repeat("ab", 32): VerdictNonCachable,
|
|
// Volatile mais portable : un index de dépôt.
|
|
"/arch/core/os/x86_64/core.db": VerdictNonCachable,
|
|
// Volatile attaché à son hôte : le seul qui se garde.
|
|
"/api/bonjour": VerdictStatut,
|
|
}
|
|
for chemin, attendu := range attendus {
|
|
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictNonCachable &&
|
|
got != VerdictAbsent {
|
|
t.Errorf("%s avant remplissage : %q", chemin, got)
|
|
}
|
|
joue(t, p, "HEAD", a.hote(), chemin)
|
|
if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != attendu {
|
|
t.Errorf("HEAD %s : %q, attendu %q", chemin, got, attendu)
|
|
}
|
|
// Le GET de la même URL reste à remplir : un corps s'y garde.
|
|
if got := p.Store.Detenir("GET", base+chemin).Verdict; got != VerdictAbsent {
|
|
t.Errorf("GET %s : %q, attendu %q", chemin, got, VerdictAbsent)
|
|
}
|
|
}
|
|
if n := corpsGardes(t, dir); n != 1 {
|
|
t.Errorf("%d objet(s) gardé(s), attendu 1 : le service et --detient"+
|
|
" divergent sur ce qu'un HEAD laisse au magasin", n)
|
|
}
|
|
if got := p.Store.Detenir("HEAD", base+"/api/jamais-vu").Verdict; got != VerdictAbsent {
|
|
t.Errorf("HEAD volatile jamais vu : %q, attendu %q", got, VerdictAbsent)
|
|
}
|
|
}
|
|
|
|
// Un corps dont la taille ne correspond plus à son méta n'est pas en
|
|
// réserve : le service refuserait de le servir.
|
|
func TestDetientRefuseUnCorpsAltere(t *testing.T) {
|
|
a := nouvelAmontScripte(t, servir("état courant"))
|
|
p, _ := proxyEtCasier(t)
|
|
joue(t, p, "GET", a.hote(), "/depot/etat")
|
|
u, _ := url.Parse("http://" + a.hote() + "/depot/etat")
|
|
_, corps := p.Store.paths(CleDe("GET", u))
|
|
if err := os.WriteFile(corps, []byte("x"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d := p.Store.Detenir("GET", u.String()); d.Verdict != VerdictAbsent {
|
|
t.Errorf("verdict %q pour un corps altéré", d.Verdict)
|
|
}
|
|
EcrireDetentions(p.Store, strings.NewReader(""), io.Discard)
|
|
}
|