erplibre/script/qemu_cache/main.go
Mathieu Benoit 90ef1d24e7 [ADD] cache qemu : revalider les index, ranger chaque représentation
Sur une installation complète, les index pip, les métadonnées npm et le
bundle de repo pesaient environ 110 Mo par VM, repris en entier à chaque
fois. Un index déjà gardé part désormais avec son ETag : l'amont juge chaque
requête, et un « 304 » sert le corps depuis le disque. Sous « Vary: Accept »,
chaque représentation est rangée à part, faute de quoi deux clients qui
alternent les retéléchargent tour à tour. Les métadonnées PEP 658 de pip,
nommées « .whl.metadata », sont figées comme l'archive qu'elles décrivent.

--- EN ---

On a full install the pip indexes, npm metadata and repo bundle weighed about
110 MB per VM, taken whole every time. An index already kept now leaves with
its ETag: upstream judges every request, and a « 304 » serves the body from
disk. Under « Vary: Accept » each representation is stored apart, without
which two clients alternating between them re-download each in turn. The
PEP 658 metadata of pip, named « .whl.metadata », are as immutable as the
archive they describe.

Assisted-by: Claude Opus 5
2026-09-16 05:36:13 -04:00

474 lines
16 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.8"
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)"))
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é"))
)
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
}
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 *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
}