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
232 lines
6.3 KiB
Go
232 lines
6.3 KiB
Go
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func ecrire(t *testing.T, s *Store, key, corps string, taille int64) error {
|
|
t.Helper()
|
|
w, err := s.NewWriter(key, Meta{
|
|
URL: "https://exemple.example/x", Method: "GET", Status: 200,
|
|
Header: http.Header{"Content-Type": []string{"application/octet-stream"}},
|
|
Class: ClassImmutable.String(),
|
|
})
|
|
if err != nil {
|
|
t.Fatalf("ouverture : %v", err)
|
|
}
|
|
io.WriteString(w, corps)
|
|
return w.Commit(taille)
|
|
}
|
|
|
|
func TestStorePuisGet(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
key := Key("GET", "https://exemple.example/x")
|
|
if err := ecrire(t, s, key, "bonjour", 7); err != nil {
|
|
t.Fatalf("commit : %v", err)
|
|
}
|
|
m, f, err := s.Get(key)
|
|
if err != nil {
|
|
t.Fatalf("get : %v", err)
|
|
}
|
|
defer f.Close()
|
|
if m.Size != 7 {
|
|
t.Errorf("taille %d, attendu 7", m.Size)
|
|
}
|
|
raw, _ := io.ReadAll(f)
|
|
if string(raw) != "bonjour" {
|
|
t.Errorf("corps %q, attendu %q", raw, "bonjour")
|
|
}
|
|
if m.StoredAt.IsZero() {
|
|
t.Error("la date de stockage manque")
|
|
}
|
|
}
|
|
|
|
// Une réponse tronquée par une coupure réseau ressemble à une réponse
|
|
// complète pour tout le reste du code : la taille annoncée est donc vérifiée.
|
|
func TestCorpsTronqueRefuse(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
key := Key("GET", "https://exemple.example/tronque")
|
|
err := ecrire(t, s, key, "court", 4096)
|
|
if err == nil {
|
|
t.Fatal("un corps tronqué a été accepté")
|
|
}
|
|
if !strings.Contains(err.Error(), "tronqué") {
|
|
t.Errorf("erreur %q, attendu qu'elle dise « tronqué »", err)
|
|
}
|
|
if _, _, err := s.Get(key); err == nil {
|
|
t.Error("l'objet refusé est pourtant lisible")
|
|
}
|
|
}
|
|
|
|
// Une taille inconnue à l'amont — pas de Content-Length — vaut -1 et ne
|
|
// déclenche aucune vérification.
|
|
func TestTailleInconnueAcceptee(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
key := Key("GET", "https://exemple.example/inconnue")
|
|
if err := ecrire(t, s, key, "quoi que ce soit", -1); err != nil {
|
|
t.Fatalf("commit : %v", err)
|
|
}
|
|
if _, f, err := s.Get(key); err != nil {
|
|
t.Errorf("get : %v", err)
|
|
} else {
|
|
f.Close()
|
|
}
|
|
}
|
|
|
|
// Un objet dont le corps ne correspond plus aux métadonnées vaut absent
|
|
// plutôt que faux.
|
|
func TestCorpsAlteréVautAbsent(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
key := Key("GET", "https://exemple.example/altere")
|
|
if err := ecrire(t, s, key, "bonjour", 7); err != nil {
|
|
t.Fatalf("commit : %v", err)
|
|
}
|
|
_, body := s.paths(key)
|
|
if err := os.WriteFile(body, []byte("plus court"), 0o644); err != nil {
|
|
t.Fatalf("altération : %v", err)
|
|
}
|
|
if _, _, err := s.Get(key); err == nil {
|
|
t.Error("un corps de taille inattendue a été servi")
|
|
}
|
|
}
|
|
|
|
func TestAbortNeLaisseRien(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &Store{Dir: dir}
|
|
key := Key("GET", "https://exemple.example/abandon")
|
|
w, err := s.NewWriter(key, Meta{URL: "x", Method: "GET", Status: 200})
|
|
if err != nil {
|
|
t.Fatalf("ouverture : %v", err)
|
|
}
|
|
io.WriteString(w, "à moitié")
|
|
w.Abort()
|
|
|
|
if _, _, err := s.Get(key); err == nil {
|
|
t.Error("un objet abandonné est lisible")
|
|
}
|
|
var restes []string
|
|
filepath.Walk(dir, func(p string, info os.FileInfo, err error) error {
|
|
if err == nil && !info.IsDir() {
|
|
restes = append(restes, p)
|
|
}
|
|
return nil
|
|
})
|
|
if len(restes) != 0 {
|
|
t.Errorf("l'abandon laisse %v", restes)
|
|
}
|
|
}
|
|
|
|
func TestSweepPartials(t *testing.T) {
|
|
dir := t.TempDir()
|
|
s := &Store{Dir: dir}
|
|
sub := filepath.Join(dir, "ab", "cd")
|
|
os.MkdirAll(sub, 0o755)
|
|
os.WriteFile(filepath.Join(sub, "clef.part-123"), []byte("x"), 0o644)
|
|
os.WriteFile(filepath.Join(sub, "clef.body"), []byte("x"), 0o644)
|
|
|
|
if n := s.SweepPartials(); n != 1 {
|
|
t.Errorf("%d fichier(s) balayé(s), attendu 1", n)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(sub, "clef.body")); err != nil {
|
|
t.Error("le balayage a emporté un corps complet")
|
|
}
|
|
}
|
|
|
|
func TestStat(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
for _, u := range []string{"a", "b", "c"} {
|
|
if err := ecrire(t, s, Key("GET", u), "12345", 5); err != nil {
|
|
t.Fatalf("commit %s : %v", u, err)
|
|
}
|
|
}
|
|
st, err := s.Stat()
|
|
if err != nil {
|
|
t.Fatalf("stat : %v", err)
|
|
}
|
|
if st.Objects != 3 {
|
|
t.Errorf("%d objets, attendu 3", st.Objects)
|
|
}
|
|
if st.Bytes != 15 {
|
|
t.Errorf("%d octets, attendu 15", st.Bytes)
|
|
}
|
|
if st.Oldest.IsZero() {
|
|
t.Error("aucune date pour le plus ancien objet")
|
|
}
|
|
}
|
|
|
|
// Un répertoire de cache qui n'existe pas encore rend un état vide et non une
|
|
// erreur : « --status » doit répondre avant le premier démarrage.
|
|
func TestStatSansRepertoire(t *testing.T) {
|
|
s := &Store{Dir: filepath.Join(t.TempDir(), "jamais-cree")}
|
|
st, err := s.Stat()
|
|
if err != nil {
|
|
t.Fatalf("stat : %v", err)
|
|
}
|
|
if st.Objects != 0 || st.Bytes != 0 {
|
|
t.Errorf("état %+v, attendu vide", st)
|
|
}
|
|
}
|
|
|
|
func TestKeyDistingueMethode(t *testing.T) {
|
|
if Key("GET", "https://x/y") == Key("HEAD", "https://x/y") {
|
|
t.Error("GET et HEAD partagent une clé : un HEAD servirait un corps")
|
|
}
|
|
}
|
|
|
|
// Detient veut dire « tient un corps 200 ». Un statut seul n'en est pas un,
|
|
// quel que soit ce que son fichier de corps contient ; un méta sans statut,
|
|
// écrit avant que le magasin garde autre chose que des corps, en est un.
|
|
func TestDetientNeCompteQueLesCorps200(t *testing.T) {
|
|
s := &Store{Dir: t.TempDir()}
|
|
cas := []struct {
|
|
statut int
|
|
seul bool
|
|
attendu bool
|
|
}{
|
|
{http.StatusFound, false, false},
|
|
{http.StatusNotFound, false, false},
|
|
// Le 200 d'un HEAD : un statut seul malgré son code.
|
|
{http.StatusOK, true, false},
|
|
{http.StatusOK, false, true},
|
|
{0, false, true},
|
|
}
|
|
for i, c := range cas {
|
|
key := Key("GET", fmt.Sprintf("https://exemple.example/%d", i))
|
|
w, err := s.NewWriter(key, Meta{
|
|
URL: "x", Method: "GET", Status: c.statut, StatusOnly: c.seul,
|
|
})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
io.WriteString(w, "corps")
|
|
if err := w.Commit(5); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := s.Detient(key); got != c.attendu {
|
|
t.Errorf("statut %d : Detient %v, attendu %v", c.statut, got, c.attendu)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHumanBytes(t *testing.T) {
|
|
cas := map[int64]string{
|
|
512: "512 o",
|
|
2048: "2.0 Kio",
|
|
5 * 1024 * 1024: "5.0 Mio",
|
|
3 * 1024 * 1024 * 1024: "3.0 Gio",
|
|
}
|
|
for n, attendu := range cas {
|
|
if got := HumanBytes(n); got != attendu {
|
|
t.Errorf("%d octets rendus %q, attendu %q", n, got, attendu)
|
|
}
|
|
}
|
|
}
|