erplibre/script/qemu_cache/main.go
Mathieu Benoit ab57a49d22 [ADD] cache qemu : ranger à nouveau le magasin sous la clé courante
La règle de clé d'un fichier portable a changé : seule la fin du chemin compte
désormais. Les objets écrits sous l'ancienne règle restent sur le disque mais
deviennent introuvables — le service les redemande à l'amont, et la place
qu'ils tiennent ne sert plus personne. Sur un magasin de 12 764 objets, 5 419
étaient dans ce cas, soit 9,11 Gio. La passe les range sous la clé courante
sans rien retélécharger, et fond au passage les copies qu'un miroir portait
sous plusieurs chemins — 1 422 groupes, environ 3,37 Gio rendus. Un statut
seul n'est pas touché : sa clé porte l'hôte. Vérifié : cinq tests, trois
mutations attrapées.

--- EN ---

The key rule for a portable file changed: only the end of the path now counts.
Objects written under the old rule stay on disk but become unreachable — the
service asks upstream again, and the space they hold serves no one. On a store
of 12 764 objects, 5 419 were in that case, that is 9.11 GiB. The pass stores
them again under the current key without downloading anything, and merges on
the way the copies a mirror carried under several paths — 1 422 groups, about
3.37 GiB returned. A status-only entry is left alone: its key carries the
host. Checked: five tests, three mutations caught.

Assisted-by: Claude Opus 5
2026-09-21 05:39:38 -04:00

535 lines
18 KiB
Go

// © 2026 TechnoLibre (http://www.technolibre.ca)
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
// erplibre_go_qemu_cache — miroir de téléchargement pour les VM QEMU locales.
//
// L'orchestrateur détourne le 80 et le 443 de ses VM vers cet outil, qui
// garde ce qui descend et sert la copie à la VM suivante. Deux VM de la même
// distribution ne tirent donc qu'une fois les mêmes paquets.
//
// Ce que l'outil NE fait pas, et qu'il faut savoir : il n'efface rien. Aucune
// éviction, aucun plafond de disque — « --status » dit ce qu'il occupe, la
// surveillance est manuelle et le cache vit sur le disque de l'orchestrateur.
package main
import (
"context"
"flag"
"fmt"
"log"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
const version = "0.2.15"
func main() {
var (
cacheDir = flag.String("cache-dir", "/var/cache/erplibre_go_qemu_cache",
T("répertoire des objets gardés"))
caDir = flag.String("ca-dir", "/var/lib/erplibre_go_qemu_cache",
T("répertoire de l'autorité de certification"))
httpPort = flag.Int("http-port", 8898, T("écoute pour le 80 détourné"))
tlsPort = flag.Int("tls-port", 8899, T("écoute pour le 443 détourné"))
bridge = flag.String("bridge", "virbr0", T("pont libvirt des VM"))
subnet = flag.String("subnet", "192.168.122.0/24", T("sous-réseau des VM"))
logPath = flag.String("access-log", "", T("journal d'accès JSON par ligne"))
exclude = flag.String("exclude", "",
T("hôtes à ne jamais déchiffrer, séparés par des virgules"))
verbose = flag.Bool("verbose", false, T("dire chaque requête"))
status = flag.Bool("status", false,
T("dire ce que le cache occupe, puis sortir"))
dryRun = flag.Bool("dry-run", false,
T("montrer les gestes privilégiés sans en faire un"))
initCA = flag.Bool("init-ca", false,
T("créer l'autorité si elle manque, puis sortir"))
printNft = flag.Bool("print-nft", false,
T("écrire les règles nft seules, à passer à « nft -f - »"))
printIptables = flag.Bool("print-iptables", false,
T("écrire les commandes iptables seules, une par ligne"))
bypassFile = flag.String("bypass-file",
"/etc/erplibre_go_qemu_cache/bypass",
T("liste des VM soustraites au détournement, une MAC par ligne"))
bypassAdd = flag.String("bypass-add", "",
T("soustraire cette adresse MAC au détournement"))
bypassName = flag.String("bypass-name", "",
T("nom de la VM, écrit à côté de la MAC ajoutée"))
bypassDel = flag.String("bypass-del", "",
T("rendre cette adresse MAC au détournement"))
gitMirrorDir = flag.String("git-mirror-dir", "",
T("racine des dépôts git tenus en miroir ; vide, git est"+
" simplement relayé vers l'amont"))
gitMirrorFresh = flag.Duration("git-mirror-fresh", 60*time.Second,
T("délai en deçà duquel un dépôt n'est pas re-interrogé"))
gitPrefetch = flag.String("git-mirror-prefetch", "",
T("fichier de dépôts, un par ligne, à tenir en miroir d'avance"))
gitPrefetchJobs = flag.Int("git-mirror-jobs", 4,
T("dépôts clonés en parallèle par le pré-remplissage"))
ageReport = flag.Bool("age-report", false,
T("dire ce que le cache occupe, groupé par âge du dernier usage"))
agePar = flag.String("age-par", "semaine",
T("découpage du relevé par âge : jour, semaine ou mois"))
purgeTout = flag.Bool("purge", false,
T("effacer TOUT le cache : objets et dépôts en miroir"))
purgeAvant = flag.String("purge-older-than", "",
T("n'effacer que ce qui n'a pas servi depuis ce délai (ex. 30j, 12h)"))
purgeTaille = flag.String("purge-to-size", "",
T("effacer le moins récemment servi jusqu'à tenir sous cette taille (ex. 50G)"))
gitList = flag.Bool("git-mirror-list", false,
T("dire les dépôts tenus en miroir, du plus lourd au plus léger"))
gitRemove = flag.String("git-mirror-remove", "",
T("effacer le miroir de ce dépôt ; il se refera au prochain besoin"))
bypassList = flag.Bool("bypass-list", false,
T("dire les exceptions en place, une « MAC nom » par ligne"))
showVersion = flag.Bool("version", false, T("dire la version, puis sortir"))
lang = flag.String("lang", "", T("langue des messages : fr ou en ; à défaut, EL_LANG"))
detient = flag.Bool("detient", false,
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et dire,"+
" pour chacune, ce que le magasin tient : une ligne séparée"+
" par des tabulations « verdict statut stored_at classe"+
" méthode url », verdict garde (corps 200), statut (statut"+
" 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"+
" statut seul. Dit, par ligne et séparé par des tabulations,"+
" « verdict octets classe méthode url », verdict oublié,"+
" absent, non-cachable ou refus. Le seul geste qui retire UNE"+
" entrée : --purge efface tout, et --purge-older-than"+
" n'atteint jamais un objet qui sert, chaque service remettant"+
" sa date à maintenant. Écrit : demande le droit du service."+
" Pas d'essai à blanc — --detient EST l'aperçu : il répond aux"+
" mêmes lignes, par les mêmes clés, sans rien modifier"))
)
flag.Parse()
// Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi
// pour que la valeur retenue soit celle que flag a comprise.
if *lang != "" {
definirLangue(*lang)
}
if *showVersion {
fmt.Printf("erplibre_go_qemu_cache %s\n", version)
return
}
// Traité AVANT tout ce qui lit une configuration : la question ne
// porte que sur le magasin, et un fichier d'exceptions illisible pour
// l'appelant ne doit pas l'empêcher d'y répondre.
if *detient {
store := &Store{Dir: *cacheDir}
if err := EcrireDetentions(store, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, T("entrée illisible : %v\n"), err)
}
return
}
// Au même endroit et pour la même raison : la question ne porte que sur
// le magasin. Écrire plutôt que lire ne change pas de quoi elle dépend.
if *oublie {
store := &Store{Dir: *cacheDir}
if err := EcrireOublis(store, os.Stdin, os.Stdout); err != nil {
fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err)
}
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
// repose telles quelles à chaque démarrage, et une exception ne survit
// donc pas au seul noyau.
exceptions, err := bypass.Load()
if err != nil {
fmt.Fprintf(os.Stderr, T("exceptions illisibles : %v\n"), err)
os.Exit(1)
}
rules := RuleSet{
Bridge: *bridge, Subnet: *subnet,
HTTPPort: *httpPort, TLSPort: *tlsPort,
Bypass: MACs(exceptions),
}
store := &Store{Dir: *cacheDir}
// Les règles sortent d'ici et de nulle part ailleurs : le service les
// pose en tubant cette sortie dans nft. Une seconde copie dans un script
// d'installation dériverait de celle que les tests vérifient.
if *printNft {
for _, l := range rules.NftLines() {
fmt.Println(l)
}
return
}
if *printIptables {
for _, l := range rules.IptablesLines() {
fmt.Println(l)
}
return
}
// Les trois gestes de la liste écrivent le fichier et rendent sur la
// SORTIE le geste à chaud correspondant, à tuber dans « nft -f - ». Ce
// paquet ne touche pas au pare-feu : c'est l'invariant qui rend les
// règles vérifiables par un test sans privilège.
if *bypassAdd != "" {
mac, err := bypass.Add(*bypassAdd, *bypassName)
if err != nil {
fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err)
os.Exit(1)
}
fmt.Fprintf(os.Stderr, T("exception posée : %s %s\n"), mac, *bypassName)
fmt.Println(BypassAddElement(mac))
return
}
if *bypassDel != "" {
mac, avait, err := bypass.Del(*bypassDel)
if err != nil {
fmt.Fprintf(os.Stderr, T("exception refusée : %v\n"), err)
os.Exit(1)
}
if !avait {
fmt.Fprintf(os.Stderr, T("aucune exception pour %s\n"), mac)
}
// Le geste à chaud est rendu même si le fichier ne l'avait pas :
// l'ensemble du noyau peut porter ce que le fichier a perdu, et le
// retrait doit alors pouvoir le rattraper.
fmt.Println(BypassDelElement(mac))
return
}
if *bypassList {
for _, e := range exceptions {
fmt.Printf("%s %s\n", e.MAC, e.Name)
}
return
}
// Une seule mémoire des amonts muets, partagée par le relais et le
// miroir : un hôte coupé l'est pour les deux, et le premier qui le
// constate en épargne le délai à l'autre.
muets := NouvelleJoignabilite()
// Le témoin que la levée d'une coupure touche. Il vit dans le magasin,
// seul répertoire que le service et la levée connaissent tous deux.
muets.Sentinelle = filepath.Join(*cacheDir, SentinelleAmonts)
miroir := &GitMirror{
Dir: *gitMirrorDir, Frais: *gitMirrorFresh, Muets: muets,
}
if *ageReport {
gran, err := LireGranularite(*agePar)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
printAge(store, miroir, gran)
return
}
if *purgeTout || *purgeAvant != "" {
avant := time.Now()
if *purgeAvant != "" {
d, err := LireDuree(*purgeAvant)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
avant = time.Now().Add(-d)
fmt.Printf(T("efface ce qui n'a pas servi depuis %s (avant %s)\n"),
*purgeAvant, avant.Format("2006-01-02 15:04"))
} else {
fmt.Println(T("efface TOUT le cache"))
}
if *dryRun {
printPurgeABlanc(store, miroir, avant)
return
}
n, oct, err := store.Purger(avant)
if err != nil {
fmt.Fprintf(os.Stderr, T("purge des objets : %v\n"), err)
}
fmt.Printf(T("objets effacés : %d, %s rendus\n"), n, HumanBytes(oct))
nd, octd, err := miroir.PurgerMiroirs(avant)
if err != nil {
fmt.Fprintf(os.Stderr, T("purge des miroirs : %v\n"), err)
}
fmt.Printf(T("dépôts effacés : %d, %s rendus\n"), nd, HumanBytes(octd))
return
}
if *purgeTaille != "" {
plafond, err := LireTaille(*purgeTaille)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
b, err := PurgerJusqua(store, miroir, plafond, *dryRun)
if err != nil {
fmt.Fprintf(os.Stderr, T("purge au plafond : %v\n"), err)
}
prefixe := ""
if *dryRun {
prefixe = T("[à blanc] ")
}
fmt.Printf(T("plafond %s : le cache occupe %s\n"), HumanBytes(plafond), HumanBytes(b.Avant))
fmt.Printf(T("%sobjets : %d, %s ; dépôts : %d, %s ; reste %s\n"), prefixe,
b.Objets, HumanBytes(b.OctetsObjets), b.Depots, HumanBytes(b.OctetsDepots),
HumanBytes(b.Apres))
return
}
if *gitList {
depots := miroir.Depots()
if len(depots) == 0 {
fmt.Println(T("aucun dépôt en miroir"))
return
}
var total int64
for _, d := range depots {
total += d.Octets
fmt.Printf("%10s %s %s\n",
HumanBytes(d.Octets), d.Maj.Format("2006-01-02"), d.Nom)
}
fmt.Printf(T("%10s %d dépôts\n"), HumanBytes(total), len(depots))
return
}
if *gitRemove != "" {
// Le nom suffit : on ne demande pas à l'opérateur de retrouver le
// chemin d'un répertoire qu'il n'a pas choisi.
cible := *gitRemove
if !strings.HasPrefix(cible, miroir.Dir) {
for _, d := range miroir.Depots() {
if d.Nom == strings.TrimSuffix(cible, ".git") {
cible = d.Chemin
break
}
}
}
if err := miroir.Retirer(cible); err != nil {
fmt.Fprintf(os.Stderr, T("effacement : %v\n"), err)
os.Exit(1)
}
fmt.Printf(T("miroir effacé : %s\n"), cible)
return
}
if *gitPrefetch != "" {
if !miroir.Actif() {
fmt.Fprintln(os.Stderr,
T("miroir git éteint : passer --git-mirror-dir"))
os.Exit(1)
}
depots, err := DepotsDuFichier(*gitPrefetch)
if err != nil {
fmt.Fprintf(os.Stderr, T("liste illisible : %v\n"), err)
os.Exit(1)
}
fmt.Printf(T("%d dépôts à tenir en miroir sous %s\n"),
len(depots), miroir.Dir)
reussis, echoues := miroir.Prefetch(
context.Background(), depots, *gitPrefetchJobs,
func(l string) { fmt.Println(l) },
)
_, octets := miroir.Occupation()
fmt.Printf(T("%d en miroir, %d en échec, %s occupés\n"),
reussis, echoues, HumanBytes(octets))
// Un dépôt mort ne fait pas échouer l'opération : sur une liste de
// trois cents, il y en a toujours un — privé, déplacé, retiré — et
// rendre une erreur ferait passer pour ratée une avance qui a pris.
// Seule une liste dont RIEN n'a été tenu est un échec.
if reussis == 0 && echoues > 0 {
os.Exit(1)
}
return
}
if *status {
if err := printStatus(store, *caDir, rules, miroir); err != nil {
fmt.Fprintf(os.Stderr, T("état illisible : %v\n"), err)
os.Exit(1)
}
return
}
if *dryRun {
printDryRun(store, *caDir, rules)
return
}
if *initCA {
ca, err := LoadOrCreateCA(*caDir)
if err != nil {
fmt.Fprintf(os.Stderr, T("autorité : %v\n"), err)
os.Exit(1)
}
fmt.Printf(T("autorité : %s\n"), CertPath(*caDir))
fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint())
return
}
if *gitMirrorDir != "" && !miroir.Actif() {
// Le dire plutôt que de laisser croire à un miroir : sans le
// programme de git, le service marche mais git repart à l'amont à
// chaque VM, ce qui est exactement ce que le miroir devait éviter.
log.Printf(
T("miroir git demandé mais « git-http-backend » est introuvable :"+
" git sera relayé vers l'amont (%s)"), *gitMirrorDir)
}
if err := serve(
store, *caDir, rules, *logPath, *exclude, *verbose, miroir, muets,
); err != nil {
log.Fatalf(T("le cache s'arrête : %v"), err)
}
}
func serve(
store *Store, caDir string, rules RuleSet,
logPath, exclude string, verbose bool, miroir *GitMirror,
muets *Joignabilite,
) error {
if err := os.MkdirAll(store.Dir, 0o755); err != nil {
return err
}
// Un objet à moitié écrit ne vaut rien et occupe : le démarrage est le
// seul moment où un « .part » n'appartient à aucune écriture vivante.
if n := store.SweepPartials(); n > 0 {
log.Printf(T("%d écriture(s) interrompue(s) retirée(s)"), n)
}
ca, err := LoadOrCreateCA(caDir)
if err != nil {
return err
}
alog, err := OpenAccessLog(logPath)
if err != nil {
return err
}
defer alog.Close()
proxy := NewProxy(store, alog)
proxy.Git = miroir
proxy.Verbose = verbose
proxy.Muets = muets
// Les ports d'écoute sont ceux que la requête d'une boucle viserait.
proxy.Ecoutes = []int{rules.HTTPPort, rules.TLSPort}
refusals := NewRefusals(append(DefaultExclusions, splitList(exclude)...))
front := &TLSFront{CA: ca, Proxy: proxy, Refusals: refusals}
httpLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.HTTPPort))
if err != nil {
return fmt.Errorf(T("écoute HTTP : %w"), err)
}
tlsLn, err := net.Listen("tcp", fmt.Sprintf(":%d", rules.TLSPort))
if err != nil {
return fmt.Errorf(T("écoute TLS : %w"), err)
}
log.Printf(T("cache : %s"), store.Dir)
log.Printf(T("autorité : %s (%s)"), CertPath(caDir), ca.Fingerprint())
log.Printf(T("écoutes : http %d, tls %d"), rules.HTTPPort, rules.TLSPort)
errc := make(chan error, 2)
go func() {
srv := &http.Server{
Handler: proxy.handler("http"),
ReadHeaderTimeout: 30 * time.Second,
}
errc <- srv.Serve(httpLn)
}()
go func() { errc <- front.Serve(tlsLn) }()
return <-errc
}
func printStatus(
store *Store, caDir string, rules RuleSet, miroirStatut *GitMirror,
) error {
st, err := store.Stat()
if err != nil {
return err
}
fmt.Printf(T("répertoire : %s\n"), store.Dir)
fmt.Printf(T("objets : %d\n"), st.Objects)
fmt.Printf(T("occupation : %s\n"), HumanBytes(st.Bytes))
if st.Oldest.IsZero() {
fmt.Printf("%s", T("plus ancien : aucun objet\n"))
} else {
fmt.Printf(T("plus ancien : %s\n"), st.Oldest.Format(time.RFC3339))
}
fmt.Printf(T("autorité : %s\n"), CertPath(caDir))
if ca, err := LoadOrCreateCA(caDir); err == nil {
fmt.Printf(T("empreinte : %s\n"), ca.Fingerprint())
} else {
fmt.Printf("%s", T("empreinte : autorité absente\n"))
}
if depots, octets := miroirStatut.Occupation(); depots > 0 {
fmt.Printf(T("dépôts git : %d en miroir, %s\n"),
depots, HumanBytes(octets))
}
if len(rules.Bypass) == 0 {
fmt.Printf("%s", T("exceptions : aucune\n"))
} else {
fmt.Printf(T("exceptions : %d VM soustraite(s) au détournement\n"),
len(rules.Bypass))
for _, m := range rules.Bypass {
fmt.Printf(" %s\n", m)
}
}
fmt.Printf("%s", T("\nAucune éviction n'est écrite : ce cache ne diminue jamais\n"))
fmt.Printf("%s", T("de lui-même, et il vit sur le disque de l'orchestrateur.\n"))
return nil
}
func printDryRun(store *Store, caDir string, rules RuleSet) {
fmt.Printf("%s", T("À blanc — rien n'est écrit, rien n'est posé.\n\n"))
fmt.Printf(T("Répertoire du cache, créé au démarrage :\n %s\n\n"), store.Dir)
fmt.Printf(T("Autorité, créée si elle manque :\n %s (clé en 0600)\n\n"),
CertPath(caDir))
fmt.Printf("%s", T("Règles nft à poser sur l'hôte :\n"))
for _, l := range rules.NftLines() {
fmt.Printf(" %s\n", l)
}
fmt.Printf("%s", T("\nÀ défaut de nft :\n"))
for _, l := range rules.IptablesLines() {
fmt.Printf(" %s\n", l)
}
fmt.Printf(T("\nRetrait :\n %s\n"), rules.NftDeleteLine())
fmt.Printf("%s", T("\nDans chaque VM qui utilise le cache :\n"))
for _, f := range []string{"pacman", "apt", "dnf", "zypper"} {
dir, cmd, bundle, _ := GuestTrustCommand(f)
fmt.Printf(T(" %-7s %s/erplibre-cache.crt puis %s\n"), f, dir, cmd)
for _, l := range GuestEnvLines(bundle) {
fmt.Printf(" %s\n", l)
}
}
}
func splitList(s string) []string {
if strings.TrimSpace(s) == "" {
return nil
}
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}