diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index 7aa501a..9822728 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -135,16 +135,18 @@ var anglais = map[string]string{ "tunnel impossible pour %q : destination inconnue (%v)": "tunnel impossible for %q: unknown destination (%v)", "tunnel refusé pour %q : connexion non détournée, sa destination %s est cette écoute même": "tunnel refused for %q: connection not redirected, its destination %s is this very listener", "tunnel vers %s : %v ; %s est déchiffré à la place": "tunnel to %s: %v; %s is decrypted instead", - "tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest", - "tunnel vers %s : %v": "tunnel to %s: %v", - "ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)", - "longueur d'enregistrement invraisemblable : %d": "implausible record length: %d", - "la connexion n'est pas une socket TCP": "the connection is not a TCP socket", - "aucune destination d'origine : la connexion n'est pas redirigée": "no original destination: the connection is not redirected", - "interception transparente : Linux seulement": "transparent interception: Linux only", - "requête sans hôte : ni ligne absolue ni en-tête Host": "request without host: neither absolute line nor Host header", - "cache : écriture impossible pour %s : %v": "cache: cannot write %s: %v", - "cache : %s non gardé : %v": "cache: %s not kept: %v", + "ranger à nouveau les objets du magasin sous la clé courante, et fondre les copies qu'un miroir portait sous plusieurs chemins ; service arrêté, et --dry-run pour ne que compter": "store the objects again under the current key, and merge the copies a mirror carried under several paths; service stopped, and --dry-run to only count", + "re-clé : %v\n": "re-key: %v\n", + "tunnel vers %s refermé après %s : %s vers l'amont, %s vers l'invité": "tunnel to %s closed after %s: %s upstream, %s to the guest", + "tunnel vers %s : %v": "tunnel to %s: %v", + "ce n'est pas une poignée de main TLS (type %d)": "not a TLS handshake (type %d)", + "longueur d'enregistrement invraisemblable : %d": "implausible record length: %d", + "la connexion n'est pas une socket TCP": "the connection is not a TCP socket", + "aucune destination d'origine : la connexion n'est pas redirigée": "no original destination: the connection is not redirected", + "interception transparente : Linux seulement": "transparent interception: Linux only", + "requête sans hôte : ni ligne absolue ni en-tête Host": "request without host: neither absolute line nor Host header", + "cache : écriture impossible pour %s : %v": "cache: cannot write %s: %v", + "cache : %s non gardé : %v": "cache: %s not kept: %v", "erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n demandé : %s\n classe : %s\n cause : %v\n%sCe fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\ndéployer une VM identique à celle qui a rempli le cache.\n": "erplibre_go_qemu_cache: upstream unreachable and nothing in store.\n requested: %s\n class : %s\n cause : %v\n%sThis file never crossed this cache. Restore the network, or\ndeploy a VM identical to the one that filled the cache.\n", "hors ligne, absent du cache : %s": "offline, missing from the cache: %s", "corps tronqué : %d octets sur %d": "truncated body: %d bytes of %d", diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index f770e11..e23b32f 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.14" +const version = "0.2.15" func main() { var ( @@ -95,6 +95,10 @@ func main() { " seul, sans corps), absent ou non-cachable. Lecture seule :"+ " --cache-dir suffit, sans privilège, et l'âge des objets"+ " n'est pas touché")) + recle = flag.Bool("recle", false, + T("ranger à nouveau les objets du magasin sous la clé courante,"+ + " et fondre les copies qu'un miroir portait sous plusieurs"+ + " chemins ; service arrêté, et --dry-run pour ne que compter")) oublie = flag.Bool("oublie", false, T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+ " EFFACER du magasin ce que chacune désigne : le corps ET le"+ @@ -137,6 +141,21 @@ func main() { } return } + // Même endroit, même raison : la passe ne touche que le magasin, et le + // service ne doit pas tourner pendant qu'elle renomme ce qu'il sert. + if *recle { + store := &Store{Dir: *cacheDir} + bilan, err := store.Recler(*dryRun) + if err != nil { + fmt.Fprintf(os.Stderr, T("re-clé : %v\n"), err) + os.Exit(1) + } + if *dryRun { + fmt.Printf("%s", T("[à blanc] ")) + } + fmt.Println(bilan.Ligne()) + return + } bypass := BypassFile{Path: *bypassFile} // Les exceptions entrent dans les règles dès leur RENDU : le service les diff --git a/script/qemu_cache/recle.go b/script/qemu_cache/recle.go new file mode 100644 index 0000000..3b0f741 --- /dev/null +++ b/script/qemu_cache/recle.go @@ -0,0 +1,185 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// « --recle » range à nouveau les objets sous la clé que le service calcule +// AUJOURD'HUI. +// +// LE MANQUE QU'IL COMBLE. La clé d'un fichier portable a changé : seule la fin +// du chemin compte désormais (voir SegmentsDeCle), là où le chemin entier +// comptait. Les objets écrits sous l'ancienne règle restent sur le disque mais +// deviennent introuvables : le service les redemande à l'amont, et l'espace +// qu'ils occupent ne sert plus personne. La passe les range sous la clé +// courante, sans rien retélécharger. +// +// ELLE FOND AUSSI LES COPIES. Un même fichier vivait sous plusieurs chemins de +// miroir, donc sous plusieurs clés ; la clé courante les réunit, et deux +// objets tombent alors au même endroit. Le plus récemment rangé est gardé — +// les octets sont les mêmes, seule la date de service les sépare — et l'autre +// rend sa place. +// +// CE QU'ELLE NE TOUCHE PAS. Un statut seul est rangé sous CleStatut, qui hache +// l'URL ENTIÈRE : la règle du chemin ne l'atteint pas, et recalculer sa clé +// avec CleDe la déplacerait là où personne ne la lit. +// +// QUAND LA LANCER. Le service doit être arrêté : la passe renomme des fichiers +// qu'il sert, et un objet déplacé entre la lecture du méta et celle du corps +// ressort en défaut de cache. Rien n'est perdu dans ce cas, mais la mesure +// d'une campagne qui tournerait en même temps ne voudrait rien dire. + +// Reclassement compte ce qu'une passe a fait. +type Reclassement struct { + Lus int + Deplaces int + Fondus int + Inchanges int + Illisibles int + Refus int + // OctetsRendus : ce que les copies fondues rendent au disque. + OctetsRendus int64 +} + +// Ligne rend le compte en une ligne lisible. +func (r Reclassement) Ligne() string { + return fmt.Sprintf( + "lus %d, déplacés %d, fondus %d (%s rendus), inchangés %d,"+ + " illisibles %d, refus %d", + r.Lus, r.Deplaces, r.Fondus, HumanBytes(r.OctetsRendus), + r.Inchanges, r.Illisibles, r.Refus) +} + +// Recler range chaque objet sous la clé courante. À blanc, rien n'est écrit et +// le compte dit ce qui bougerait. +func (s *Store) Recler(aBlanc bool) (Reclassement, error) { + var r Reclassement + // La liste est faite AVANT de renommer : déplacer un fichier sous un + // répertoire que le parcours n'a pas encore visité le lui ferait + // rencontrer deux fois. + var metas []string + err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error { + if err != nil || info == nil { + return nil + } + if horsCasier(s.Dir, p, info) { + return filepath.SkipDir + } + if !info.IsDir() && filepath.Ext(p) == ".meta" { + metas = append(metas, p) + } + return nil + }) + if err != nil && !os.IsNotExist(err) { + return r, err + } + + for _, chemin := range metas { + r.Lus++ + ancienne := strings.TrimSuffix(filepath.Base(chemin), ".meta") + m, err := s.LireMeta(ancienne) + if err != nil { + r.Illisibles++ + continue + } + // Un statut seul vit sous une clé qui porte l'hôte : la règle du + // chemin ne le concerne pas. + if m.StatutSeul() { + r.Inchanges++ + continue + } + u, err := url.Parse(m.URL) + if err != nil || !u.IsAbs() || u.Host == "" { + r.Illisibles++ + continue + } + nouvelle := CleDe(m.Method, u) + if nouvelle == ancienne { + r.Inchanges++ + continue + } + s.deplacer(&r, ancienne, nouvelle, m, aBlanc) + } + return r, nil +} + +// deplacer range un objet sous sa nouvelle clé, ou le fond dans la copie qui +// s'y trouve déjà. +// +// Le CORPS part en premier et le méta ensuite : c'est l'ordre qui publie un +// objet, et l'interrompre laisse au pire un corps sans méta — que le magasin +// ignore — plutôt qu'un méta qui promet un corps absent. +func (s *Store) deplacer( + r *Reclassement, ancienne, nouvelle string, m *Meta, aBlanc bool, +) { + ancienMeta, ancienCorps := s.paths(ancienne) + nouveauMeta, nouveauCorps := s.paths(nouvelle) + + if autre, err := s.LireMeta(nouvelle); err == nil { + // Deux chemins de miroir pour le même fichier. Le plus récemment + // rangé reste : les octets sont les mêmes, seule leur date diffère. + garde, rendus := autre.StoredAt, m.Size + if m.StoredAt.After(garde) { + rendus = autre.Size + if !aBlanc { + if err := s.retirerPaire(nouveauMeta, nouveauCorps); err != nil { + r.Refus++ + return + } + if err := s.renommerPaire( + ancienMeta, ancienCorps, nouveauMeta, nouveauCorps, + ); err != nil { + r.Refus++ + return + } + } + r.Deplaces++ + } else if !aBlanc { + if err := s.retirerPaire(ancienMeta, ancienCorps); err != nil { + r.Refus++ + return + } + } + r.Fondus++ + r.OctetsRendus += rendus + return + } + + if !aBlanc { + if err := os.MkdirAll(filepath.Dir(nouveauMeta), 0o755); err != nil { + r.Refus++ + return + } + if err := s.renommerPaire( + ancienMeta, ancienCorps, nouveauMeta, nouveauCorps, + ); err != nil { + r.Refus++ + return + } + } + r.Deplaces++ +} + +func (s *Store) renommerPaire(ancienMeta, ancienCorps, nouveauMeta, nouveauCorps string) error { + if err := os.Rename(ancienCorps, nouveauCorps); err != nil { + return err + } + return os.Rename(ancienMeta, nouveauMeta) +} + +func (s *Store) retirerPaire(meta, corps string) error { + if err := os.Remove(corps); err != nil && !os.IsNotExist(err) { + return err + } + if err := os.Remove(meta); err != nil && !os.IsNotExist(err) { + return err + } + return nil +} diff --git a/script/qemu_cache/recle_test.go b/script/qemu_cache/recle_test.go new file mode 100644 index 0000000..fc7b128 --- /dev/null +++ b/script/qemu_cache/recle_test.go @@ -0,0 +1,231 @@ +// © 2026 TechnoLibre (http://www.technolibre.ca) +// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +package main + +import ( + "crypto/sha256" + "encoding/hex" + "io" + "net/url" + "os" + "path/filepath" + "strings" + "testing" +) + +// ancienneCle reproduit la règle d'AVANT : le chemin entier, tel que le +// magasin le hachait jusqu'à la normalisation. Les objets déjà rangés sur un +// disque portent cette clé-là, et c'est d'eux que la passe s'occupe. +func ancienneCle(methode string, u *url.URL) string { + sum := sha256.Sum256([]byte(methode + " path " + u.Path)) + return hex.EncodeToString(sum[:]) +} + +// planter écrit un objet sous la clé donnée, comme le magasin l'aurait fait. +func planter(t *testing.T, s *Store, cle, brut, corps string) { + t.Helper() + w, err := s.NewWriter(cle, Meta{ + URL: brut, Method: "GET", Status: 200, Class: "immutable", + }) + if err != nil { + t.Fatalf("écriture de %s : %v", brut, err) + } + if _, err := w.Write([]byte(corps)); err != nil { + t.Fatal(err) + } + if err := w.Commit(int64(len(corps))); err != nil { + t.Fatalf("publication de %s : %v", brut, err) + } +} + +const ( + rockyA = "https://a.example/rocky/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm" + rockyB = "https://b.example/mirror/rocky-linux/10.2/AppStream/x86_64/os/Packages/r/rust-1.92.0-2.el10_2.x86_64.rpm" +) + +// Un objet rangé sous l'ancienne clé est introuvable : le service le +// redemande à l'amont alors qu'il est sur le disque. La passe le range sous la +// clé courante, sans rien retélécharger. +func TestReclerRendTrouvableUnObjetAncien(t *testing.T) { + s := &Store{Dir: t.TempDir()} + u, _ := url.Parse(rockyA) + planter(t, s, ancienneCle("GET", u), rockyA, "le paquet") + + if s.Detient(CleDe("GET", u)) { + t.Fatal("l'objet est déjà trouvable : le test ne mesure rien") + } + bilan, err := s.Recler(false) + if err != nil { + t.Fatal(err) + } + if bilan.Deplaces != 1 || bilan.Fondus != 0 { + t.Errorf("bilan %+v, attendu un seul déplacement", bilan) + } + if !s.Detient(CleDe("GET", u)) { + t.Error("l'objet reste introuvable après la passe") + } + m, f, err := s.Get(CleDe("GET", u)) + if err != nil { + t.Fatalf("lecture après passe : %v", err) + } + defer f.Close() + if m.URL != rockyA { + t.Errorf("méta perdu en route : %q", m.URL) + } +} + +// Deux chemins de miroir pour le même fichier : la clé courante les réunit, et +// la passe n'en garde qu'un — le plus récemment rangé. +// +// Les deux DIRECTIONS sont éprouvées, et l'ordre est fixé en plantant d'abord +// l'objet de destination : laisser le parcours décider laquelle des deux +// copies arrive la première ne couvrait qu'une branche sur deux, au gré du +// hachage, et la branche non couverte est passée au travers d'une mutation. +func TestReclerFondLesCopiesDeMiroir(t *testing.T) { + ua, _ := url.Parse(rockyA) + + t.Run("entrante plus ancienne", func(t *testing.T) { + s := &Store{Dir: t.TempDir()} + planter(t, s, ancienneCle("GET", ua), rockyA, "copie ancienne") + planter(t, s, CleDe("GET", ua), rockyB, "copie en place, plus recente") + + bilan, err := s.Recler(false) + if err != nil { + t.Fatal(err) + } + if bilan.Fondus != 1 || bilan.OctetsRendus == 0 { + t.Errorf("bilan %+v, attendu une fusion qui rend des octets", bilan) + } + if corps := corpsDuMagasin(t, s); corps != 1 { + t.Errorf("%d corps sur le disque, attendu 1", corps) + } + if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie en place, plus recente" { + t.Errorf("la copie gardée est %q : la plus récente devait rester", lu) + } + }) + + t.Run("entrante plus récente", func(t *testing.T) { + s := &Store{Dir: t.TempDir()} + planter(t, s, CleDe("GET", ua), rockyB, "copie en place, ancienne") + planter(t, s, ancienneCle("GET", ua), rockyA, "copie entrante, recente") + + bilan, err := s.Recler(false) + if err != nil { + t.Fatal(err) + } + if bilan.Fondus != 1 || bilan.Deplaces != 1 { + t.Errorf("bilan %+v, attendu une fusion et un déplacement", bilan) + } + if corps := corpsDuMagasin(t, s); corps != 1 { + t.Errorf("%d corps sur le disque, attendu 1", corps) + } + if lu := lireCorps(t, s, CleDe("GET", ua)); lu != "copie entrante, recente" { + t.Errorf("la copie gardée est %q : la plus récente devait rester", lu) + } + }) +} + +// corpsDuMagasin compte les corps réellement sur le disque : une copie qui +// n'est plus référencée mais reste écrite occupe la place qu'on croyait rendue. +func corpsDuMagasin(t *testing.T, s *Store) int { + t.Helper() + n := 0 + err := filepath.Walk(s.Dir, func(p string, info os.FileInfo, err error) error { + if err == nil && info != nil && !info.IsDir() && + strings.HasSuffix(p, ".body") { + n++ + } + return nil + }) + if err != nil { + t.Fatal(err) + } + return n +} + +func lireCorps(t *testing.T, s *Store, cle string) string { + t.Helper() + _, f, err := s.Get(cle) + if err != nil { + t.Fatalf("lecture de %s : %v", cle, err) + } + defer f.Close() + b, err := io.ReadAll(f) + if err != nil { + t.Fatal(err) + } + return string(b) +} + +// Un chemin court tient tout entier dans la clé : rien ne doit bouger. +func TestReclerNeTouchePasUnCheminCourt(t *testing.T) { + s := &Store{Dir: t.TempDir()} + brut := "https://a.example/core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst" + u, _ := url.Parse(brut) + planter(t, s, CleDe("GET", u), brut, "paquet arch") + + bilan, err := s.Recler(false) + if err != nil { + t.Fatal(err) + } + if bilan.Deplaces != 0 || bilan.Inchanges != 1 { + t.Errorf("bilan %+v, attendu un objet inchangé", bilan) + } + if !s.Detient(CleDe("GET", u)) { + t.Error("un objet qui ne devait pas bouger a disparu") + } +} + +// Un statut seul est rangé sous une clé qui porte l'HÔTE : la règle du chemin +// ne le concerne pas, et le déplacer l'enverrait là où personne ne le lit. +func TestReclerLaisseLesStatutsSeuls(t *testing.T) { + s := &Store{Dir: t.TempDir()} + brut := "https://a.example/depot/releases/latest/download/outil.tar.gz" + u, _ := url.Parse(brut) + cle := CleStatut("GET", u) + w, err := s.NewWriter(cle, Meta{ + URL: brut, Method: "GET", Status: 302, Class: "volatile", + StatusOnly: true, + }) + if err != nil { + t.Fatal(err) + } + if err := w.Commit(0); err != nil { + t.Fatal(err) + } + + bilan, err := s.Recler(false) + if err != nil { + t.Fatal(err) + } + if bilan.Deplaces != 0 { + t.Errorf("bilan %+v : un statut seul a été déplacé", bilan) + } + if _, err := s.LireMeta(cle); err != nil { + t.Error("le statut seul n'est plus sous sa clé") + } +} + +// À blanc, la passe compte sans rien écrire : c'est ce qui permet de la lancer +// avant de décider. +func TestReclerABlancNeDeplaceRien(t *testing.T) { + s := &Store{Dir: t.TempDir()} + u, _ := url.Parse(rockyA) + ancienne := ancienneCle("GET", u) + planter(t, s, ancienne, rockyA, "le paquet") + + bilan, err := s.Recler(true) + if err != nil { + t.Fatal(err) + } + if bilan.Deplaces != 1 { + t.Errorf("bilan %+v, attendu un déplacement annoncé", bilan) + } + if _, err := s.LireMeta(ancienne); err != nil { + t.Error("l'objet a bougé alors que la passe était à blanc") + } + if s.Detient(CleDe("GET", u)) { + t.Error("un objet est apparu sous la clé courante, à blanc") + } +}