[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 (
|
|
|
|
|
"bufio"
|
|
|
|
|
"fmt"
|
|
|
|
|
"io"
|
|
|
|
|
"net/url"
|
|
|
|
|
"os"
|
|
|
|
|
"strconv"
|
|
|
|
|
"strings"
|
|
|
|
|
"time"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// « --detient » répond depuis le MAGASIN et non depuis le journal d'accès.
|
|
|
|
|
// Le journal dit qu'un objet est entré un jour ; un objet effacé depuis — par
|
|
|
|
|
// une purge, par âge ou en entier — y garde ses lignes « stored ». Seul le
|
|
|
|
|
// magasin sait ce qui sortira hors ligne.
|
|
|
|
|
//
|
|
|
|
|
// La lecture ne passe pas par Store.Get, qui remet la date du corps à
|
|
|
|
|
// maintenant : le relevé rajeunirait tout ce qu'il regarde, et l'âge du
|
|
|
|
|
// dernier usage ne voudrait plus rien dire. Elle ne lit que des métas (0644)
|
|
|
|
|
// et des casiers (0755) : aucun privilège n'est requis.
|
|
|
|
|
|
|
|
|
|
// Verdicts de « --detient ».
|
|
|
|
|
const (
|
|
|
|
|
// VerdictGarde : un corps 200 est en réserve, il sortira hors ligne.
|
|
|
|
|
VerdictGarde = "garde"
|
|
|
|
|
// VerdictStatut : un statut seul — redirection, refus, 200 d'un HEAD —
|
|
|
|
|
// est en réserve, rejoué sans corps quand l'amont est muet.
|
|
|
|
|
VerdictStatut = "statut"
|
|
|
|
|
// VerdictAbsent : la requête est cachable, rien n'est en réserve.
|
|
|
|
|
VerdictAbsent = "absent"
|
|
|
|
|
// VerdictNonCachable : le magasin ne tiendra jamais cette requête —
|
|
|
|
|
// méthode d'écriture, négociation git, URL illisible, ou HEAD d'une
|
|
|
|
|
// classe qui ne garde pas de statut seul.
|
|
|
|
|
VerdictNonCachable = "non-cachable"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
// Detention dit ce que le magasin tient pour une méthode et une URL.
|
|
|
|
|
type Detention struct {
|
|
|
|
|
Verdict string
|
|
|
|
|
// Statut et StockeLe sont nuls quand rien n'est en réserve.
|
|
|
|
|
Statut int
|
|
|
|
|
StockeLe time.Time
|
|
|
|
|
Classe string
|
|
|
|
|
Methode string
|
|
|
|
|
URL string
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Ligne rend la détention en une ligne séparée par des tabulations :
|
|
|
|
|
// verdict, statut, date de stockage en RFC 3339, classe, méthode, URL. Un
|
|
|
|
|
// champ sans valeur vaut « - », pour que chaque ligne ait six champs.
|
|
|
|
|
func (d Detention) Ligne() string {
|
|
|
|
|
statut, date, classe := "-", "-", "-"
|
|
|
|
|
if d.Statut != 0 {
|
|
|
|
|
statut = strconv.Itoa(d.Statut)
|
|
|
|
|
}
|
|
|
|
|
if !d.StockeLe.IsZero() {
|
|
|
|
|
date = d.StockeLe.UTC().Format(time.RFC3339)
|
|
|
|
|
}
|
|
|
|
|
if d.Classe != "" {
|
|
|
|
|
classe = d.Classe
|
|
|
|
|
}
|
|
|
|
|
return strings.Join(
|
|
|
|
|
[]string{d.Verdict, statut, date, classe, d.Methode, d.URL}, "\t")
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Detenir dit ce que le magasin tient pour une requête, sans rien modifier.
|
|
|
|
|
//
|
|
|
|
|
// Les clés sont calculées comme le service les calcule — classe, méthode
|
|
|
|
|
// cachable, clé portable ou non —, par les mêmes fonctions, et lues dans le
|
|
|
|
|
// même ordre que la branche hors ligne : la clé du corps d'abord, qui ne
|
|
|
|
|
// compte que pour un 200 ; la clé du statut seul (CleStatut) ensuite. Un
|
|
|
|
|
// objet n'est tenu que si son corps a la taille que son méta annonce : c'est
|
|
|
|
|
// la condition à laquelle le service le sert, et un objet que le service
|
|
|
|
|
// refuserait n'est pas en réserve. L'URL est rendue telle que reçue, pour que
|
|
|
|
|
// l'appelant retrouve sa ligne.
|
|
|
|
|
//
|
|
|
|
|
// Un HEAD n'a jamais de corps gardé, et son statut ne se garde que pour une
|
|
|
|
|
// classe volatile sous une clé attachée à l'hôte : ailleurs, il est
|
|
|
|
|
// non-cachable et non absent, sans quoi l'appelant le croirait à remplir et
|
|
|
|
|
// le rejouerait pour rien, indéfiniment. C'est la négation exacte de la
|
|
|
|
|
// condition du statut seul dans Proxy.serve.
|
|
|
|
|
func (s *Store) Detenir(methode, brut string) Detention {
|
|
|
|
|
methode = strings.ToUpper(methode)
|
|
|
|
|
d := Detention{Verdict: VerdictNonCachable, Methode: methode, URL: brut}
|
|
|
|
|
u, err := url.Parse(brut)
|
|
|
|
|
if err != nil || !u.IsAbs() || u.Host == "" {
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
class := Classify(u)
|
|
|
|
|
d.Classe = class.String()
|
|
|
|
|
if !CacheableMethod(methode) || class == ClassNoStore {
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
|
[FIX] hors ligne : HEAD servi, attente NTP levée, étape nommée
Trois défauts qui empêchaient une VM hors ligne d'aboutir. zypper vérifie
chaque dépôt par un HEAD, jamais gardé sous une clé portable : le dépôt était
déclaré invalide alors que le magasin tenait son index, et un HEAD reçoit
désormais les en-têtes du corps du GET gardé. Une image qui attend la
synchronisation NTP avant son étape finale ne démarrait jamais son serveur
ssh, faute de serveur de temps joignable ; l'attente est levée sur une VM
déployée hors ligne. Enfin, une troisième VM en échec nomme l'étape qui l'a
arrêtée, là où le résumé d'une série n'affichait qu'un verdict nu.
--- EN ---
Three faults that kept an offline VM from finishing. zypper checks each
repository with a HEAD, never stored under a portable key: the repository was
declared invalid while the store held its index, and a HEAD now gets the
headers of the stored GET body. An image waiting for NTP synchronisation
before its final stage never started its ssh server, no time server being
reachable; that wait is lifted on a VM deployed offline. Last, a failing third
VM names the step that stopped it, where a series summary showed a bare
verdict.
Assisted-by: Claude Opus 5
2026-09-16 03:00:57 -04:00
|
|
|
s.corpsDuGET(&d, 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
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
d.Verdict = VerdictAbsent
|
|
|
|
|
if m, ok := s.lirePresent(CleDe(methode, u)); ok && !m.StatutSeul() {
|
|
|
|
|
d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() {
|
|
|
|
|
d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt
|
[FIX] hors ligne : HEAD servi, attente NTP levée, étape nommée
Trois défauts qui empêchaient une VM hors ligne d'aboutir. zypper vérifie
chaque dépôt par un HEAD, jamais gardé sous une clé portable : le dépôt était
déclaré invalide alors que le magasin tenait son index, et un HEAD reçoit
désormais les en-têtes du corps du GET gardé. Une image qui attend la
synchronisation NTP avant son étape finale ne démarrait jamais son serveur
ssh, faute de serveur de temps joignable ; l'attente est levée sur une VM
déployée hors ligne. Enfin, une troisième VM en échec nomme l'étape qui l'a
arrêtée, là où le résumé d'une série n'affichait qu'un verdict nu.
--- EN ---
Three faults that kept an offline VM from finishing. zypper checks each
repository with a HEAD, never stored under a portable key: the repository was
declared invalid while the store held its index, and a HEAD now gets the
headers of the stored GET body. An image waiting for NTP synchronisation
before its final stage never started its ssh server, no time server being
reachable; that wait is lifted on a VM deployed offline. Last, a failing third
VM names the step that stopped it, where a series summary showed a bare
verdict.
Assisted-by: Claude Opus 5
2026-09-16 03:00:57 -04:00
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
if methode == "HEAD" {
|
|
|
|
|
s.corpsDuGET(&d, 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
|
|
|
}
|
|
|
|
|
return d
|
|
|
|
|
}
|
|
|
|
|
|
[FIX] hors ligne : HEAD servi, attente NTP levée, étape nommée
Trois défauts qui empêchaient une VM hors ligne d'aboutir. zypper vérifie
chaque dépôt par un HEAD, jamais gardé sous une clé portable : le dépôt était
déclaré invalide alors que le magasin tenait son index, et un HEAD reçoit
désormais les en-têtes du corps du GET gardé. Une image qui attend la
synchronisation NTP avant son étape finale ne démarrait jamais son serveur
ssh, faute de serveur de temps joignable ; l'attente est levée sur une VM
déployée hors ligne. Enfin, une troisième VM en échec nomme l'étape qui l'a
arrêtée, là où le résumé d'une série n'affichait qu'un verdict nu.
--- EN ---
Three faults that kept an offline VM from finishing. zypper checks each
repository with a HEAD, never stored under a portable key: the repository was
declared invalid while the store held its index, and a HEAD now gets the
headers of the stored GET body. An image waiting for NTP synchronisation
before its final stage never started its ssh server, no time server being
reachable; that wait is lifted on a VM deployed offline. Last, a failing third
VM names the step that stopped it, where a series summary showed a bare
verdict.
Assisted-by: Claude Opus 5
2026-09-16 03:00:57 -04:00
|
|
|
// corpsDuGET marque « garde » un HEAD dont le GET de la même URL a son corps
|
|
|
|
|
// en réserve : hors ligne, le service répond au HEAD avec les en-têtes de ce
|
|
|
|
|
// corps, APRÈS le statut gardé du HEAD lui-même. Sans corps, le verdict reste
|
|
|
|
|
// celui que l'appelant a posé.
|
|
|
|
|
func (s *Store) corpsDuGET(d *Detention, u *url.URL) {
|
|
|
|
|
if m, ok := s.lirePresent(CleDe("GET", u)); ok && !m.StatutSeul() {
|
|
|
|
|
d.Verdict, d.Statut, d.StockeLe = VerdictGarde, m.StatutReel(), m.StoredAt
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
[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
|
|
|
// lirePresent rend le méta d'une clé quand l'objet est complet : méta lisible
|
|
|
|
|
// et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié.
|
|
|
|
|
func (s *Store) lirePresent(key string) (*Meta, bool) {
|
|
|
|
|
m, err := s.LireMeta(key)
|
|
|
|
|
if err != nil {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
_, bodyPath := s.paths(key)
|
|
|
|
|
if fi, err := os.Stat(bodyPath); err != nil || fi.Size() != m.Size {
|
|
|
|
|
return nil, false
|
|
|
|
|
}
|
|
|
|
|
return m, true
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// EcrireDetentions lit des lignes « MÉTHODE URL » et écrit une ligne de
|
|
|
|
|
// détention pour chacune, dans l'ordre. Une ligne vide ou commençant par
|
|
|
|
|
// « # » est sautée ; une ligne d'un seul champ vaut « GET <champ> ». Chaque
|
|
|
|
|
// réponse part aussitôt écrite, pour qu'un appelant qui lit au fil de ses
|
|
|
|
|
// questions ne reste pas bloqué.
|
|
|
|
|
func EcrireDetentions(s *Store, entree io.Reader, sortie io.Writer) error {
|
|
|
|
|
sc := bufio.NewScanner(entree)
|
|
|
|
|
// Une URL signée dépasse volontiers les 64 Kio de la ligne par défaut.
|
|
|
|
|
sc.Buffer(make([]byte, 64*1024), 1<<20)
|
|
|
|
|
w := bufio.NewWriter(sortie)
|
|
|
|
|
for sc.Scan() {
|
|
|
|
|
ligne := strings.TrimSpace(sc.Text())
|
|
|
|
|
if ligne == "" || strings.HasPrefix(ligne, "#") {
|
|
|
|
|
continue
|
|
|
|
|
}
|
|
|
|
|
champs := strings.Fields(ligne)
|
|
|
|
|
methode, brut := "GET", champs[0]
|
|
|
|
|
if len(champs) >= 2 {
|
|
|
|
|
methode, brut = champs[0], champs[1]
|
|
|
|
|
}
|
|
|
|
|
fmt.Fprintln(w, s.Detenir(methode, brut).Ligne())
|
|
|
|
|
if err := w.Flush(); err != nil {
|
|
|
|
|
return err
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
return sc.Err()
|
|
|
|
|
}
|