erplibre/script/qemu_cache/store.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

430 lines
14 KiB
Go

// © 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"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"time"
)
// Store garde les réponses sur disque, un objet valant deux fichiers : les
// métadonnées en JSON et le corps brut.
//
// Le corps ne passe jamais en mémoire : une image qcow2 pèse des gigaoctets,
// et un cache qui la charge pour la servir tue l'orchestrateur. Écriture par
// fichier temporaire puis renommage, si bien qu'un objet visible est toujours
// un objet complet — une interruption ne laisse qu'un temporaire, que le
// démarrage suivant balaie.
type Store struct {
Dir string
}
// Meta accompagne chaque corps stocké.
type Meta struct {
URL string `json:"url"`
Method string `json:"method"`
Status int `json:"status"`
Header http.Header `json:"header"`
Size int64 `json:"size"`
StoredAt time.Time `json:"stored_at"`
Class string `json:"class"`
// StatusOnly marque un objet gardé SANS corps, qui ne rejoue que son
// statut. Le statut seul ne suffit pas à le dire : le 200 d'un HEAD est
// un statut seul, et se lirait sinon comme un corps vide.
//
// Le marqueur décrit l'objet, il ne le protège pas : ce qui tient un
// lecteur plus ancien à l'écart d'un statut seul, c'est la clé sous
// laquelle il est rangé (voir CleStatut), pas un champ qu'un tel lecteur
// ignore.
StatusOnly bool `json:"status_only,omitempty"`
}
// StatutReel rend le statut gardé, 200 quand le méta n'en porte pas : un méta
// écrit avant que le magasin garde autre chose que des corps n'a pas de
// statut, et il décrivait toujours un 200.
func (m *Meta) StatutReel() int {
if m.Status == 0 {
return http.StatusOK
}
return m.Status
}
// StatutSeul dit si l'objet ne porte qu'un statut : marqué comme tel, ou de
// statut autre que 200 — seul un 200 a jamais été gardé avec son corps.
func (m *Meta) StatutSeul() bool {
return m.StatusOnly || m.StatutReel() != http.StatusOK
}
// Stats répond au besoin de surveillance manuelle : aucune éviction n'est
// écrite, donc l'outil doit au moins dire ce qu'il occupe.
type Stats struct {
Objects int
Bytes int64
Oldest time.Time
}
var errMiss = errors.New("cache: absent")
// Key mêle la méthode et l'URL : un HEAD et un GET sur la même adresse ne
// portent pas le même corps.
func Key(method, rawURL string) string {
sum := sha256.Sum256([]byte(method + " " + rawURL))
return hex.EncodeToString(sum[:])
}
// KeySansHote range un objet sous son CHEMIN, l'hôte écarté.
//
// Une liste de miroirs tourne : pacman tire un fichier de « fastly », le
// suivant de « geo », et une clé qui porte l'hôte réduit alors le cache à
// néant — même fichier, autre nom, défaut de cache. Le chemin, lui, identifie
// le fichier sur TOUS les miroirs d'une même distribution :
// « /core/os/x86_64/bash-5.3-1-x86_64.pkg.tar.zst » nomme le même octet
// partout, sa version et son architecture étant dans son nom.
//
// Réservé aux fichiers dont le NOM porte l'identité — paquets, index de
// dépôt. L'appliquer à tout ferait entrer en collision les « /index.html » de
// deux sites sans rapport.
func KeySansHote(method string, u *url.URL) string {
chemin := u.Path
if u.RawQuery != "" {
chemin += "?" + u.RawQuery
}
sum := sha256.Sum256([]byte(method + " path " + chemin))
return hex.EncodeToString(sum[:])
}
// CleDe rend la clé sous laquelle une réponse est rangée et cherchée.
//
// La clé écarte l'hôte quand le NOM du fichier l'identifie partout : une
// liste de miroirs tourne, et une clé qui porte l'hôte ferait manquer le
// cache au fichier déjà gardé sous un autre nom de miroir. Le service et la
// lecture « --detient » passent tous deux par ici : deux calculs de la clé
// finiraient par diverger, et le relevé dirait absent ce que le service sert.
func CleDe(method string, u *url.URL) string {
if PortableParChemin(u) {
return KeySansHote(method, u)
}
return Key(method, u.String())
}
// CleStatut rend la clé sous laquelle un STATUT SEUL est rangé et cherché.
//
// Un espace de clés à part, et non la clé du corps : un lecteur qui ne
// connaît que les corps ne lit que Key et KeySansHote, et ne tombe donc
// jamais sur un statut seul. Rangé sous la clé du corps, un tel objet — un
// méta 302 et un corps de zéro octet de la bonne taille — ressortirait chez
// lui en « 200 » vide, que « curl … | bash » exécuterait comme un script
// vide qui réussit.
//
// Aucune clé de corps ne peut la rejoindre : une méthode HTTP ne porte pas
// d'espace, si bien que « STATUT GET … » n'est la méthode d'aucune requête.
// L'URL entière, hôte compris : un statut seul ne se garde jamais sous une
// clé portable.
func CleStatut(method string, u *url.URL) string {
return Key("STATUT "+method, u.String())
}
// TientStatut dit si la clé porte un statut seul que le rejeu servirait : un
// méta lisible, marqué statut seul, dont le corps a la taille annoncée — les
// conditions mêmes auxquelles Get le rend.
func (s *Store) TientStatut(key string) bool {
m, err := s.LireMeta(key)
if err != nil || !m.StatutSeul() {
return false
}
_, bodyPath := s.paths(key)
fi, err := os.Stat(bodyPath)
return err == nil && fi.Size() == m.Size
}
func (s *Store) paths(key string) (metaPath, bodyPath string) {
// Deux niveaux de répertoires : un seul répertoire de cent mille entrées
// ralentit chaque ouverture sur la plupart des systèmes de fichiers.
dir := filepath.Join(s.Dir, key[0:2], key[2:4])
return filepath.Join(dir, key+".meta"), filepath.Join(dir, key+".body")
}
// LireMeta rend les métadonnées d'une clé sans toucher au corps.
//
// Get, lui, remet la date du corps à maintenant : un relevé qui passerait par
// lui rajeunirait tout ce qu'il regarde, et l'âge du dernier usage ne
// voudrait plus rien dire. Un méta illisible vaut absent.
func (s *Store) LireMeta(key string) (*Meta, error) {
metaPath, _ := s.paths(key)
raw, err := os.ReadFile(metaPath)
if err != nil {
return nil, errMiss
}
var m Meta
if err := json.Unmarshal(raw, &m); err != nil {
return nil, errMiss
}
return &m, nil
}
// Get rend les métadonnées et un lecteur positionné sur le corps. Le lecteur
// est à refermer par l'appelant.
func (s *Store) Get(key string) (*Meta, *os.File, error) {
metaPath, bodyPath := s.paths(key)
raw, err := os.ReadFile(metaPath)
if err != nil {
return nil, nil, errMiss
}
var m Meta
if err := json.Unmarshal(raw, &m); err != nil {
// Métadonnées illisibles : l'objet vaut absent plutôt que faux.
return nil, nil, errMiss
}
f, err := os.Open(bodyPath)
if err != nil {
return nil, nil, errMiss
}
// La date du corps est remise à MAINTENANT parce qu'on va le servir.
//
// C'est ce qui permet à un nettoyage par âge de vouloir dire « ce qui ne
// sert plus » et non « ce qui est entré il y a longtemps ». Un paquet
// servi tous les jours depuis un an n'est pas vieux : l'effacer
// obligerait à le retélécharger le lendemain, ce qui est exactement le
// contraire de ce qu'un cache est là pour faire.
//
// L'échec est ignoré : un magasin en lecture seule doit servir, pas
// refuser parce qu'il n'a pas pu noter une date.
maintenant := time.Now()
_ = os.Chtimes(bodyPath, maintenant, maintenant)
st, err := f.Stat()
if err != nil || st.Size() != m.Size {
// Un corps dont la taille ne correspond plus est une écriture
// interrompue par un moyen qui a contourné le renommage.
f.Close()
return nil, nil, errMiss
}
return &m, f, nil
}
// Writer accumule un corps dans un temporaire et ne le publie qu'à la
// fermeture réussie.
type Writer struct {
store *Store
key string
meta Meta
tmp *os.File
written int64
finished bool
}
// Detient dit si une clé porte le corps d'une réponse 200, sans l'ouvrir.
//
// Sert à décider si une requête conditionnelle peut partir telle quelle :
// sans corps en réserve, un « 304 » de l'amont ne laisserait rien à garder,
// et le cache resterait vide pour cette ressource aussi longtemps que ses
// clients en détiennent une copie — c'est-à-dire toujours.
//
// Un objet de statut seul ne compte pas : il vit sous sa propre clé
// (CleStatut), et un méta de statut qui se trouverait sous une clé de corps
// n'en fait pas un corps. La condition est alors retirée, pour que le premier
// 200 que l'amont rendra vienne entier. C'est aussi le test qui interdit de
// garder un refus quand un corps est en réserve : un statut seul ne s'écrit
// que là où Detient est faux pour la clé du corps.
func (s *Store) Detient(key string) bool {
m, err := s.LireMeta(key)
if err != nil || m.StatutSeul() {
return false
}
_, bodyPath := s.paths(key)
fi, err := os.Stat(bodyPath)
return err == nil && fi.Size() > 0
}
// NewWriter ouvre un temporaire dans le répertoire de destination : un
// renommage n'est atomique qu'au sein d'un même système de fichiers.
func (s *Store) NewWriter(key string, m Meta) (*Writer, error) {
metaPath, _ := s.paths(key)
if err := os.MkdirAll(filepath.Dir(metaPath), 0o755); err != nil {
return nil, err
}
tmp, err := os.CreateTemp(filepath.Dir(metaPath), key+".part-*")
if err != nil {
return nil, err
}
return &Writer{store: s, key: key, meta: m, tmp: tmp}, nil
}
func (w *Writer) Write(p []byte) (int, error) {
n, err := w.tmp.Write(p)
w.written += int64(n)
return n, err
}
// Commit publie l'objet. La taille annoncée par l'amont, quand il l'annonce,
// est vérifiée : une réponse tronquée par une coupure réseau ressemble à une
// réponse complète pour tout le reste du code.
func (w *Writer) Commit(expected int64) error {
defer w.cleanup()
if expected >= 0 && w.written != expected {
return fmt.Errorf("corps tronqué : %d octets sur %d", w.written, expected)
}
if err := w.tmp.Sync(); err != nil {
return err
}
if err := w.tmp.Close(); err != nil {
return err
}
w.meta.Size = w.written
w.meta.StoredAt = time.Now().UTC()
raw, err := json.Marshal(w.meta)
if err != nil {
return err
}
metaPath, bodyPath := w.store.paths(w.key)
if err := os.Rename(w.tmp.Name(), bodyPath); err != nil {
return err
}
// Les métadonnées en DERNIER : leur présence est ce qui rend l'objet
// visible, et un corps sans métadonnées est simplement ignoré.
if err := os.WriteFile(metaPath+".part", raw, 0o644); err != nil {
return err
}
w.finished = true
return os.Rename(metaPath+".part", metaPath)
}
// Abort jette le temporaire. Appelé quand le client se déconnecte ou que
// l'amont coupe : rien de partiel n'entre au cache.
func (w *Writer) Abort() {
w.cleanup()
}
func (w *Writer) cleanup() {
if w.finished {
return
}
name := w.tmp.Name()
w.tmp.Close()
os.Remove(name)
}
// Stat parcourt le cache. Coûteux sur un grand cache, donc appelé à la
// demande et non à chaque requête.
// horsCasier écarte, à la racine du magasin, tout répertoire qui n'est pas un
// casier à lui.
//
// Les objets sont rangés sous deux niveaux de deux caractères hexadécimaux.
// Ce qui vit à côté — les dépôts git tenus en miroir, par exemple — n'a rien à
// faire dans un parcours du magasin : le traverser coûterait un appel système
// par fichier de chaque dépôt, à chaque relevé et à chaque démarrage.
func horsCasier(racine, chemin string, info os.FileInfo) bool {
if !info.IsDir() || filepath.Dir(chemin) != filepath.Clean(racine) {
return false
}
nom := filepath.Base(chemin)
if len(nom) != 2 {
return true
}
for _, c := range nom {
if !strings.ContainsRune("0123456789abcdefABCDEF", c) {
return true
}
}
return false
}
func (s *Store) Stat() (Stats, error) {
var st Stats
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() {
return nil
}
if filepath.Ext(p) != ".body" {
return nil
}
st.Objects++
st.Bytes += info.Size()
if st.Oldest.IsZero() || info.ModTime().Before(st.Oldest) {
st.Oldest = info.ModTime()
}
return nil
})
if os.IsNotExist(err) {
return st, nil
}
return st, err
}
// SweepPartials retire ce qu'une interruption a laissé. Lancé au démarrage,
// jamais pendant le service : un « .part » y appartient à une écriture vivante.
func (s *Store) SweepPartials() int {
n := 0
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() {
return nil
}
name := filepath.Base(p)
if filepath.Ext(p) == ".part" || containsPart(name) {
if os.Remove(p) == nil {
n++
}
}
return nil
})
return n
}
func containsPart(name string) bool {
for i := 0; i+5 <= len(name); i++ {
if name[i:i+5] == ".part" {
return true
}
}
return false
}
// HumanBytes rend une taille lisible par un opérateur, la surveillance du
// disque étant manuelle.
func HumanBytes(n int64) string {
const unit = 1024
if n < unit {
return fmt.Sprintf("%d o", n)
}
units := []string{"Kio", "Mio", "Gio", "Tio"}
v := float64(n)
for _, u := range units {
v /= unit
if v < unit {
return fmt.Sprintf("%.1f %s", v, u)
}
}
return fmt.Sprintf("%.1f Pio", v/unit)
}
// copyTee écrit dans le cache ET vers le client en une seule lecture de
// l'amont : lire deux fois doublerait le trafic que l'outil existe pour
// supprimer.
func copyTee(dst io.Writer, cache io.Writer, src io.Reader) (int64, error) {
if cache == nil {
return io.Copy(dst, src)
}
return io.Copy(io.MultiWriter(dst, cache), src)
}