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

287 lines
8.1 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"
"sort"
"strings"
"time"
)
// Le cache ne diminue jamais de lui-même — c'est assumé, et la place se
// surveille à la main. Encore faut-il pouvoir la surveiller : savoir CE QUI
// occupe, et depuis QUAND, avant de décider ce qui peut partir.
//
// L'âge retenu est la date du dernier USAGE et non celle du stockage. Un
// paquet servi tous les jours depuis un an n'est pas vieux : le supprimer
// obligerait à le retélécharger le lendemain. C'est ce qu'un opérateur veut
// dire par « nettoyer ce qui ne sert plus », et c'est pour cela que le service
// remet la date d'un objet à chaque fois qu'il le sert.
// Tranche est un groupe d'objets d'un même âge.
type Tranche struct {
// Debut est le premier instant de la tranche ; Libelle la nomme.
Debut time.Time
Libelle string
Objets int
Octets int64
}
// Granularite dit comment les tranches sont découpées.
type Granularite string
const (
ParJour Granularite = "jour"
ParSemaine Granularite = "semaine"
ParMois Granularite = "mois"
)
// LireGranularite tolère l'anglais comme le français : le CLI est bilingue
// partout ailleurs dans ce dépôt.
func LireGranularite(s string) (Granularite, error) {
switch strings.ToLower(strings.TrimSpace(s)) {
case "jour", "day", "j", "d":
return ParJour, nil
case "semaine", "week", "s", "w":
return ParSemaine, nil
case "mois", "month", "m":
return ParMois, nil
}
return "", fmt.Errorf("granularité inconnue %q : jour, semaine ou mois", s)
}
// debutDeTranche ramène un instant au premier de sa tranche.
//
// La semaine commence le LUNDI : c'est la convention ISO, et celle que
// « %V » et les outils du système emploient. Ramener au dimanche donnerait des
// tranches qui ne correspondent à aucun autre relevé de la machine.
func debutDeTranche(t time.Time, g Granularite) time.Time {
t = t.Local()
jour := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
switch g {
case ParSemaine:
recul := (int(jour.Weekday()) + 6) % 7
return jour.AddDate(0, 0, -recul)
case ParMois:
return time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
}
return jour
}
func libelleDeTranche(t time.Time, g Granularite) string {
switch g {
case ParSemaine:
an, sem := t.ISOWeek()
return fmt.Sprintf("%d-S%02d", an, sem)
case ParMois:
return t.Format("2006-01")
}
return t.Format("2006-01-02")
}
// TranchesStore groupe les objets du magasin par âge, du plus récent au plus
// ancien.
func (s *Store) Tranches(g Granularite) []Tranche {
par := map[time.Time]*Tranche{}
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) != ".body" {
return nil
}
d := debutDeTranche(info.ModTime(), g)
tr, ok := par[d]
if !ok {
tr = &Tranche{Debut: d, Libelle: libelleDeTranche(d, g)}
par[d] = tr
}
tr.Objets++
tr.Octets += info.Size()
return nil
})
return trier(par)
}
// TranchesMiroirs fait de même pour les dépôts git.
//
// Un dépôt entier par tranche, pas ses fichiers : ce qui s'efface est un
// dépôt, et le compter au fichier donnerait un relevé qu'on ne peut pas
// suivre d'un geste.
func (g *GitMirror) Tranches(gran Granularite) []Tranche {
if g == nil || g.Dir == "" {
return nil
}
par := map[time.Time]*Tranche{}
for _, d := range g.Depots() {
debut := debutDeTranche(d.Maj, gran)
tr, ok := par[debut]
if !ok {
tr = &Tranche{Debut: debut, Libelle: libelleDeTranche(debut, gran)}
par[debut] = tr
}
tr.Objets++
tr.Octets += d.Octets
}
return trier(par)
}
func trier(par map[time.Time]*Tranche) []Tranche {
out := make([]Tranche, 0, len(par))
for _, tr := range par {
out = append(out, *tr)
}
sort.Slice(out, func(i, j int) bool {
return out[i].Debut.After(out[j].Debut)
})
return out
}
// Purger efface les objets dont le dernier usage précède `avant`.
//
// 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, c'est-à-dire au pire
// moment. Rend (objets effacés, octets rendus).
func (s *Store) Purger(avant time.Time) (int, int64, error) {
n, octets := 0, int64(0)
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) != ".body" {
return nil
}
if !info.ModTime().Before(avant) {
return nil
}
taille := info.Size()
if e := os.Remove(p); e != nil {
return nil
}
os.Remove(strings.TrimSuffix(p, ".body") + ".meta")
n++
octets += taille
return nil
})
return n, octets, err
}
// PurgerMiroirs efface les dépôts qui n'ont pas été rafraîchis depuis
// `avant`. Rend (dépôts effacés, octets rendus).
func (g *GitMirror) PurgerMiroirs(avant time.Time) (int, int64, error) {
if g == nil || g.Dir == "" {
return 0, 0, nil
}
n, octets := 0, int64(0)
for _, d := range g.Depots() {
if !d.Maj.Before(avant) {
continue
}
if err := g.Retirer(d.Chemin); err != nil {
return n, octets, err
}
n++
octets += d.Octets
}
return n, octets, nil
}
// LireDuree accepte les suffixes de Go et, en plus, « j » pour un jour.
//
// « 30d » n'existe pas dans la bibliothèque, et un opérateur qui nettoie
// raisonne en jours, pas en sept cent vingt heures.
func LireDuree(s string) (time.Duration, error) {
s = strings.ToLower(strings.TrimSpace(s))
if s == "" {
return 0, fmt.Errorf("durée vide")
}
if n, unite := s[:len(s)-1], s[len(s)-1]; unite == 'j' || unite == 'd' {
var jours float64
if _, err := fmt.Sscanf(n, "%g", &jours); err != nil || jours < 0 {
return 0, fmt.Errorf("durée illisible %q", s)
}
return time.Duration(jours * 24 * float64(time.Hour)), nil
}
d, err := time.ParseDuration(s)
if err != nil || d < 0 {
return 0, fmt.Errorf(
"durée illisible %q : essayer 30j, 12h, 90m", s)
}
return d, nil
}
// printAge écrit le relevé par âge : les objets, puis les dépôts.
//
// Les deux SÉPARÉMENT : ils ne s'effacent pas de la même façon — un objet est
// un fichier, un dépôt est un arbre — et un total commun cacherait qu'un seul
// dépôt pèse plus que mille objets.
func printAge(store *Store, miroir *GitMirror, gran Granularite) {
fmt.Printf("Âge du dernier usage, par %s.\n", gran)
fmt.Printf(
"\nUn objet servi voit sa date remise à jour : « vieux » veut donc " +
"dire\n« n'a plus servi », et non « est entré il y a " +
"longtemps ».\n")
ecrire := func(titre string, tranches []Tranche, quoi string) {
fmt.Printf("\n%s\n", titre)
if len(tranches) == 0 {
fmt.Println(" rien")
return
}
var n int
var octets int64
for _, tr := range tranches {
fmt.Printf(" %-12s %6d %-8s %10s\n",
tr.Libelle, tr.Objets, quoi, HumanBytes(tr.Octets))
n += tr.Objets
octets += tr.Octets
}
fmt.Printf(" %-12s %6d %-8s %10s\n", "total", n, quoi,
HumanBytes(octets))
}
ecrire("Objets du cache :", store.Tranches(gran), "objets")
if miroir.Actif() {
ecrire("Dépôts en miroir :", miroir.Tranches(gran), "dépôts")
}
}
// printPurgeABlanc dit ce qu'une purge emporterait, sans rien effacer.
//
// Une purge ne se rattrape pas — les octets sont rendus, il faut les
// retélécharger — et c'est le genre de geste qu'on veut relire avant.
func printPurgeABlanc(store *Store, miroir *GitMirror, avant time.Time) {
var n int
var octets int64
for _, tr := range store.Tranches(ParJour) {
if tr.Debut.Before(avant) {
n += tr.Objets
octets += tr.Octets
}
}
fmt.Printf("[à blanc] objets qui partiraient : %d, %s\n",
n, HumanBytes(octets))
var nd int
var octd int64
for _, d := range miroir.Depots() {
if d.Maj.Before(avant) {
nd++
octd += d.Octets
}
}
fmt.Printf("[à blanc] dépôts qui partiraient : %d, %s\n",
nd, HumanBytes(octd))
fmt.Println("[à blanc] rien n'a été effacé.")
}