[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 (
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
"context"
|
[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
|
|
|
"encoding/json"
|
|
|
|
|
"fmt"
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
"io"
|
[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
|
|
|
"log"
|
|
|
|
|
"net"
|
|
|
|
|
"net/http"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"strings"
|
|
|
|
|
"sync"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Issues d'une requête, telles qu'elles paraissent au journal d'accès. Le
|
|
|
|
|
// journal EST la mesure : un test qui veut prouver que la seconde VM n'a rien
|
|
|
|
|
// tiré de l'amont compte les lignes, sans avoir à instrumenter l'invité.
|
|
|
|
|
const (
|
|
|
|
|
OutcomeHit = "hit" // servi du disque
|
|
|
|
|
OutcomeStored = "stored" // pris à l'amont, gardé
|
|
|
|
|
OutcomeFetched = "fetched" // pris à l'amont, non gardé
|
|
|
|
|
OutcomeStale = "stale" // amont muet, copie stockée servie
|
|
|
|
|
OutcomeOfflineMiss = "offline-miss" // amont muet, rien en réserve
|
|
|
|
|
OutcomeKeep = "keep" // amont muet, le client garde la sienne
|
|
|
|
|
OutcomePassthrough = "passthrough" // méthode ou requête non cachable
|
|
|
|
|
OutcomeError = "error" // amont joignable, mais en erreur
|
|
|
|
|
OutcomeMirror = "mirror" // servi d'un dépôt git tenu sur l'hôte
|
|
|
|
|
|
|
|
|
|
// Un statut gardé sans corps a ses PROPRES noms : les lecteurs du
|
|
|
|
|
// journal tiennent « stored » et « stale » pour la preuve qu'un corps est
|
|
|
|
|
// en réserve, et un refus gardé ne l'est pas.
|
|
|
|
|
OutcomeStoredStatus = "stored-status" // pris à l'amont, statut seul gardé
|
|
|
|
|
OutcomeStaleStatus = "stale-status" // amont muet, statut seul rejoué
|
[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
|
|
|
OutcomeRevalidated = "revalidated" // l'amont confirme la copie (304), corps servi du disque
|
[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
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// AccessLog écrit une ligne JSON par requête. Un format à une ligne par
|
|
|
|
|
// requête se lit par « grep » et se compte par « wc », ce qui est exactement
|
|
|
|
|
// ce qu'un test de bout en bout a besoin de faire.
|
|
|
|
|
type AccessLog struct {
|
|
|
|
|
mu sync.Mutex
|
|
|
|
|
f *os.File
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func OpenAccessLog(path string) (*AccessLog, error) {
|
|
|
|
|
if path == "" {
|
|
|
|
|
return &AccessLog{}, nil
|
|
|
|
|
}
|
|
|
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
return &AccessLog{f: f}, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
type accessLine struct {
|
|
|
|
|
Time string `json:"time"`
|
|
|
|
|
Method string `json:"method"`
|
|
|
|
|
URL string `json:"url"`
|
|
|
|
|
Class string `json:"class"`
|
|
|
|
|
Outcome string `json:"outcome"`
|
|
|
|
|
Status int `json:"status"`
|
|
|
|
|
Bytes int64 `json:"bytes"`
|
|
|
|
|
// Upstream dit si l'octet a traversé le réseau. C'est le champ que la
|
|
|
|
|
// mesure regarde.
|
|
|
|
|
Upstream bool `json:"upstream"`
|
|
|
|
|
// Client est l'adresse de l'invité qui a demandé.
|
|
|
|
|
//
|
|
|
|
|
// Sans elle, le journal dit ce que le cache a fait mais pas POUR QUI. Un
|
|
|
|
|
// doute sur l'accélération reste alors sans réponse : rien ne sépare ce
|
|
|
|
|
// qu'une VM a tiré du réseau de ce qu'une autre a été servie du disque.
|
|
|
|
|
// Le port est retiré — il change à chaque connexion et empêcherait tout
|
|
|
|
|
// regroupement.
|
|
|
|
|
Client string `json:"client,omitempty"`
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// clientDe rend l'adresse de l'invité, sans son port.
|
|
|
|
|
func clientDe(adresse string) string {
|
|
|
|
|
if h, _, err := net.SplitHostPort(adresse); err == nil {
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
return adresse
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *AccessLog) Write(l accessLine) {
|
|
|
|
|
if a == nil || a.f == nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
l.Time = time.Now().UTC().Format(time.RFC3339)
|
|
|
|
|
raw, err := json.Marshal(l)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
a.mu.Lock()
|
|
|
|
|
defer a.mu.Unlock()
|
|
|
|
|
a.f.Write(append(raw, '\n'))
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (a *AccessLog) Close() {
|
|
|
|
|
if a != nil && a.f != nil {
|
|
|
|
|
a.f.Close()
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Proxy sert les requêtes détournées vers lui.
|
|
|
|
|
type Proxy struct {
|
|
|
|
|
Store *Store
|
|
|
|
|
Log *AccessLog
|
|
|
|
|
Client *http.Client
|
|
|
|
|
// ClientPatient sert les échanges où l'amont CALCULE avant de répondre.
|
|
|
|
|
// Un serveur git énumère ses références à la demande, ce qui demande des
|
|
|
|
|
// dizaines de secondes avant le premier octet sur un dépôt chargé, quand
|
|
|
|
|
// un miroir de paquets répond en quelques centaines de millisecondes. Le
|
|
|
|
|
// délai court,
|
|
|
|
|
// qui existe pour que le repli hors ligne arrive avant que le client
|
|
|
|
|
// renonce, prenait ce calcul pour un amont injoignable et rendait un 504 :
|
|
|
|
|
// « repo sync » échouait alors sur un dépôt parfaitement joignable.
|
|
|
|
|
ClientPatient *http.Client
|
|
|
|
|
// Git tient les dépôts en miroir sur l'hôte. Nul ou éteint, la
|
|
|
|
|
// négociation git est simplement relayée vers l'amont.
|
|
|
|
|
Git *GitMirror
|
|
|
|
|
// Verbose fait parler chaque requête sur la sortie standard, ce qu'un
|
|
|
|
|
// service systemd envoie au journal.
|
|
|
|
|
Verbose bool
|
|
|
|
|
// Muets retient les amonts dont l'établissement vient d'échouer, et la
|
|
|
|
|
// mémoire est partagée avec le miroir git. Nulle, chaque requête retente
|
|
|
|
|
// l'amont et repaie le délai d'établissement.
|
|
|
|
|
Muets *Joignabilite
|
|
|
|
|
// Ecoutes porte les ports où le cache lui-même écoute. Une requête qui
|
|
|
|
|
// vise l'un d'eux sur une adresse de cette machine est une boucle. Vide,
|
|
|
|
|
// rien n'est refusé.
|
|
|
|
|
Ecoutes []int
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
|
|
|
|
|
// completions tient les clés dont le corps entier est en cours de prise
|
|
|
|
|
// (voir completer) ; enCours les compte, pour qui doit les attendre.
|
|
|
|
|
completions sync.Map
|
|
|
|
|
enCours sync.WaitGroup
|
[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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// NewProxy monte le client amont. Aucun délai GLOBAL n'est posé : une image
|
|
|
|
|
// qcow2 met des minutes à descendre, et un délai global la couperait au
|
|
|
|
|
// milieu. Les délais portent donc sur l'établissement et sur l'attente des
|
|
|
|
|
// en-têtes, jamais sur la durée du corps.
|
|
|
|
|
func NewProxy(store *Store, alog *AccessLog) *Proxy {
|
|
|
|
|
// Les délais sont bornés par la PATIENCE DU CLIENT, pas par la nôtre.
|
|
|
|
|
// pacman abandonne un fichier après dix secondes sous un octet par
|
|
|
|
|
// seconde ; si notre repli sur la copie stockée arrive plus tard, il
|
|
|
|
|
// n'arrive jamais — et un pare-feu qui jette les paquets sans les refuser
|
|
|
|
|
// fait justement pendre l'établissement jusqu'au délai.
|
|
|
|
|
tr := &http.Transport{
|
|
|
|
|
DialContext: (&net.Dialer{Timeout: DelaiEtablissement}).DialContext,
|
|
|
|
|
TLSHandshakeTimeout: 5 * time.Second,
|
|
|
|
|
ResponseHeaderTimeout: 8 * time.Second,
|
|
|
|
|
MaxIdleConnsPerHost: 8,
|
|
|
|
|
Proxy: http.ProxyFromEnvironment,
|
|
|
|
|
}
|
|
|
|
|
// Le même transport, la seule attente des en-têtes allongée : un serveur
|
|
|
|
|
// injoignable est toujours détecté à l'établissement, en quatre secondes.
|
|
|
|
|
trPatient := tr.Clone()
|
|
|
|
|
trPatient.ResponseHeaderTimeout = 120 * time.Second
|
|
|
|
|
|
|
|
|
|
// Une redirection est RENDUE au client plutôt que suivie : il la
|
|
|
|
|
// redemandera au travers du cache, et la copie reste rangée sous l'URL que
|
|
|
|
|
// l'invité a réellement demandée.
|
|
|
|
|
sansSuivre := func(*http.Request, []*http.Request) error {
|
|
|
|
|
return http.ErrUseLastResponse
|
|
|
|
|
}
|
|
|
|
|
return &Proxy{
|
|
|
|
|
Store: store,
|
|
|
|
|
Log: alog,
|
|
|
|
|
Client: &http.Client{Transport: tr, CheckRedirect: sansSuivre},
|
|
|
|
|
ClientPatient: &http.Client{
|
|
|
|
|
Transport: trPatient, CheckRedirect: sansSuivre,
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// En-têtes que la norme réserve à un saut : les recopier vers l'amont ou vers
|
|
|
|
|
// le client casse la connexion.
|
|
|
|
|
var hopByHop = []string{
|
|
|
|
|
"Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate",
|
|
|
|
|
"Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func copyHeader(dst, src http.Header) {
|
|
|
|
|
for k, vs := range src {
|
|
|
|
|
for _, v := range vs {
|
|
|
|
|
dst.Add(k, v)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
for _, h := range hopByHop {
|
|
|
|
|
dst.Del(h)
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// absoluteURL reconstruit l'adresse demandée. En interception transparente le
|
|
|
|
|
// client parle comme s'il tenait le serveur en face de lui : la ligne de
|
|
|
|
|
// requête ne porte qu'un chemin, et l'hôte vient de l'en-tête « Host » pour
|
|
|
|
|
// HTTP, du SNI pour TLS.
|
|
|
|
|
func absoluteURL(r *http.Request, scheme string) (*url.URL, error) {
|
|
|
|
|
if r.URL.IsAbs() {
|
|
|
|
|
return r.URL, nil
|
|
|
|
|
}
|
|
|
|
|
host := r.Host
|
|
|
|
|
if host == "" {
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
return nil, fmt.Errorf("%s", T("requête sans hôte : ni ligne absolue ni en-tête Host"))
|
[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
|
|
|
}
|
|
|
|
|
u := *r.URL
|
|
|
|
|
u.Scheme = scheme
|
|
|
|
|
u.Host = host
|
|
|
|
|
return &u, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) handler(scheme string) http.Handler {
|
|
|
|
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
|
|
|
p.serve(w, r, scheme)
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) {
|
|
|
|
|
u, err := absoluteURL(r, scheme)
|
|
|
|
|
if err != nil {
|
|
|
|
|
http.Error(w, err.Error(), http.StatusBadRequest)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
class := Classify(u)
|
|
|
|
|
key := CleDe(r.Method, u)
|
|
|
|
|
cacheable := CacheableMethod(r.Method) && class != ClassNoStore
|
|
|
|
|
|
|
|
|
|
// Une requête adressée au cache lui-même serait relayée vers sa propre
|
|
|
|
|
// écoute, qui la relaierait de nouveau, sans fin : chaque tour ouvre une
|
|
|
|
|
// connexion, jusqu'à épuiser les descripteurs et arrêter le service. Elle
|
|
|
|
|
// est refusée avant toute autre chose.
|
|
|
|
|
if p.viseLeCache(u) {
|
|
|
|
|
p.boucle(w, u, class, r.Method, clientDe(r.RemoteAddr))
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Une requête partielle n'est servie du cache que si le corps ENTIER y
|
|
|
|
|
// est ; sinon elle passe et ne se garde pas, un fragment ne valant rien
|
|
|
|
|
// pour la requête suivante.
|
|
|
|
|
partial := r.Header.Get("Range") != ""
|
|
|
|
|
|
|
|
|
|
if cacheable && class == ClassImmutable {
|
|
|
|
|
if p.serveFromStore(w, r, u, key, class, OutcomeHit) {
|
|
|
|
|
return
|
|
|
|
|
}
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
// La plage passe à l'amont et ne se garde pas ; le fichier entier est
|
|
|
|
|
// pris à part, pour que la plage suivante sorte du disque.
|
|
|
|
|
if partial && r.Method == "GET" {
|
|
|
|
|
p.completer(r, u, key, class)
|
|
|
|
|
}
|
[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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// La négociation git n'est pas relayée quand un miroir peut la servir :
|
|
|
|
|
// c'est un DÉPÔT que l'on tient, pas des réponses, aucune réponse de ce
|
|
|
|
|
// protocole ne se réutilisant d'un client à l'autre.
|
|
|
|
|
if p.Git.Actif() && EstGitSmart(u) && !strings.HasSuffix(
|
|
|
|
|
u.Path, "/git-receive-pack",
|
|
|
|
|
) {
|
|
|
|
|
if depot, reste, ok := DepotDeURL(u); ok {
|
|
|
|
|
if chemin, pret := p.Git.Assurer(r.Context(), depot); pret {
|
|
|
|
|
// Le relevé vient APRÈS : c'est la seule façon de dire ce que
|
|
|
|
|
// la réponse a réellement pesé, et ce chemin porte l'essentiel
|
|
|
|
|
// du trafic d'une installation.
|
|
|
|
|
n := p.Git.Servir(w, r, chemin, reste)
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
URL: u.String(), Method: r.Method, Class: class.String(),
|
|
|
|
|
Outcome: OutcomeMirror, Status: http.StatusOK, Bytes: n,
|
|
|
|
|
Client: clientDe(r.RemoteAddr),
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Le client détient-il déjà une copie ? Relevé AVANT tout, et jamais
|
|
|
|
|
// relu sur la requête d'amont : celle-ci peut avoir perdu sa condition
|
|
|
|
|
// juste en dessous, et l'oubli ferait rendre « 504 » à un client qui
|
|
|
|
|
// avait de quoi se passer de nous.
|
|
|
|
|
conditionnelle := estConditionnelle(r)
|
|
|
|
|
|
|
|
|
|
// Une requête conditionnelle sur une ressource dont NOUS n'avons pas le
|
|
|
|
|
// corps rapporte « 304 », donc rien à garder. Le cache resterait vide
|
|
|
|
|
// aussi longtemps que ses clients en détiennent une copie — c'est-à-dire
|
|
|
|
|
// toujours, une image cloud livrant déjà l'index de sa suite de base.
|
|
|
|
|
// La condition est donc retirée pour ce seul aller : l'amont envoie le
|
|
|
|
|
// corps entier, une fois, et toute VM suivante est servie, hors ligne
|
|
|
|
|
// comprise. Le corps une fois en réserve, la condition repart et le
|
|
|
|
|
// « 304 » économise de nouveau la bande passante.
|
|
|
|
|
detient := cacheable && p.Store.Detient(key)
|
|
|
|
|
amont := r
|
|
|
|
|
if cacheable && r.Method == "GET" && conditionnelle && !detient {
|
|
|
|
|
amont = sansCondition(r)
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// Un index déjà gardé part avec SON validateur. L'amont reste juge de
|
|
|
|
|
// chaque requête — un index n'est jamais servi du disque sans son accord
|
|
|
|
|
// tant qu'il répond — mais une copie qu'il déclare à jour n'est pas
|
|
|
|
|
// retéléchargée : « 304 » ne porte aucun corps, et le nôtre sort du
|
|
|
|
|
// disque. Sans cela, chaque installation reprenait en entier des index
|
|
|
|
|
// de dizaines de mégaoctets qui n'avaient pas changé.
|
|
|
|
|
//
|
|
|
|
|
// L'ETag seul, jamais la date : sous « Vary: Accept », deux
|
|
|
|
|
// représentations d'une même URL partagent leur Last-Modified, et un
|
|
|
|
|
// « 304 » accordé sur la date validerait celle qui n'est pas gardée. Pas
|
|
|
|
|
// sous une clé portable non plus : partagée par tous les miroirs, elle
|
|
|
|
|
// présenterait à l'un le validateur d'un autre. La condition du client,
|
|
|
|
|
// quand il en pose une, reste la sienne.
|
|
|
|
|
//
|
|
|
|
|
// Sous « Vary: Accept », la représentation que CE client accepte a pu être
|
|
|
|
|
// rangée à part (voir CleVariante) : c'est son validateur qui part, et
|
|
|
|
|
// elle qui sort sur « 304 ».
|
|
|
|
|
cleVariante := ""
|
|
|
|
|
if cacheable && class == ClassVolatile && r.Method == "GET" && !partial {
|
|
|
|
|
cleVariante = CleVariante(key, r.Header.Get("Accept"))
|
|
|
|
|
}
|
|
|
|
|
varianteTenue := cleVariante != "" && p.Store.Detient(cleVariante)
|
|
|
|
|
cleRevalidee := key
|
|
|
|
|
if varianteTenue {
|
|
|
|
|
cleRevalidee = cleVariante
|
|
|
|
|
}
|
|
|
|
|
revalide := false
|
|
|
|
|
if cacheable && (detient || varianteTenue) && class == ClassVolatile &&
|
|
|
|
|
r.Method == "GET" && !partial && !conditionnelle && !PortableParChemin(u) {
|
|
|
|
|
if etag := p.etagGarde(cleRevalidee); etag != "" {
|
|
|
|
|
amont = r.Clone(r.Context())
|
|
|
|
|
amont.Header.Set("If-None-Match", etag)
|
|
|
|
|
revalide = true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// Un statut seul vit sous sa propre clé, que les lecteurs de corps ne
|
|
|
|
|
// calculent pas : voir CleStatut.
|
|
|
|
|
cleStatut := CleStatut(r.Method, u)
|
|
|
|
|
|
|
|
|
|
// repli dit si la branche hors ligne a de quoi répondre : un corps 200,
|
|
|
|
|
// la copie du client (requête conditionnelle d'ORIGINE) ou un statut
|
|
|
|
|
// gardé. Seul un repli autorise à ne pas composer vers un amont connu
|
|
|
|
|
// muet ; sans lui, sauter la tentative changerait un aléa passager en
|
|
|
|
|
// « 504 » certain, pour une requête que l'amont revenu aurait servie.
|
|
|
|
|
repli := cacheable &&
|
|
|
|
|
(detient || conditionnelle || p.Store.TientStatut(cleStatut))
|
|
|
|
|
|
|
|
|
|
resp, upErr := p.fetch(amont, u, repli)
|
[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
|
|
|
if upErr == nil && revalide && resp.StatusCode == http.StatusNotModified {
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
// Effacée entre-temps — par un nettoyage —, la copie ne sort plus :
|
|
|
|
|
// la requête repart sans condition, et le client reçoit le corps de
|
|
|
|
|
// l'amont plutôt qu'un « 304 » qu'il n'a pas demandé.
|
|
|
|
|
if p.serveFromStore(w, r, u, cleRevalidee, class, OutcomeRevalidated) {
|
|
|
|
|
if cleRevalidee != key {
|
|
|
|
|
p.Store.Toucher(key)
|
|
|
|
|
}
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp, upErr = p.fetch(r, u, repli)
|
|
|
|
|
}
|
[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
|
|
|
// Une redirection est SUIVIE quand le nom du fichier demandé porte déjà
|
|
|
|
|
// son identité, et le contenu est gardé sous l'URL DEMANDÉE.
|
|
|
|
|
//
|
|
|
|
|
// Sans cela, un paquet publié derrière une redirection n'entre jamais au
|
|
|
|
|
// cache : la cible est une URL SIGNÉE qui change à chaque requête, si bien
|
|
|
|
|
// que la machine suivante ne retrouve rien et retélécharge. Rendre la
|
|
|
|
|
// redirection au client suppose une cible stable, ce qu'une signature à
|
|
|
|
|
// péremption n'est pas.
|
|
|
|
|
//
|
|
|
|
|
// Chaque étape part de la requête d'AMONT : ses conditions sont retirées
|
|
|
|
|
// quand le cache ne détient rien, et la requête d'origine ferait répondre
|
|
|
|
|
// « 304 » à la cible — il n'y aurait alors rien à garder.
|
|
|
|
|
if upErr == nil && class == ClassImmutable && r.Method == "GET" {
|
|
|
|
|
resp = p.suivreRedirections(amont, u, resp)
|
|
|
|
|
}
|
|
|
|
|
if upErr != nil {
|
|
|
|
|
// L'amont est injoignable : DNS muet, connexion refusée, délai
|
|
|
|
|
// dépassé. C'est ici, et seulement ici, qu'une copie périmée sort —
|
|
|
|
|
// y compris un index, ce qui rend le déploiement hors ligne possible.
|
|
|
|
|
// Un index plus récent que la signature qui l'annonce n'est PAS
|
|
|
|
|
// servi : voir indexIncoherent. Le client tombe alors sur le « 304 »
|
|
|
|
|
// qui le laisse garder ses listes, ou sur le refus qui suit.
|
[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
|
|
|
// La représentation de CE client d'abord : la base porte la dernière
|
|
|
|
|
// rangée, qui peut être l'autre.
|
|
|
|
|
if cacheable && varianteTenue && !p.indexIncoherent(u, key) &&
|
|
|
|
|
p.serveFromStore(w, r, u, cleVariante, class, OutcomeStale) {
|
|
|
|
|
p.Store.Toucher(key)
|
|
|
|
|
return
|
|
|
|
|
}
|
[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
|
|
|
if cacheable && !p.indexIncoherent(u, key) &&
|
|
|
|
|
p.serveFromStore(w, r, u, key, class, OutcomeStale) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Le client a posé une condition : il DÉTIENT déjà une copie, et ne
|
|
|
|
|
// demandait qu'à savoir si elle avait changé. Ne pouvant plus le
|
|
|
|
|
// vérifier, lui rendre « 304 » le laisse garder la sienne — c'est ce
|
|
|
|
|
// que « stale-if-error » veut dire pour une requête conditionnelle.
|
|
|
|
|
//
|
|
|
|
|
// Un « 504 » à sa place fait échouer toute la suite de dépôt : apt
|
|
|
|
|
// ne trouve alors plus un paquet de la suite de base, alors que la
|
|
|
|
|
// machine avait chez elle de quoi le nommer.
|
|
|
|
|
if conditionnelle {
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache", OutcomeKeep)
|
|
|
|
|
w.WriteHeader(http.StatusNotModified)
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: r.Method, URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: OutcomeKeep, Status: http.StatusNotModified,
|
|
|
|
|
Client: clientDe(r.RemoteAddr),
|
|
|
|
|
})
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
// Ni corps en réserve ni copie chez le client : reste le STATUT que
|
|
|
|
|
// l'amont a rendu la dernière fois, une redirection ou un refus. Le
|
|
|
|
|
// client qui suit la redirection, ou qui s'arrête sur le 404, reçoit
|
|
|
|
|
// ce qu'il aurait reçu en ligne, là où un 504 l'arrêterait net.
|
|
|
|
|
//
|
|
|
|
|
// Rejoué en DERNIER recours : un corps gardé l'emporte toujours, et le
|
|
|
|
|
// client qui détient sa copie garde son 304 plutôt qu'un refus.
|
|
|
|
|
if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.offlineMiss(
|
|
|
|
|
w, u, class, r.Method, clientDe(r.RemoteAddr), upErr,
|
|
|
|
|
)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
|
|
|
|
|
store := cacheable && !partial && resp.StatusCode == http.StatusOK &&
|
|
|
|
|
r.Method == "GET"
|
|
|
|
|
// Un statut est gardé SEUL, sans corps, quand il porte une réponse
|
|
|
|
|
// qu'aucun corps ne remplace : redirection ou refus définitif pour un GET,
|
|
|
|
|
// et en plus le 200 d'un HEAD, qui n'a jamais de corps. Il est rangé
|
|
|
|
|
// sous CleStatut, jamais sous la clé du corps, et ne ressort que l'amont
|
|
|
|
|
// muet. Trois bornes :
|
|
|
|
|
// - le volatile seul : l'immuable suit ses redirections, et un 404
|
|
|
|
|
// servi du disque y masquerait le fichier publié ensuite ;
|
|
|
|
|
// - jamais sous une clé portable : partagée par tous les miroirs, elle
|
|
|
|
|
// recevrait le refus d'un miroir en retard à la place de l'index
|
|
|
|
|
// qu'un autre a rendu ;
|
|
|
|
|
// - jamais quand la clé du CORPS tient un 200 : le rejeu ne passe
|
|
|
|
|
// qu'après lui, et le garder ne servirait qu'à le faire mentir le
|
|
|
|
|
// jour où ce corps disparaît.
|
|
|
|
|
// Un statut passager — 403, 429, 5xx — ne se garde pas : le rejouer
|
|
|
|
|
// figerait une panne qui n'a duré qu'un moment.
|
|
|
|
|
//
|
|
|
|
|
// Detenir, dans « --detient », tient la négation exacte de cette
|
|
|
|
|
// condition pour un HEAD : les deux sont à changer ensemble.
|
|
|
|
|
statutSeul := !store && cacheable && !partial &&
|
|
|
|
|
class == ClassVolatile && !PortableParChemin(u) &&
|
|
|
|
|
statutSansCorps(r.Method, resp) && !detient
|
|
|
|
|
|
|
|
|
|
var cw *Writer
|
|
|
|
|
if store || statutSeul {
|
|
|
|
|
cle := key
|
|
|
|
|
if statutSeul {
|
|
|
|
|
cle = cleStatut
|
|
|
|
|
}
|
|
|
|
|
m := Meta{
|
|
|
|
|
URL: u.String(),
|
|
|
|
|
Method: r.Method,
|
|
|
|
|
Status: resp.StatusCode,
|
|
|
|
|
Header: resp.Header.Clone(),
|
|
|
|
|
Class: class.String(),
|
|
|
|
|
}
|
|
|
|
|
if statutSeul {
|
|
|
|
|
m.StatusOnly = true
|
|
|
|
|
sansLongueurMenteuse(r.Method, m.Header)
|
|
|
|
|
// Le témoin de session appartient à la machine qui l'a reçu.
|
|
|
|
|
m.Header.Del("Set-Cookie")
|
|
|
|
|
}
|
|
|
|
|
if cw, err = p.Store.NewWriter(cle, m); err != nil {
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
log.Printf(T("cache : écriture impossible pour %s : %v"), u, err)
|
[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
|
|
|
cw = nil
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
copyHeader(w.Header(), resp.Header)
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache", "miss")
|
|
|
|
|
w.WriteHeader(resp.StatusCode)
|
|
|
|
|
|
|
|
|
|
// Le corps d'un statut seul va au client tel quel, jamais au disque.
|
|
|
|
|
var sink *Writer
|
|
|
|
|
if cw != nil && store {
|
|
|
|
|
sink = cw
|
|
|
|
|
}
|
|
|
|
|
var n int64
|
|
|
|
|
if sink != nil {
|
|
|
|
|
n, err = copyTee(w, sink, resp.Body)
|
|
|
|
|
} else {
|
|
|
|
|
n, err = copyTee(w, nil, resp.Body)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
outcome := OutcomeFetched
|
|
|
|
|
switch {
|
|
|
|
|
case err != nil:
|
|
|
|
|
// Le client s'est déconnecté ou l'amont a coupé : rien de partiel
|
|
|
|
|
// n'entre au cache.
|
|
|
|
|
if cw != nil {
|
|
|
|
|
cw.Abort()
|
|
|
|
|
}
|
|
|
|
|
outcome = OutcomeError
|
|
|
|
|
case cw != nil:
|
|
|
|
|
// Un statut seul se publie VIDE, quelle que soit la longueur
|
|
|
|
|
// annoncée : pour un HEAD, la bibliothèque la tire de l'en-tête alors
|
|
|
|
|
// qu'aucun corps ne suit, et la comparer ferait tout refuser.
|
|
|
|
|
attendu := resp.ContentLength
|
|
|
|
|
if statutSeul {
|
|
|
|
|
attendu = 0
|
|
|
|
|
}
|
|
|
|
|
if cerr := cw.Commit(attendu); cerr != nil {
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
log.Printf(T("cache : %s non gardé : %v"), u, cerr)
|
[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
|
|
|
outcome = OutcomeFetched
|
|
|
|
|
} else if statutSeul {
|
|
|
|
|
outcome = OutcomeStoredStatus
|
|
|
|
|
} else {
|
|
|
|
|
outcome = OutcomeStored
|
[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
|
|
|
// La base est publiée telle qu'avant ; sa copie sous la variante
|
|
|
|
|
// garde cette représentation quand l'autre la remplacera.
|
|
|
|
|
if cleVariante != "" && varieSurAccept(resp.Header) {
|
|
|
|
|
if verr := p.Store.Copier(key, cleVariante); verr != nil {
|
|
|
|
|
log.Printf(T("cache : variante de %s non gardée : %v"), u, verr)
|
|
|
|
|
}
|
|
|
|
|
}
|
[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
|
|
|
}
|
|
|
|
|
case !cacheable:
|
|
|
|
|
outcome = OutcomePassthrough
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: r.Method, URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: outcome, Status: resp.StatusCode, Bytes: n, Upstream: true,
|
|
|
|
|
Client: clientDe(r.RemoteAddr),
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// varieSurAccept dit si la réponse annonce varier selon l'en-tête Accept.
|
|
|
|
|
// « Vary » est une liste de noms d'en-têtes, insensible à la casse, et peut
|
|
|
|
|
// être répété.
|
|
|
|
|
func varieSurAccept(h http.Header) bool {
|
|
|
|
|
for _, v := range h.Values("Vary") {
|
|
|
|
|
for _, nom := range strings.Split(v, ",") {
|
|
|
|
|
if strings.EqualFold(strings.TrimSpace(nom), "Accept") {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
[FIX] cache qemu : métadonnées RPM figées, plages prises en entier
An RPM repository names each metadata file after the hash of its content;
only repomd.xml is current. Classified volatile, a primary.xml.gz of tens of
megabytes was taken whole on every install, about 110 MB per VM on a RHEL
family system. dnf fetches .zck metadata by ranges, and a range is never
stored: upstream cut, the next VM got 504 and dnf stopped. Such files are now
immutable, and a range on a missing immutable file makes the cache fetch the
whole file in the background, once per key. Checked: a range served offline
from disk, each rule mutation-tested, race detector clean.
--- FR ---
Un dépôt RPM nomme chaque métadonnée d'après l'empreinte de son contenu ;
seul repomd.xml est courant. Classé volatile, un primary.xml.gz de dizaines de
mégaoctets était repris en entier à chaque installation, environ 110 Mo par VM
sur un système de la famille RHEL. dnf prend les métadonnées .zck par plages,
et une plage ne se garde pas : amont coupé, la VM suivante recevait 504 et dnf
s'arrêtait. Ces fichiers sont désormais figés, et une plage sur un fichier figé
absent fait prendre au cache le fichier entier en arrière-plan, une fois par
clé. Vérifié : plage servie hors ligne du disque, chaque règle éprouvée par
mutation, détecteur de courses muet.
Assisted-by: Claude Opus 5
2026-09-16 03:00:18 -04:00
|
|
|
// delaiCompletion borne la prise d'un corps entier en arrière-plan. Aucun
|
|
|
|
|
// client n'attend cette prise : sans borne, un amont qui cesse d'envoyer au
|
|
|
|
|
// milieu du corps la garderait ouverte pour toujours.
|
|
|
|
|
const delaiCompletion = time.Hour
|
|
|
|
|
|
|
|
|
|
// completer prend à l'amont, en arrière-plan, le corps ENTIER d'un fichier
|
|
|
|
|
// figé dont un client n'a demandé qu'une plage, et le garde sous sa clé.
|
|
|
|
|
//
|
|
|
|
|
// dnf télécharge ses métadonnées zchunk par plages, et pacman reprend de même
|
|
|
|
|
// un paquet interrompu : une plage ne se garde pas, si bien que sans cette
|
|
|
|
|
// prise ces fichiers repartiraient à l'amont à chaque VM — et, amont coupé, ne
|
|
|
|
|
// seraient pas là. Une fois gardé, le fichier sert toute plage depuis le
|
|
|
|
|
// disque.
|
|
|
|
|
//
|
|
|
|
|
// Une seule prise par clé à la fois, et la requête du client ne l'attend pas.
|
|
|
|
|
// Elle part sans plage ni condition, avec le seul agent de l'invité, et un
|
|
|
|
|
// amont connu muet n'est pas recomposé. Une prise manquée se retente à la
|
|
|
|
|
// plage suivante.
|
|
|
|
|
func (p *Proxy) completer(r *http.Request, u *url.URL, key string, class Class) {
|
|
|
|
|
if _, deja := p.completions.LoadOrStore(key, true); deja {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
entete := http.Header{}
|
|
|
|
|
if agent := r.Header.Get("User-Agent"); agent != "" {
|
|
|
|
|
entete.Set("User-Agent", agent)
|
|
|
|
|
}
|
|
|
|
|
p.enCours.Add(1)
|
|
|
|
|
go func() {
|
|
|
|
|
defer p.enCours.Done()
|
|
|
|
|
defer p.completions.Delete(key)
|
|
|
|
|
p.prendreEntier(u, key, class, entete)
|
|
|
|
|
}()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// attendreCompletions rend la main quand plus aucune prise n'est en cours.
|
|
|
|
|
func (p *Proxy) attendreCompletions() {
|
|
|
|
|
p.enCours.Wait()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// prendreEntier fait la prise elle-même : un 200 entier, publié sous la clé
|
|
|
|
|
// par le même écrivain que le chemin du client, taille annoncée vérifiée. Tout
|
|
|
|
|
// autre statut, ou une clé devenue détenue entre-temps, ne publie rien.
|
|
|
|
|
func (p *Proxy) prendreEntier(u *url.URL, key string, class Class, entete http.Header) {
|
|
|
|
|
ctx, annuler := context.WithTimeout(context.Background(), delaiCompletion)
|
|
|
|
|
defer annuler()
|
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "GET", u.String(), nil)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
req.Header = entete
|
|
|
|
|
resp, err := p.fetch(req, u, true)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
resp = p.suivreRedirections(req, u, resp)
|
|
|
|
|
defer resp.Body.Close()
|
|
|
|
|
if resp.StatusCode != http.StatusOK || p.Store.Detient(key) {
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
cw, err := p.Store.NewWriter(key, Meta{
|
|
|
|
|
URL: u.String(), Method: "GET", Status: resp.StatusCode,
|
|
|
|
|
Header: resp.Header.Clone(), Class: class.String(),
|
|
|
|
|
})
|
|
|
|
|
if err != nil {
|
|
|
|
|
log.Printf(T("cache : écriture impossible pour %s : %v"), u, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
n, err := io.Copy(cw, resp.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
cw.Abort()
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
if err := cw.Commit(resp.ContentLength); err != nil {
|
|
|
|
|
log.Printf(T("cache : %s non gardé : %v"), u, err)
|
|
|
|
|
return
|
|
|
|
|
}
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: "GET", URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: OutcomeStored, Status: resp.StatusCode, Bytes: n, Upstream: true,
|
|
|
|
|
})
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// etagGarde rend l'ETag du corps gardé sous la clé, ou "" : rien de gardé,
|
|
|
|
|
// un statut seul, ou une réponse d'amont qui n'en portait pas.
|
|
|
|
|
func (p *Proxy) etagGarde(key string) string {
|
|
|
|
|
m, err := p.Store.LireMeta(key)
|
|
|
|
|
if err != nil || m.StatutSeul() {
|
|
|
|
|
return ""
|
|
|
|
|
}
|
|
|
|
|
return m.Header.Get("ETag")
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// serveFromStore rend vrai quand la réponse est partie du disque.
|
|
|
|
|
func (p *Proxy) serveFromStore(
|
|
|
|
|
w http.ResponseWriter, r *http.Request, u *url.URL, key string,
|
|
|
|
|
class Class, outcome string,
|
|
|
|
|
) bool {
|
|
|
|
|
m, f, err := p.Store.Get(key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
defer f.Close()
|
|
|
|
|
// Seul un corps 200 sort par ici. Un statut seul a son propre chemin,
|
|
|
|
|
// réservé à l'amont muet : ServeContent en ferait un 200 vide, et sur le
|
|
|
|
|
// chemin de l'immuable il sortirait même quand l'amont répond.
|
|
|
|
|
if m.StatutSeul() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
copyHeader(w.Header(), m.Header)
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache", outcome)
|
|
|
|
|
if outcome == OutcomeStale {
|
|
|
|
|
// L'opérateur doit pouvoir dire de quand datent les octets sur
|
|
|
|
|
// lesquels sa VM se bâtit.
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339))
|
|
|
|
|
}
|
|
|
|
|
// ServeContent tient les requêtes partielles et le code 206 : pacman
|
|
|
|
|
// reprend un téléchargement interrompu par une plage.
|
|
|
|
|
http.ServeContent(w, r, "", m.StoredAt, f)
|
|
|
|
|
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: r.Method, URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: outcome, Status: http.StatusOK, Bytes: m.Size, Upstream: false,
|
|
|
|
|
Client: clientDe(r.RemoteAddr),
|
|
|
|
|
})
|
|
|
|
|
if p.Verbose {
|
|
|
|
|
log.Printf("%s %s -> %s (%s)", r.Method, u, outcome, HumanBytes(m.Size))
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// rejouerStatut rend, sans corps, le statut gardé sous la clé — une clé de
|
|
|
|
|
// CleStatut : ses en-têtes, « Location » compris et tel que l'amont l'a
|
|
|
|
|
// écrit, puis le code. Rend faux quand la clé ne porte pas de statut seul.
|
|
|
|
|
//
|
|
|
|
|
// ServeContent est évité entièrement : il réécrirait le code en 200, et
|
|
|
|
|
// ferait d'une redirection un « 304 » ou un « 206 » selon ce que le client
|
|
|
|
|
// pose. La longueur d'un GET est retirée une seconde fois, un méta écrit à
|
|
|
|
|
// la main pouvant la porter ; celle d'un HEAD ressort telle que l'amont l'a
|
|
|
|
|
// annoncée.
|
|
|
|
|
func (p *Proxy) rejouerStatut(
|
|
|
|
|
w http.ResponseWriter, r *http.Request, u *url.URL, key string,
|
|
|
|
|
class Class,
|
|
|
|
|
) bool {
|
|
|
|
|
m, f, err := p.Store.Get(key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
f.Close()
|
|
|
|
|
if !m.StatutSeul() {
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
copyHeader(w.Header(), m.Header)
|
|
|
|
|
sansLongueurMenteuse(m.Method, w.Header())
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache", OutcomeStaleStatus)
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache-Date", m.StoredAt.Format(time.RFC3339))
|
|
|
|
|
w.WriteHeader(m.Status)
|
|
|
|
|
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: r.Method, URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: OutcomeStaleStatus, Status: m.Status, Upstream: false,
|
|
|
|
|
Client: clientDe(r.RemoteAddr),
|
|
|
|
|
})
|
|
|
|
|
if p.Verbose {
|
|
|
|
|
log.Printf("%s %s -> %s (%d)", r.Method, u, OutcomeStaleStatus, m.Status)
|
|
|
|
|
}
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sansLongueurMenteuse retire la longueur d'un statut seul, sauf pour un HEAD.
|
|
|
|
|
//
|
|
|
|
|
// Pour un GET, la longueur annoncée sans corps mentirait : le client
|
|
|
|
|
// attendrait des octets qui ne viendront jamais, et échouerait sur une fin de
|
|
|
|
|
// flux inattendue. Pour un HEAD, elle ne ment pas — la norme (RFC 9110) en
|
|
|
|
|
// fait la taille du corps que rendrait le GET, et aucun client ne lit de
|
|
|
|
|
// corps après un HEAD. C'est même la seule chose qu'il apprend du corps :
|
|
|
|
|
// l'installateur qui compare une taille par « curl -I » recevrait sinon hors
|
|
|
|
|
// ligne une autre réponse qu'en ligne.
|
|
|
|
|
func sansLongueurMenteuse(methode string, h http.Header) {
|
|
|
|
|
if methode != http.MethodHead {
|
|
|
|
|
h.Del("Content-Length")
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// statutSansCorps dit si la réponse porte un statut qui vaut d'être gardé
|
|
|
|
|
// seul : une redirection qui dit où aller, un refus définitif, et pour un
|
|
|
|
|
// HEAD aussi le 200 — le HEAD n'a de toute façon jamais de corps.
|
|
|
|
|
//
|
|
|
|
|
// Une redirection sans « Location » ne mène nulle part : elle n'est pas
|
|
|
|
|
// gardée.
|
|
|
|
|
func statutSansCorps(methode string, resp *http.Response) bool {
|
|
|
|
|
switch resp.StatusCode {
|
|
|
|
|
case http.StatusMovedPermanently, http.StatusFound, http.StatusSeeOther,
|
|
|
|
|
http.StatusTemporaryRedirect, http.StatusPermanentRedirect:
|
|
|
|
|
return resp.Header.Get("Location") != ""
|
|
|
|
|
case http.StatusNotFound, http.StatusGone:
|
|
|
|
|
return true
|
|
|
|
|
case http.StatusOK:
|
|
|
|
|
return methode == "HEAD"
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// offlineMiss dit CE QUI manque. Un 404 nu ferait accuser le miroir : le
|
|
|
|
|
// client n'a aucun moyen de savoir qu'un cache s'est interposé, et le message
|
|
|
|
|
// est la seule chance de le lui apprendre.
|
|
|
|
|
func (p *Proxy) offlineMiss(
|
|
|
|
|
w http.ResponseWriter, u *url.URL, class Class, method, client string,
|
|
|
|
|
cause error,
|
|
|
|
|
) {
|
|
|
|
|
msg := enCommentaire(fmt.Sprintf(
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
T("erplibre_go_qemu_cache : amont injoignable et rien en réserve.\n"+
|
[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
|
|
|
" demandé : %s\n"+
|
|
|
|
|
" classe : %s\n"+
|
|
|
|
|
" cause : %v\n"+
|
|
|
|
|
"%s"+
|
|
|
|
|
"Ce fichier n'a jamais traversé ce cache. Rétablir le réseau, ou\n"+
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
"déployer une VM identique à celle qui a rempli le cache.\n"),
|
[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
|
|
|
u, class, cause, decrireMuet(cause)))
|
|
|
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
|
|
|
w.Header().Set("X-ERPLibre-Cache", OutcomeOfflineMiss)
|
|
|
|
|
w.WriteHeader(http.StatusGatewayTimeout)
|
|
|
|
|
fmt.Fprint(w, msg)
|
|
|
|
|
|
|
|
|
|
p.record(accessLine{
|
|
|
|
|
Method: method, URL: u.String(), Class: class.String(),
|
|
|
|
|
Outcome: OutcomeOfflineMiss, Status: http.StatusGatewayTimeout,
|
|
|
|
|
Client: client,
|
|
|
|
|
})
|
[ADD] cache qemu : messages du binaire en anglais ou en français
Le binaire ne parlait que français alors que le menu suit EL_LANG : un
opérateur anglophone lisait un statut qu'il ne comprenait pas. Chaque message
humain passe par T(), dont la clé EST le texte français, et un catalogue
anglais le traduit ; la langue vient de --lang, puis d'EL_LANG, le français
restant le défaut. Le menu demande le français pour ce qu'il RELIT, ses
motifs ne connaissant que cette langue. Un test refuse un message sans
traduction, une traduction orpheline et un verbe de format qui diverge.
--- EN ---
The binary spoke French only while the menu follows EL_LANG: an English
reader got a status he could not read. Every human message goes through T(),
whose key IS the French text, and an English catalogue translates it; the
language comes from --lang, then EL_LANG, French remaining the default. The
menu asks for French for what it READS BACK, its patterns knowing that
language alone. A test refuses a message without translation, an orphan
translation, and a format verb that drifts.
Assisted-by: Claude Opus 5
2026-09-16 02:59:01 -04:00
|
|
|
log.Printf(T("hors ligne, absent du cache : %s"), u)
|
[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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// maxRedirections borne la chaîne : une boucle de redirections tournerait
|
|
|
|
|
// jusqu'à épuiser la mémoire, et aucune publication légitime n'en enchaîne
|
|
|
|
|
// autant.
|
|
|
|
|
const maxRedirections = 5
|
|
|
|
|
|
|
|
|
|
// suivreRedirections rend la réponse FINALE d'une chaîne de redirections, ou
|
|
|
|
|
// la dernière obtenue si quelque chose s'y oppose.
|
|
|
|
|
//
|
|
|
|
|
// Chaque étape rejoue `r`, la requête d'amont partie vers `depart`. Les
|
|
|
|
|
// identifiants — « Authorization », « Cookie », « Proxy-Authorization » — ne
|
|
|
|
|
// suivent pas une étape qui quitte l'hôte de départ : ils ont été confiés à
|
|
|
|
|
// cet hôte, et une redirection vers un stockage tiers les lui livrerait. Le
|
|
|
|
|
// client HTTP de la bibliothèque les retire de même ; un suivi à la main doit
|
|
|
|
|
// le faire lui-même.
|
|
|
|
|
//
|
|
|
|
|
// Une cible qui désigne le cache lui-même arrête la chaîne : la suivre
|
|
|
|
|
// renverrait la requête ici.
|
|
|
|
|
//
|
|
|
|
|
// Le corps de chaque étape est refermé : une redirection en porte un, court,
|
|
|
|
|
// que personne ne lira. Une erreur en route rend l'étape courante plutôt que
|
|
|
|
|
// rien : le client verra la redirection et se débrouillera, ce qui est le
|
|
|
|
|
// comportement d'avant.
|
|
|
|
|
func (p *Proxy) suivreRedirections(
|
|
|
|
|
r *http.Request, depart *url.URL, resp *http.Response,
|
|
|
|
|
) *http.Response {
|
|
|
|
|
origine := adresseAmont(depart)
|
|
|
|
|
for i := 0; i < maxRedirections; i++ {
|
|
|
|
|
if resp.StatusCode < 300 || resp.StatusCode > 399 {
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
cible, err := resp.Location()
|
|
|
|
|
if err != nil || cible == nil || p.viseLeCache(cible) {
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
etape := r
|
|
|
|
|
if adresseAmont(cible) != origine {
|
|
|
|
|
etape = sansIdentifiants(r)
|
|
|
|
|
}
|
|
|
|
|
// Sans repli : l'étape qui échoue rend la redirection au client,
|
|
|
|
|
// qui la redemandera au travers du cache — la tenter coûte au plus
|
|
|
|
|
// le délai d'établissement, la sauter ne ferait que le déplacer.
|
|
|
|
|
suivante, err := p.fetch(etape, cible, false)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
resp.Body.Close()
|
|
|
|
|
resp = suivante
|
|
|
|
|
}
|
|
|
|
|
return resp
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// enTetesDIdentite : ce par quoi un client s'authentifie auprès d'un hôte.
|
|
|
|
|
var enTetesDIdentite = []string{
|
|
|
|
|
"Authorization", "Cookie", "Proxy-Authorization",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sansIdentifiants rend une COPIE de la requête, ses identifiants retirés.
|
|
|
|
|
func sansIdentifiants(r *http.Request) *http.Request {
|
|
|
|
|
out := r.Clone(r.Context())
|
|
|
|
|
for _, h := range enTetesDIdentite {
|
|
|
|
|
out.Header.Del(h)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// enCommentaire rend un texte INERTE pour un interpréteur de commandes.
|
|
|
|
|
//
|
|
|
|
|
// Un corps d'erreur finit régulièrement dans un shell : l'idiome
|
|
|
|
|
// « curl … | bash » est celui de la moitié des installateurs, et « curl »
|
|
|
|
|
// sans « -f » lui livre le corps d'un 504 comme s'il l'avait demandé. Chaque
|
|
|
|
|
// ligne du message devenait alors une commande, et le lecteur recevait une
|
|
|
|
|
// cascade de « command not found » à la place de la cause.
|
|
|
|
|
//
|
|
|
|
|
// Chaque ligne est donc préfixée — y compris celles d'une cause qui en
|
|
|
|
|
// porterait plusieurs, sans quoi la première suffirait à sortir du
|
|
|
|
|
// commentaire. Le texte reste lisible pour l'humain, et ne fait rien.
|
|
|
|
|
func enCommentaire(texte string) string {
|
|
|
|
|
lignes := strings.Split(strings.TrimRight(texte, "\n"), "\n")
|
|
|
|
|
for i, l := range lignes {
|
|
|
|
|
lignes[i] = "# " + l
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(lignes, "\n") + "\n"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// enTetesConditionnels : ce par quoi un client dit « seulement si ça a
|
|
|
|
|
// changé ». Le « Range » n'en est pas — il demande un fragment, pas une
|
|
|
|
|
// validation, et il est traité ailleurs.
|
|
|
|
|
var enTetesConditionnels = []string{
|
|
|
|
|
"If-None-Match", "If-Modified-Since", "If-Match", "If-Unmodified-Since",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// estConditionnelle dit si le client détient déjà une copie de la ressource.
|
|
|
|
|
func estConditionnelle(r *http.Request) bool {
|
|
|
|
|
for _, h := range enTetesConditionnels {
|
|
|
|
|
if r.Header.Get(h) != "" {
|
|
|
|
|
return true
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return false
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// sansCondition rend une COPIE de la requête, ses conditions retirées.
|
|
|
|
|
//
|
|
|
|
|
// Une copie : la requête d'origine est celle du serveur HTTP, et la modifier
|
|
|
|
|
// changerait ce que voit tout ce qui la lit ensuite — le relevé, notamment.
|
|
|
|
|
func sansCondition(r *http.Request) *http.Request {
|
|
|
|
|
out := r.Clone(r.Context())
|
|
|
|
|
for _, h := range enTetesConditionnels {
|
|
|
|
|
out.Header.Del(h)
|
|
|
|
|
}
|
|
|
|
|
return out
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// fetch interroge l'amont.
|
|
|
|
|
//
|
|
|
|
|
// repli dit que l'appelant a de quoi répondre sans l'amont : corps gardé,
|
|
|
|
|
// copie du client ou statut gardé. Avec lui, un amont dont l'établissement
|
|
|
|
|
// vient d'échouer n'est pas retenté : fetch rend aussitôt une erreur qui
|
|
|
|
|
// enveloppe errAmontConnuMuet, et l'appelant sert son repli sans repayer le
|
|
|
|
|
// délai d'établissement. Un pare-feu qui jette les paquets fait attendre ce
|
|
|
|
|
// délai entier à chaque tentative ; le repli, lui, n'a rien à attendre.
|
|
|
|
|
//
|
|
|
|
|
// Sans repli, fetch compose toujours, mémoire ou non : un échec
|
|
|
|
|
// d'établissement passager — un paquet perdu, un miroir qui redémarre —
|
|
|
|
|
// ferait sinon rendre « 504 » à toute requête vers cet hôte pendant la
|
|
|
|
|
// fenêtre, alors que l'amont revenu l'aurait servie.
|
|
|
|
|
//
|
|
|
|
|
// Chaque échec d'établissement est retenu et chaque réponse efface l'hôte,
|
|
|
|
|
// avec ou sans repli : la mémoire ne retient que les établissements manqués.
|
|
|
|
|
func (p *Proxy) fetch(
|
|
|
|
|
r *http.Request, u *url.URL, repli bool,
|
|
|
|
|
) (*http.Response, error) {
|
|
|
|
|
adresse := adresseAmont(u)
|
|
|
|
|
if repli && p.Muets.ConnuMuet(adresse) {
|
|
|
|
|
return nil, fmt.Errorf("%w (%s)", errAmontConnuMuet, adresse)
|
|
|
|
|
}
|
|
|
|
|
out, err := http.NewRequestWithContext(r.Context(), r.Method, u.String(), r.Body)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
copyHeader(out.Header, r.Header)
|
|
|
|
|
// L'identité de l'invité est conservée : certains miroirs répondent
|
|
|
|
|
// différemment selon l'agent, et un paquet servi à un agent n'est pas
|
|
|
|
|
// forcément celui servi à un autre.
|
|
|
|
|
out.Header.Del("Accept-Encoding")
|
|
|
|
|
client := p.Client
|
|
|
|
|
if EstGitSmart(u) {
|
|
|
|
|
client = p.ClientPatient
|
|
|
|
|
}
|
|
|
|
|
resp, err := client.Do(out)
|
|
|
|
|
if err != nil {
|
|
|
|
|
p.Muets.Echec(adresse, err)
|
|
|
|
|
return nil, err
|
|
|
|
|
}
|
|
|
|
|
p.Muets.Reussite(adresse)
|
|
|
|
|
return resp, nil
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
func (p *Proxy) record(l accessLine) {
|
|
|
|
|
p.Log.Write(l)
|
|
|
|
|
if p.Verbose && l.Upstream {
|
|
|
|
|
log.Printf("%s %s -> %s (%s)", l.Method, l.URL, l.Outcome, HumanBytes(l.Bytes))
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// hostOnly retire le port d'une autorité, le SNI n'en portant pas.
|
|
|
|
|
func hostOnly(hostport string) string {
|
|
|
|
|
if h, _, err := net.SplitHostPort(hostport); err == nil {
|
|
|
|
|
return h
|
|
|
|
|
}
|
|
|
|
|
return strings.TrimSuffix(hostport, ":")
|
|
|
|
|
}
|