erplibre/script/qemu_cache/age_test.go
Mathieu Benoit a7706adf3d [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 16:46:15 -04:00

204 lines
6.2 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
package main
import (
"fmt"
"os"
"path/filepath"
"testing"
"time"
)
func TestLireGranularite(t *testing.T) {
for _, cas := range []struct {
brut string
attendue Granularite
}{
{"jour", ParJour}, {"day", ParJour}, {"J", ParJour},
{"semaine", ParSemaine}, {"week", ParSemaine}, {" W ", ParSemaine},
{"mois", ParMois}, {"month", ParMois}, {"m", ParMois},
} {
g, err := LireGranularite(cas.brut)
if err != nil || g != cas.attendue {
t.Errorf("%q → %q, %v", cas.brut, g, err)
}
}
if _, err := LireGranularite("trimestre"); err == nil {
t.Error("une granularité inconnue est acceptée")
}
}
// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie
// raisonne en jours, pas en sept cent vingt heures.
func TestLireDuree(t *testing.T) {
for _, cas := range []struct {
brut string
attendue time.Duration
}{
{"30j", 30 * 24 * time.Hour},
{"7d", 7 * 24 * time.Hour},
{"12h", 12 * time.Hour},
{"90m", 90 * time.Minute},
{"0.5j", 12 * time.Hour},
} {
d, err := LireDuree(cas.brut)
if err != nil || d != cas.attendue {
t.Errorf("%q → %v, %v (attendu %v)", cas.brut, d, err, cas.attendue)
}
}
for _, brut := range []string{"", "3lunes", "-5j", "abc"} {
if d, err := LireDuree(brut); err == nil {
t.Errorf("%q accepté et rendu %v", brut, d)
}
}
}
// La semaine commence le LUNDI : c'est la convention ISO, celle des outils du
// système. La ramener au dimanche donnerait des tranches qui ne correspondent
// à aucun autre relevé de la machine.
func TestLaSemaineCommenceLundi(t *testing.T) {
// Un mercredi, et le dimanche qui le suit.
mercredi := time.Date(2026, 9, 2, 15, 0, 0, 0, time.Local)
dimanche := time.Date(2026, 9, 6, 15, 0, 0, 0, time.Local)
a := debutDeTranche(mercredi, ParSemaine)
b := debutDeTranche(dimanche, ParSemaine)
if !a.Equal(b) {
t.Errorf("mercredi et le dimanche suivant tombent dans deux"+
" semaines : %s contre %s", a, b)
}
if a.Weekday() != time.Monday {
t.Errorf("la semaine commence un %s", a.Weekday())
}
}
// magasinDate pose un objet du magasin avec une date choisie.
func magasinDate(t *testing.T, s *Store, cle string, quand time.Time, n int) {
t.Helper()
meta, corps := s.paths(cle)
if err := os.MkdirAll(filepath.Dir(corps), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(corps, make([]byte, n), 0o644); err != nil {
t.Fatal(err)
}
// La taille est écrite dans le méta : le magasin refuse un corps dont
// elle ne correspond plus, y voyant une écriture interrompue.
if err := os.WriteFile(
meta, []byte(fmt.Sprintf(`{"size":%d}`, n)), 0o644,
); err != nil {
t.Fatal(err)
}
if err := os.Chtimes(corps, quand, quand); err != nil {
t.Fatal(err)
}
}
func TestTranchesGroupentParAge(t *testing.T) {
s := &Store{Dir: t.TempDir()}
maintenant := time.Now()
magasinDate(t, s, "aaaa1111", maintenant, 100)
magasinDate(t, s, "bbbb2222", maintenant, 200)
magasinDate(t, s, "cccc3333", maintenant.AddDate(0, 0, -40), 300)
jours := s.Tranches(ParJour)
if len(jours) != 2 {
t.Fatalf("%d tranches par jour : %+v", len(jours), jours)
}
// Du plus récent au plus ancien : c'est l'ordre de lecture.
if !jours[0].Debut.After(jours[1].Debut) {
t.Error("les tranches ne descendent pas dans le temps")
}
if jours[0].Objets != 2 || jours[0].Octets != 300 {
t.Errorf("tranche du jour : %+v", jours[0])
}
if n := len(s.Tranches(ParMois)); n != 2 {
t.Errorf("%d tranches par mois, deux attendues", n)
}
}
// Le méta part AVEC le corps : un méta orphelin ferait croire à une copie
// présente, et la lecture échouerait au moment de servir.
func TestPurgerEmporteLeMetaAvecLeCorps(t *testing.T) {
s := &Store{Dir: t.TempDir()}
vieux := time.Now().AddDate(0, 0, -40)
magasinDate(t, s, "aaaa1111", vieux, 500)
magasinDate(t, s, "bbbb2222", time.Now(), 700)
n, octets, err := s.Purger(time.Now().AddDate(0, 0, -30))
if err != nil {
t.Fatal(err)
}
if n != 1 || octets != 500 {
t.Fatalf("purge : %d objets, %d octets", n, octets)
}
meta, corps := s.paths("aaaa1111")
for _, f := range []string{meta, corps} {
if _, err := os.Stat(f); !os.IsNotExist(err) {
t.Errorf("%s subsiste", filepath.Base(f))
}
}
// Et le récent est INTACT : une purge par âge ne touche pas au reste.
if _, f, err := s.Get("bbbb2222"); err != nil {
t.Errorf("l'objet récent a été emporté : %v", err)
} else {
f.Close()
}
}
func TestPurgerToutNeLaisseRien(t *testing.T) {
s := &Store{Dir: t.TempDir()}
for i, cle := range []string{"aaaa1111", "bbbb2222", "cccc3333"} {
magasinDate(t, s, cle, time.Now().AddDate(0, 0, -i), 100)
}
if n, _, err := s.Purger(time.Now().Add(time.Minute)); err != nil ||
n != 3 {
t.Fatalf("purge totale : %d objets, %v", n, err)
}
if st, err := s.Stat(); err != nil || st.Objects != 0 {
t.Errorf("il reste %d objets", st.Objects)
}
}
// L'âge d'un objet est celui de son dernier USAGE, pas de son entrée.
//
// Sans cela, « nettoyer ce qui ne sert plus » emporterait un paquet servi tous
// les jours depuis un an, et il faudrait le retélécharger le lendemain — le
// contraire de ce qu'un cache est là pour faire.
func TestServirRajeunitUnObjet(t *testing.T) {
a := nouvelAmont(t, "charge utile")
p := proxyDeTest(t)
chemin := "/arch/core/os/x86_64/bash-5.2-1-x86_64.pkg.tar.zst"
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("premier appel : %d", w.Code)
}
// L'objet est vieilli à la main, comme s'il dormait depuis quarante jours.
var corps string
filepath.Walk(p.Store.Dir, func(q string, i os.FileInfo, e error) error {
if e == nil && i != nil && filepath.Ext(q) == ".body" {
corps = q
}
return nil
})
if corps == "" {
t.Fatal("aucun objet gardé")
}
vieux := time.Now().AddDate(0, 0, -40)
if err := os.Chtimes(corps, vieux, vieux); err != nil {
t.Fatal(err)
}
if w := demande(t, p, a.hote(), chemin); w.Code != 200 {
t.Fatalf("second appel : %d", w.Code)
}
info, err := os.Stat(corps)
if err != nil {
t.Fatal(err)
}
if time.Since(info.ModTime()) > time.Minute {
t.Errorf("l'objet servi garde sa vieille date (%s) : un nettoyage"+
" par âge l'emporterait alors qu'il sert", info.ModTime())
}
}