[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 14:41:19 -04:00
|
|
|
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
|
|
|
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
|
|
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
"net/url"
|
|
|
|
|
"path"
|
|
|
|
|
"regexp"
|
|
|
|
|
"strings"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Class dit ce que le cache a le droit de faire d'une réponse.
|
|
|
|
|
//
|
|
|
|
|
// La distinction porte tout l'outil. Un fichier de paquet est IMMUABLE :
|
|
|
|
|
// son nom porte sa version, son contenu ne change jamais, et le servir du
|
|
|
|
|
// disque est exactement le gain cherché. Un index de dépôt est VOLATILE :
|
|
|
|
|
// il nomme les versions qui existent en ce moment, et servir un index périmé
|
|
|
|
|
// fait échouer l'installation sur un fichier retiré des miroirs — « failed
|
|
|
|
|
// retrieving file … 404 », le mode de défaillance que la préparation d'un
|
|
|
|
|
// invité Arch documente déjà.
|
|
|
|
|
//
|
|
|
|
|
// D'où la règle : seul l'immuable est servi du cache tant que l'amont répond.
|
|
|
|
|
// Le volatile est STOCKÉ quand même, et ne ressort que si l'amont est
|
|
|
|
|
// injoignable, ce qui rend le déploiement hors ligne possible sans jamais
|
|
|
|
|
// ouvrir de fenêtre de péremption quand le réseau est là.
|
|
|
|
|
type Class int
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
// ClassImmutable : servi du disque dès qu'il y est.
|
|
|
|
|
ClassImmutable Class = iota
|
|
|
|
|
// ClassVolatile : toujours pris à l'amont, stocké, servi hors ligne seul.
|
|
|
|
|
ClassVolatile
|
|
|
|
|
// ClassNoStore : ni servi ni stocké.
|
|
|
|
|
ClassNoStore
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
func (c Class) String() string {
|
|
|
|
|
switch c {
|
|
|
|
|
case ClassImmutable:
|
|
|
|
|
return "immutable"
|
|
|
|
|
case ClassVolatile:
|
|
|
|
|
return "volatile"
|
|
|
|
|
default:
|
|
|
|
|
return "no-store"
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Suffixes d'un fichier dont le nom porte sa version. L'ordre n'importe pas ;
|
|
|
|
|
// le premier qui correspond gagne.
|
|
|
|
|
var immutableSuffixes = []string{
|
|
|
|
|
// paquets de distribution
|
|
|
|
|
".pkg.tar.zst", ".pkg.tar.xz", ".deb", ".rpm", ".apk",
|
|
|
|
|
// signatures détachées, aussi figées que ce qu'elles signent
|
|
|
|
|
".pkg.tar.zst.sig", ".pkg.tar.xz.sig",
|
|
|
|
|
// écosystèmes Python et Node
|
|
|
|
|
".whl", ".tgz",
|
[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 02:59:02 -04:00
|
|
|
// métadonnées d'une distribution Python servies à part (PEP 658) : le nom
|
|
|
|
|
// est celui de l'archive suivi de « .metadata », sous le même chemin
|
|
|
|
|
// d'empreinte. Sans ces suffixes, « .metadata » ne correspond à rien et le
|
|
|
|
|
// fichier tombe dans le volatil par défaut, repris à chaque installation.
|
|
|
|
|
".whl.metadata", ".tar.gz.metadata", ".zip.metadata",
|
[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 14:41:19 -04:00
|
|
|
// images et supports d'installation
|
|
|
|
|
".qcow2", ".iso", ".img", ".raw", ".vmdk",
|
|
|
|
|
// archives amont
|
|
|
|
|
".tar.gz", ".tar.xz", ".tar.bz2", ".tar.zst", ".zip",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Noms et suffixes d'un index de dépôt. Un « .db » d'Arch, un « InRelease »
|
|
|
|
|
// de Debian et un « repomd.xml » de Fedora décrivent tous l'état COURANT du
|
|
|
|
|
// miroir.
|
|
|
|
|
var volatileNames = []string{
|
|
|
|
|
"inrelease", "release", "release.gpg",
|
|
|
|
|
"packages", "packages.gz", "packages.xz", "packages.bz2",
|
|
|
|
|
"sources", "sources.gz", "sources.xz",
|
|
|
|
|
"repomd.xml", "repomd.xml.asc", "repomd.xml.key",
|
|
|
|
|
"index.json", "index.html",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var volatileSuffixes = []string{
|
|
|
|
|
".db", ".db.sig", ".db.tar.gz", ".files", ".files.tar.gz",
|
|
|
|
|
".xml.gz", ".xml.zck", ".sqlite.bz2", ".sqlite.gz",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// parEmpreinte reconnaît un index Debian publié sous l'empreinte de son
|
|
|
|
|
// contenu : « …/by-hash/SHA256/<hexadécimal> ». Le nom EST la somme du
|
|
|
|
|
// contenu, si bien qu'un contenu différent porte un autre nom : le fichier est
|
|
|
|
|
// aussi figé qu'un paquet, quoiqu'il n'ait aucune extension.
|
|
|
|
|
//
|
|
|
|
|
// Il se range SANS son hôte, et c'est ce qui le distingue d'un index nommé
|
|
|
|
|
// par sa suite : deux miroirs qui servent ce chemin servent le même octet,
|
|
|
|
|
// ou l'un des deux est corrompu et son client le rejette sur la somme. Le
|
|
|
|
|
// raisonnement inverse — « le même chemin ne vaut que si le miroir publie la
|
|
|
|
|
// même suite » — vaut pour « Packages », dont le contenu varie d'un miroir à
|
|
|
|
|
// l'autre sous un nom identique ; il ne vaut pas pour un nom qui EST la somme
|
|
|
|
|
// de son contenu, et l'appliquer ici vide le cache de ses index dès qu'une
|
|
|
|
|
// liste de miroirs tourne.
|
|
|
|
|
var parEmpreinte = regexp.MustCompile(
|
|
|
|
|
`/by-hash/(MD5Sum|SHA1|SHA256|SHA512)/[0-9a-fA-F]{32,128}$`)
|
|
|
|
|
|
|
|
|
|
// dernierePublication reconnaît « /<propriétaire>/<dépôt>/releases/latest/
|
|
|
|
|
// download/<fichier> » : un POINTEUR vers la dernière version publiée, dont
|
|
|
|
|
// la cible change à chaque publication.
|
|
|
|
|
//
|
|
|
|
|
// Son suffixe — « .tar.gz », « .zip » — le ferait passer pour figé : servi du
|
|
|
|
|
// disque sans jamais redemander, il resterait à la première version vue, et
|
|
|
|
|
// rangé sans son hôte il répondrait pour n'importe quelle forge. Il est donc
|
|
|
|
|
// volatile et attaché à son hôte, et la règle passe AVANT les suffixes.
|
|
|
|
|
var dernierePublication = regexp.MustCompile(
|
|
|
|
|
`^/[^/]+/[^/]+/releases/latest/download/[^/]+$`)
|
|
|
|
|
|
|
|
|
|
// Chemins du protocole « smart HTTP » de git. Ce sont des points de
|
|
|
|
|
// NÉGOCIATION : le serveur calcule sa réponse en fonction de ce que le client
|
|
|
|
|
// détient déjà. Rien n'y est réutilisable d'une requête à l'autre, et servir
|
|
|
|
|
// une réponse gardée y ferait croire à des références qui n'existent plus.
|
|
|
|
|
//
|
|
|
|
|
// Les fichiers du protocole « dumb », eux, restent cachables : un objet
|
|
|
|
|
// « .../objects/ab/cdef… » porte son empreinte dans son nom.
|
|
|
|
|
var gitSmartPaths = []string{
|
|
|
|
|
"/info/refs", "/git-upload-pack", "/git-receive-pack",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EstGitSmart dit si l'URL vise l'un de ces points de négociation.
|
|
|
|
|
//
|
|
|
|
|
// Sert deux fois. Le contenu n'est ni gardé ni servi du cache. Et l'amont y a
|
|
|
|
|
// droit à BEAUCOUP plus de patience : un serveur git énumère ses références à
|
|
|
|
|
// la demande, ce qui prend des dizaines de secondes sur un dépôt chargé, là où
|
|
|
|
|
// un miroir de paquets répond en quelques centaines de millisecondes.
|
|
|
|
|
func EstGitSmart(u *url.URL) bool {
|
|
|
|
|
if u == nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
for _, s := range gitSmartPaths {
|
|
|
|
|
if strings.HasSuffix(u.Path, s) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Classify tranche pour une URL, sans regarder la réponse : la décision doit
|
|
|
|
|
// être prise AVANT d'interroger l'amont, puisqu'elle décide s'il faut
|
|
|
|
|
// l'interroger.
|
|
|
|
|
//
|
|
|
|
|
// Le doute profite au volatile. Une URL inconnue est donc toujours reprise à
|
|
|
|
|
// l'amont quand il répond, et ne sert de copie que hors ligne : le pire cas
|
|
|
|
|
// est une requête inutile, jamais une réponse fausse.
|
|
|
|
|
func Classify(u *url.URL) Class {
|
|
|
|
|
if u == nil {
|
|
|
|
|
return ClassNoStore
|
|
|
|
|
}
|
|
|
|
|
if EstGitSmart(u) {
|
|
|
|
|
return ClassNoStore
|
|
|
|
|
}
|
|
|
|
|
name := strings.ToLower(path.Base(u.Path))
|
|
|
|
|
|
|
|
|
|
// Une requête portant une chaîne de requête décrit un appel d'API et non
|
|
|
|
|
// un fichier : deux paramètres différents rendent deux réponses, et son
|
|
|
|
|
// nom de fichier ne dit rien de son contenu.
|
|
|
|
|
if u.RawQuery != "" {
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
// Les deux règles de CHEMIN l'emportent sur celles du nom : l'une porte un
|
|
|
|
|
// suffixe figé qui ment, l'autre n'en porte aucun et dit pourtant vrai.
|
|
|
|
|
if dernierePublication.MatchString(u.Path) {
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
if parEmpreinte.MatchString(u.Path) {
|
|
|
|
|
return ClassImmutable
|
|
|
|
|
}
|
|
|
|
|
for _, n := range volatileNames {
|
|
|
|
|
if name == n {
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, s := range volatileSuffixes {
|
|
|
|
|
if strings.HasSuffix(name, s) {
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
// L'index « simple » de PyPI est une page sans extension sous /simple/.
|
|
|
|
|
if strings.Contains(u.Path, "/simple/") {
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
for _, s := range immutableSuffixes {
|
|
|
|
|
if strings.HasSuffix(name, s) {
|
|
|
|
|
return ClassImmutable
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return ClassVolatile
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// PortableParChemin dit si le NOM du fichier suffit à l'identifier sur
|
|
|
|
|
// n'importe quel miroir de la même distribution.
|
|
|
|
|
//
|
|
|
|
|
// Vrai pour un paquet — sa version et son architecture sont dans son nom — et
|
|
|
|
|
// pour un index de dépôt, dont le chemin est le même partout. Faux pour le
|
|
|
|
|
// reste : « /index.html » n'identifie rien.
|
|
|
|
|
func PortableParChemin(u *url.URL) bool {
|
|
|
|
|
if u == nil || u.RawQuery != "" {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
if dernierePublication.MatchString(u.Path) {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
// Un objet adressé par son empreinte se range sans son hôte. La règle est
|
|
|
|
|
// POSITIVE parce qu'aucune des tables suivantes ne le reconnaîtrait : une
|
|
|
|
|
// somme hexadécimale n'a pas d'extension, et retirer la seule exclusion ne
|
|
|
|
|
// suffirait donc pas à le rendre portable.
|
|
|
|
|
if parEmpreinte.MatchString(u.Path) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
name := strings.ToLower(path.Base(u.Path))
|
|
|
|
|
for _, s := range immutableSuffixes {
|
|
|
|
|
if strings.HasSuffix(name, s) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, s := range volatileSuffixes {
|
|
|
|
|
if strings.HasSuffix(name, s) {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, n := range volatileNames {
|
|
|
|
|
// « index.html » et « index.json » nomment n'importe quoi : ils sont
|
|
|
|
|
// dans la table des index, mais pas portables pour autant.
|
|
|
|
|
if name == n && !strings.HasPrefix(n, "index.") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// CacheableMethod : seules les lectures entrent au cache. Un POST ou un PUT
|
|
|
|
|
// change un état à l'amont et n'a pas de copie qui vaille.
|
|
|
|
|
func CacheableMethod(method string) bool {
|
|
|
|
|
return method == "GET" || method == "HEAD"
|
|
|
|
|
}
|