erplibre/script/qemu_cache/store.go

431 lines
14 KiB
Go
Raw Normal View History

// © 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(T("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(T("%d o"), n)
}
units := []string{T("Kio"), T("Mio"), T("Gio"), T("Tio")}
v := float64(n)
for _, u := range units {
v /= unit
if v < unit {
return fmt.Sprintf("%.1f %s", v, u)
}
}
return fmt.Sprintf(T("%.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)
}