[ADD] cache : retirer une seule entrée du magasin, par URL
Rien n'invalidait une entrée dont la somme ne correspond pas : le magasin continuait de la servir, et retélécharger ne changeait rien puisque c'est lui qui répond. Les deux purges existantes ne l'atteignent pas — l'une efface tout, l'autre saute ce qui est récent alors que chaque service remet cette date, si bien qu'un objet empoisonné qui sert ne vieillit jamais. Symétrique de « --detient » : mêmes lignes, mêmes fonctions de clé, mêmes refus, et un objet présent qui résiste est dit refusé plutôt qu'oublié. Mesuré : garde, puis oublié 53080, puis absent. --- EN --- Nothing invalidated an entry whose checksum does not match: the store kept serving it, and re-downloading changed nothing since the store is what answers. Neither existing purge reaches it — one erases everything, the other skips what is recent while every service resets that date, so a poisoned object that serves never ages. Symmetric to "--detient": same lines, same key functions, same refusals, and a present object that resists is reported refused rather than forgotten. Measured: held, then forgotten 53080, then absent. Assisted-by: Claude Opus 5
This commit is contained in:
parent
64a8e6400f
commit
e0624d9aaa
8 changed files with 460 additions and 6 deletions
|
|
@ -986,7 +986,22 @@ def image_fetch_cmd(
|
|||
# Et la cible elle-même, fraîche ou déjà en cache : le cas visé est un
|
||||
# fichier substitué entre deux déploiements, qu'aucun test de présence
|
||||
# ne voit.
|
||||
cmd += f" && {somme(cible)}"
|
||||
#
|
||||
# L'échec nomme le remède, parce que le remède ORDINAIRE ne suffit
|
||||
# pas : cet hôte peut être une VM derrière le cache de
|
||||
# téléchargement, et effacer l'image la fera resservir à l'identique
|
||||
# depuis le magasin. L'entrée s'en retire d'abord — « --purge »
|
||||
# efface tout, et « --purge-older-than » n'atteint jamais un objet
|
||||
# que chaque service rajeunit.
|
||||
aide = (
|
||||
f"rm -f {shlex.quote(cible)} et relancer ;"
|
||||
" derrière un cache de téléchargement, en retirer l'entrée"
|
||||
f" d'abord : printf %s | sudo erplibre_go_qemu_cache --oublie"
|
||||
)
|
||||
cmd += (
|
||||
f" && {{ {somme(cible)} || {{ "
|
||||
f"echo {shlex.quote(aide)} >&2; false; }}; }}"
|
||||
)
|
||||
return cmd
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -1657,7 +1657,9 @@ def pinned_sha256(distro: str) -> str:
|
|||
return NIXOS_IMAGE_SHA256 if distro == "nixos" else ""
|
||||
|
||||
|
||||
def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
|
||||
def verify_pinned_sha256(
|
||||
distro: str, image: Path, dry_run: bool, urls: tuple = ()
|
||||
) -> None:
|
||||
"""Vérifie une image contre la somme que le DÉPÔT porte pour elle.
|
||||
|
||||
Sans rapport avec --verify, qui lit un SHA256SUMS publié par la
|
||||
|
|
@ -1678,6 +1680,20 @@ def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
|
|||
digest.update(morceau)
|
||||
obtenu = digest.hexdigest()
|
||||
if obtenu != attendu:
|
||||
# Le remède ORDINAIRE ne suffit pas derrière le cache : effacer le
|
||||
# fichier le fait retélécharger, et c'est le cache qui resservira les
|
||||
# mêmes octets, indéfiniment. Son entrée se retire d'abord — ni
|
||||
# « --purge », qui efface tout, ni « --purge-older-than », qu'un objet
|
||||
# servi ne vieillit jamais assez pour atteindre.
|
||||
remede = ""
|
||||
if urls:
|
||||
lignes = "\n".join(f"GET {u}" for u in urls)
|
||||
remede = (
|
||||
"\n\n Si un cache de téléchargement est en place, effacer le "
|
||||
"fichier ne suffit pas : retirez-en l'entrée d'abord.\n"
|
||||
f" printf '%s\\n' {shlex.quote(lignes)} \\\n"
|
||||
" | sudo erplibre_go_qemu_cache --oublie"
|
||||
)
|
||||
sys.exit(
|
||||
f"Erreur : l'image {image} ne correspond pas à la somme que le "
|
||||
f"dépôt porte pour elle.\n"
|
||||
|
|
@ -1685,7 +1701,7 @@ def verify_pinned_sha256(distro: str, image: Path, dry_run: bool) -> None:
|
|||
f" obtenu : {obtenu}\n"
|
||||
" Supprimez le fichier pour le retélécharger. S'il revient "
|
||||
"différent, l'amont a republié sous le même tag : le relire "
|
||||
"avant de figer la nouvelle somme."
|
||||
"avant de figer la nouvelle somme." + remede
|
||||
)
|
||||
print(f" Somme sha256 conforme à celle du dépôt ({attendu[:12]}…).")
|
||||
|
||||
|
|
@ -5156,7 +5172,9 @@ def main() -> None:
|
|||
)
|
||||
print(f" Destination : {args.image_path}")
|
||||
download_image(urls, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(
|
||||
args.distro, args.image_path, args.dry_run, tuple(urls)
|
||||
)
|
||||
if do_verify:
|
||||
verify_sha256(url, args.image_path, args.dry_run)
|
||||
print("\nTerminé (téléchargement seul).")
|
||||
|
|
@ -5271,7 +5289,9 @@ def main() -> None:
|
|||
f"\n== 1/5 Image cloud ({args.distro} {args.version} / {code}) =="
|
||||
)
|
||||
download_image(urls, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(args.distro, args.image_path, args.dry_run)
|
||||
verify_pinned_sha256(
|
||||
args.distro, args.image_path, args.dry_run, tuple(urls)
|
||||
)
|
||||
if do_verify:
|
||||
verify_sha256(url, args.image_path, args.dry_run)
|
||||
|
||||
|
|
|
|||
|
|
@ -74,7 +74,8 @@ var anglais = map[string]string{
|
|||
"effacer le miroir de ce dépôt ; il se refera au prochain besoin": "erase this repository's mirror; it is rebuilt when next needed",
|
||||
"dire les exceptions en place, une « MAC nom » par ligne": "list the exceptions in place, one « MAC name » per line",
|
||||
"dire la version, puis sortir": "print the version, then exit",
|
||||
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched",
|
||||
"lire des lignes « MÉTHODE URL » sur l'entrée standard et dire, pour chacune, ce que le magasin tient : une ligne séparée par des tabulations « verdict statut stored_at classe méthode url », verdict garde (corps 200), statut (statut seul, sans corps), absent ou non-cachable. Lecture seule : --cache-dir suffit, sans privilège, et l'âge des objets n'est pas touché": "read « METHOD URL » lines on standard input and say, for each, what the store holds: a tab-separated line « verdict status stored_at class method url », verdict garde (200 body), statut (status only, no body), absent or non-cachable. Read-only: --cache-dir is enough, no privilege, and object ages are not touched",
|
||||
"lire des lignes « MÉTHODE URL » sur l'entrée standard et EFFACER du magasin ce que chacune désigne : le corps ET le statut seul. Dit, par ligne et séparé par des tabulations, « verdict octets classe méthode url », verdict oublié, absent, non-cachable ou refus. Le seul geste qui retire UNE entrée : --purge efface tout, et --purge-older-than n'atteint jamais un objet qui sert, chaque service remettant sa date à maintenant. Écrit : demande le droit du service. Pas d'essai à blanc — --detient EST l'aperçu : il répond aux mêmes lignes, par les mêmes clés, sans rien modifier": "read « METHOD URL » lines on standard input and ERASE from the store what each one designates: the body AND the status-only entry. Says, per line and tab-separated, « verdict bytes class method url », verdict oublié, absent, non-cachable or refus. The only gesture that removes ONE entry: --purge erases everything, and --purge-older-than never reaches an object that serves, every service resetting its date to now. Writes: needs the service's rights. No dry run — --detient IS the preview: it answers the same lines, through the same keys, without modifying anything",
|
||||
"entrée illisible : %v\n": "unreadable input: %v\n",
|
||||
"exceptions illisibles : %v\n": "unreadable exceptions: %v\n",
|
||||
"exception refusée : %v\n": "exception refused: %v\n",
|
||||
|
|
|
|||
|
|
@ -95,6 +95,17 @@ func main() {
|
|||
" seul, sans corps), absent ou non-cachable. Lecture seule :"+
|
||||
" --cache-dir suffit, sans privilège, et l'âge des objets"+
|
||||
" n'est pas touché"))
|
||||
oublie = flag.Bool("oublie", false,
|
||||
T("lire des lignes « MÉTHODE URL » sur l'entrée standard et"+
|
||||
" EFFACER du magasin ce que chacune désigne : le corps ET le"+
|
||||
" statut seul. Dit, par ligne et séparé par des tabulations,"+
|
||||
" « verdict octets classe méthode url », verdict oublié,"+
|
||||
" absent, non-cachable ou refus. Le seul geste qui retire UNE"+
|
||||
" entrée : --purge efface tout, et --purge-older-than"+
|
||||
" n'atteint jamais un objet qui sert, chaque service remettant"+
|
||||
" sa date à maintenant. Écrit : demande le droit du service."+
|
||||
" Pas d'essai à blanc — --detient EST l'aperçu : il répond aux"+
|
||||
" mêmes lignes, par les mêmes clés, sans rien modifier"))
|
||||
)
|
||||
flag.Parse()
|
||||
// Déjà lue dans os.Args avant l'analyse (voir Langue) ; posée ici aussi
|
||||
|
|
@ -117,6 +128,15 @@ func main() {
|
|||
}
|
||||
return
|
||||
}
|
||||
// Au même endroit et pour la même raison : la question ne porte que sur
|
||||
// le magasin. Écrire plutôt que lire ne change pas de quoi elle dépend.
|
||||
if *oublie {
|
||||
store := &Store{Dir: *cacheDir}
|
||||
if err := EcrireOublis(store, os.Stdin, os.Stdout); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "entrée illisible : %v\n", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
bypass := BypassFile{Path: *bypassFile}
|
||||
// Les exceptions entrent dans les règles dès leur RENDU : le service les
|
||||
|
|
|
|||
167
script/qemu_cache/oublie.go
Normal file
167
script/qemu_cache/oublie.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
// © 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"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// « --oublie » efface du magasin ce qu'une méthode et une URL désignent.
|
||||
//
|
||||
// LE MANQUE QU'IL COMBLE. Rien n'invalide une entrée quand la somme d'un
|
||||
// fichier servi ne correspond pas : le magasin continue de le rendre, et
|
||||
// retélécharger ne change rien puisque c'est lui qui répond. Les deux purges
|
||||
// existantes ne l'atteignent pas — « --purge » efface TOUT, et
|
||||
// « --purge-older-than » saute ce dont la date est récente, or Store.Get remet
|
||||
// cette date à maintenant à CHAQUE service. Un objet empoisonné qui continue
|
||||
// d'être servi rajeunit donc à chaque VM et ne vieillit jamais : aucun délai
|
||||
// ne le rattrape.
|
||||
//
|
||||
// SYMÉTRIQUE DE « --detient », et c'est ce qui le rend sûr : mêmes lignes en
|
||||
// entrée, mêmes fonctions de classement et de clé, mêmes verdicts de refus.
|
||||
// Ce que l'un dit « garde », l'autre l'efface ; ce que l'un dit
|
||||
// « non-cachable », l'autre n'a rien à effacer. Deux calculs de clé finiraient
|
||||
// par diverger, et l'on croirait avoir effacé ce que le service sert encore.
|
||||
//
|
||||
// ÉCRITURE : le service écrit ses objets en root, et les effacer demande le
|
||||
// même droit. Un refus est DIT plutôt que compté comme un succès — croire un
|
||||
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
|
||||
|
||||
// Verdicts de « --oublie ».
|
||||
const (
|
||||
// OubliEfface : le magasin tenait quelque chose, il ne l'a plus.
|
||||
OubliEfface = "oublié"
|
||||
// OubliAbsent : la requête est cachable, le magasin ne tenait rien.
|
||||
OubliAbsent = "absent"
|
||||
// OubliNonCachable : le magasin ne tiendra jamais cette requête, il n'y
|
||||
// a donc rien à y effacer.
|
||||
OubliNonCachable = "non-cachable"
|
||||
// OubliRefus : un objet était là et n'a pas pu être retiré — droits,
|
||||
// système de fichiers en lecture seule. Surtout pas « oublié ».
|
||||
OubliRefus = "refus"
|
||||
)
|
||||
|
||||
// Oubli dit ce que le magasin a cessé de tenir pour une méthode et une URL.
|
||||
type Oubli struct {
|
||||
Verdict string
|
||||
// Octets rendus au disque. Nul quand rien n'a été effacé.
|
||||
Octets int64
|
||||
Classe string
|
||||
Methode string
|
||||
URL string
|
||||
}
|
||||
|
||||
// Ligne rend l'oubli en une ligne séparée par des tabulations : verdict,
|
||||
// octets, classe, méthode, URL. Un champ sans valeur vaut « - », pour que
|
||||
// chaque ligne ait cinq champs.
|
||||
func (o Oubli) Ligne() string {
|
||||
octets, classe := "-", "-"
|
||||
if o.Octets != 0 {
|
||||
octets = fmt.Sprintf("%d", o.Octets)
|
||||
}
|
||||
if o.Classe != "" {
|
||||
classe = o.Classe
|
||||
}
|
||||
return strings.Join(
|
||||
[]string{o.Verdict, octets, classe, o.Methode, o.URL}, "\t")
|
||||
}
|
||||
|
||||
// Oublier efface du magasin ce que la requête désigne, et dit ce qu'il en est.
|
||||
//
|
||||
// LES DEUX CLÉS, toujours : le corps (CleDe) et le statut seul (CleStatut)
|
||||
// vivent dans des espaces séparés, et n'en effacer qu'une laisserait le rejeu
|
||||
// resservir une redirection vers l'octet qu'on vient de retirer.
|
||||
//
|
||||
// Le méta part AVEC le corps, comme dans Purger : un méta orphelin ferait
|
||||
// croire à une copie présente, et la lecture échouerait au moment de servir.
|
||||
func (s *Store) Oublier(methode, brut string) Oubli {
|
||||
methode = strings.ToUpper(methode)
|
||||
o := Oubli{Verdict: OubliNonCachable, Methode: methode, URL: brut}
|
||||
u, err := url.Parse(brut)
|
||||
if err != nil || !u.IsAbs() || u.Host == "" {
|
||||
return o
|
||||
}
|
||||
class := Classify(u)
|
||||
o.Classe = class.String()
|
||||
if !CacheableMethod(methode) || class == ClassNoStore {
|
||||
return o
|
||||
}
|
||||
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
|
||||
return o
|
||||
}
|
||||
o.Verdict = OubliAbsent
|
||||
for _, key := range []string{
|
||||
CleDe(methode, u), CleStatut(methode, u),
|
||||
} {
|
||||
octets, etat := s.effacer(key)
|
||||
o.Octets += octets
|
||||
switch etat {
|
||||
case OubliRefus:
|
||||
o.Verdict = OubliRefus
|
||||
case OubliEfface:
|
||||
if o.Verdict != OubliRefus {
|
||||
o.Verdict = OubliEfface
|
||||
}
|
||||
}
|
||||
}
|
||||
return o
|
||||
}
|
||||
|
||||
// effacer retire le corps et le méta d'une clé. Rend (octets rendus, verdict
|
||||
// de cette clé seule).
|
||||
//
|
||||
// Un fichier absent n'est pas une erreur : une clé sans objet est le cas
|
||||
// ordinaire, et deux clés sont essayées pour chaque requête. Un fichier
|
||||
// PRÉSENT qui résiste en est une, et elle remonte.
|
||||
func (s *Store) effacer(key string) (int64, string) {
|
||||
metaPath, bodyPath := s.paths(key)
|
||||
octets := int64(0)
|
||||
etat := OubliAbsent
|
||||
if fi, err := os.Stat(bodyPath); err == nil {
|
||||
if e := os.Remove(bodyPath); e != nil {
|
||||
return 0, OubliRefus
|
||||
}
|
||||
octets = fi.Size()
|
||||
etat = OubliEfface
|
||||
}
|
||||
if _, err := os.Stat(metaPath); err == nil {
|
||||
if e := os.Remove(metaPath); e != nil {
|
||||
return octets, OubliRefus
|
||||
}
|
||||
etat = OubliEfface
|
||||
}
|
||||
return octets, etat
|
||||
}
|
||||
|
||||
// EcrireOublis lit des lignes « MÉTHODE URL » et écrit une ligne d'oubli 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, comme pour « --detient ».
|
||||
func EcrireOublis(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.Oublier(methode, brut).Ligne())
|
||||
if err := w.Flush(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return sc.Err()
|
||||
}
|
||||
188
script/qemu_cache/oublie_test.go
Normal file
188
script/qemu_cache/oublie_test.go
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
// © 2026 TechnoLibre (http://www.technolibre.ca)
|
||||
// License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Le magasin est rempli PAR le service, comme pour « --detient » : c'est la
|
||||
// seule façon de voir une divergence entre le calcul de clé du service et
|
||||
// celui de la commande. Effacer sous une clé que le service n'écrit pas
|
||||
// laisserait l'objet empoisonné en place tout en disant l'avoir retiré.
|
||||
func TestOublieRetireCeQueLeMagasinSert(t *testing.T) {
|
||||
paquet := "/core/os/x86_64/outil-1.0-1-x86_64.pkg.tar.zst"
|
||||
a := nouvelAmontScripte(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/installer.sh":
|
||||
rediriger(http.StatusFound,
|
||||
"https://ailleurs.example/bootstrap.sh")(w, r)
|
||||
default:
|
||||
servir("contenu du paquet")(w, r)
|
||||
}
|
||||
})
|
||||
p := proxyDeTest(t)
|
||||
for _, c := range []string{"/installer.sh", "/depot/etat", paquet} {
|
||||
joue(t, p, "GET", a.hote(), c)
|
||||
}
|
||||
|
||||
base := "http://" + a.hote()
|
||||
questions := []string{
|
||||
"GET " + base + paquet,
|
||||
"GET " + base + "/installer.sh",
|
||||
"GET " + base + "/jamais-vu.sh",
|
||||
"POST " + base + "/depot/etat",
|
||||
"",
|
||||
"# un commentaire",
|
||||
"GET pas-une-url",
|
||||
}
|
||||
var sortie bytes.Buffer
|
||||
if err := EcrireOublis(
|
||||
p.Store, strings.NewReader(strings.Join(questions, "\n")), &sortie,
|
||||
); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
attendus := []struct{ verdict, classe, methode, url string }{
|
||||
{"oublié", "immutable", "GET", base + paquet},
|
||||
// Une redirection est un STATUT SEUL : sans la seconde clé, le rejeu
|
||||
// la resservirait vers l'octet qu'on vient de retirer.
|
||||
{"oublié", "volatile", "GET", base + "/installer.sh"},
|
||||
{"absent", "volatile", "GET", base + "/jamais-vu.sh"},
|
||||
{"non-cachable", "volatile", "POST", base + "/depot/etat"},
|
||||
{"non-cachable", "-", "GET", "pas-une-url"},
|
||||
}
|
||||
lignes := strings.Split(strings.TrimRight(sortie.String(), "\n"), "\n")
|
||||
if len(lignes) != len(attendus) {
|
||||
t.Fatalf("%d lignes, attendu %d :\n%s",
|
||||
len(lignes), len(attendus), sortie.String())
|
||||
}
|
||||
for i, at := range attendus {
|
||||
champs := strings.Split(lignes[i], "\t")
|
||||
if len(champs) != 5 {
|
||||
t.Errorf("ligne %d : %d champs, attendu 5 : %q",
|
||||
i+1, len(champs), lignes[i])
|
||||
continue
|
||||
}
|
||||
if champs[0] != at.verdict || champs[2] != at.classe ||
|
||||
champs[3] != at.methode || champs[4] != at.url {
|
||||
t.Errorf("ligne %d : %q, attendu %s … %s %s %s",
|
||||
i+1, lignes[i], at.verdict, at.classe, at.methode, at.url)
|
||||
}
|
||||
}
|
||||
// Les octets ne sont comptés que pour ce qui est parti.
|
||||
if champs := strings.Split(lignes[0], "\t"); champs[1] == "-" {
|
||||
t.Error("un objet effacé doit dire les octets rendus")
|
||||
}
|
||||
if champs := strings.Split(lignes[2], "\t"); champs[1] != "-" {
|
||||
t.Errorf("rien d'effacé, octets %q", champs[1])
|
||||
}
|
||||
}
|
||||
|
||||
// L'INVARIANT qui rend la commande sûre : ce que « --detient » dit tenir,
|
||||
// « --oublie » l'efface, et « --detient » ne le tient plus. Deux calculs de
|
||||
// clé qui divergeraient se verraient ici, et nulle part ailleurs.
|
||||
func TestCeQueDetientVoitOublieLeRetire(t *testing.T) {
|
||||
paquet := "/core/os/x86_64/outil-2.0-1-x86_64.pkg.tar.zst"
|
||||
a := nouvelAmontScripte(t, servir("contenu du paquet"))
|
||||
p := proxyDeTest(t)
|
||||
joue(t, p, "GET", a.hote(), paquet)
|
||||
|
||||
question := "GET http://" + a.hote() + paquet
|
||||
avant := ligneUnique(t, p, question, EcrireDetentions)
|
||||
if verdict := strings.Split(avant, "\t")[0]; verdict != "garde" {
|
||||
t.Fatalf("le magasin devrait garder : %q", avant)
|
||||
}
|
||||
efface := ligneUnique(t, p, question, EcrireOublis)
|
||||
if verdict := strings.Split(efface, "\t")[0]; verdict != "oublié" {
|
||||
t.Fatalf("l'oubli devrait effacer : %q", efface)
|
||||
}
|
||||
apres := ligneUnique(t, p, question, EcrireDetentions)
|
||||
if verdict := strings.Split(apres, "\t")[0]; verdict != "absent" {
|
||||
t.Fatalf("le magasin tient encore : %q", apres)
|
||||
}
|
||||
}
|
||||
|
||||
// Ce que ni « --purge » ni « --purge-older-than » ne savent faire : retirer
|
||||
// UNE entrée. La voisine reste, et c'est tout l'intérêt — un objet empoisonné
|
||||
// ne doit pas coûter le cache entier.
|
||||
func TestOublieNeTouchePasLaVoisine(t *testing.T) {
|
||||
a := nouvelAmontScripte(t, servir("contenu du paquet"))
|
||||
p := proxyDeTest(t)
|
||||
garde := "/core/os/x86_64/garde-1.0-1-x86_64.pkg.tar.zst"
|
||||
part := "/core/os/x86_64/part-1.0-1-x86_64.pkg.tar.zst"
|
||||
joue(t, p, "GET", a.hote(), garde)
|
||||
joue(t, p, "GET", a.hote(), part)
|
||||
|
||||
ligneUnique(t, p, "GET http://"+a.hote()+part, EcrireOublis)
|
||||
reste := ligneUnique(t, p, "GET http://"+a.hote()+garde, EcrireDetentions)
|
||||
if verdict := strings.Split(reste, "\t")[0]; verdict != "garde" {
|
||||
t.Fatalf("la voisine a disparu : %q", reste)
|
||||
}
|
||||
}
|
||||
|
||||
// Un objet présent qui résiste est un REFUS, jamais un oubli : croire un
|
||||
// magasin nettoyé qui ne l'est pas est la seule issue vraiment mauvaise.
|
||||
func TestUnObjetQuiResisteEstUnRefus(t *testing.T) {
|
||||
if os.Geteuid() == 0 {
|
||||
t.Skip("root efface dans un répertoire en lecture seule")
|
||||
}
|
||||
a := nouvelAmontScripte(t, servir("contenu du paquet"))
|
||||
p := proxyDeTest(t)
|
||||
paquet := "/core/os/x86_64/dur-1.0-1-x86_64.pkg.tar.zst"
|
||||
joue(t, p, "GET", a.hote(), paquet)
|
||||
|
||||
u := "http://" + a.hote() + paquet
|
||||
casier := casierDe(t, p, u)
|
||||
fige(t, casier, 0o500)
|
||||
|
||||
ligne := ligneUnique(t, p, "GET "+u, EcrireOublis)
|
||||
if verdict := strings.Split(ligne, "\t")[0]; verdict != "refus" {
|
||||
t.Fatalf("un effacement impossible doit se dire : %q", ligne)
|
||||
}
|
||||
}
|
||||
|
||||
// ligneUnique pose UNE question et rend la ligne rendue, sans son saut.
|
||||
func ligneUnique(
|
||||
t *testing.T, p *Proxy, question string,
|
||||
ecrire func(*Store, io.Reader, io.Writer) error,
|
||||
) string {
|
||||
t.Helper()
|
||||
var sortie bytes.Buffer
|
||||
if err := ecrire(p.Store, strings.NewReader(question), &sortie); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return strings.TrimRight(sortie.String(), "\n")
|
||||
}
|
||||
|
||||
// casierDe rend le répertoire où le magasin range le corps d'une URL.
|
||||
func casierDe(t *testing.T, p *Proxy, brut string) string {
|
||||
t.Helper()
|
||||
u, err := url.Parse(brut)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, corps := p.Store.paths(CleDe("GET", u))
|
||||
return filepath.Dir(corps)
|
||||
}
|
||||
|
||||
// fige retire le droit d'écrire dans un répertoire, et le rend au nettoyage :
|
||||
// sans cela le répertoire temporaire du test ne pourrait plus être effacé.
|
||||
func fige(t *testing.T, dir string, mode os.FileMode) {
|
||||
t.Helper()
|
||||
avant, err := os.Stat(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(dir, mode); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { os.Chmod(dir, avant.Mode()) })
|
||||
}
|
||||
|
|
@ -403,6 +403,27 @@ class TestLesCommandes(unittest.TestCase):
|
|||
self.assertIn("if [ -s", cmd)
|
||||
self.assertIn("wget", cmd)
|
||||
|
||||
def test_a_substituted_image_names_what_to_do_first(self):
|
||||
"""Cet hôte peut être une VM derrière le cache de téléchargement :
|
||||
effacer l'image la fera resservir à l'identique depuis le magasin.
|
||||
L'entrée s'en retire d'abord, et l'échec le dit — c'est là que
|
||||
l'opérateur se tient quand la somme lâche."""
|
||||
cmd = pve.image_fetch_cmd(
|
||||
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
|
||||
)
|
||||
self.assertIn("--oublie", cmd)
|
||||
self.assertIn("rm -f", cmd)
|
||||
# Vers l'erreur standard : la sortie normale est lue par la machine.
|
||||
self.assertIn(">&2", cmd)
|
||||
|
||||
def test_the_failure_is_still_a_failure(self):
|
||||
"""Un message n'absout pas : « false » garde le code de retour, sans
|
||||
quoi le déploiement continuerait sur une image substituée."""
|
||||
cmd = pve.image_fetch_cmd(
|
||||
"https://x/deb.qcow2", "deb.qcow2", sha256="ab" * 32
|
||||
)
|
||||
self.assertIn("false;", cmd)
|
||||
|
||||
def test_the_internal_bridge_never_touches_a_physical_nic(self):
|
||||
"""Le point le plus important de ce module : ajouter l'interface au
|
||||
pont déplace l'adresse de l'hôte et coupe la session SSH — à distance,
|
||||
|
|
|
|||
|
|
@ -126,6 +126,28 @@ class LImage(unittest.TestCase):
|
|||
"""Leur somme, quand elle existe, vient de leur propre SHA256SUMS."""
|
||||
DQ.verify_pinned_sha256("ubuntu", Path("/inexistant"), False)
|
||||
|
||||
def _refus(self, urls=()):
|
||||
with mock.patch.object(DQ, "open", mock.mock_open(read_data=b"x")):
|
||||
with self.assertRaises(SystemExit) as pris:
|
||||
DQ.verify_pinned_sha256("nixos", Path("/x"), False, urls)
|
||||
return str(pris.exception)
|
||||
|
||||
def test_the_cache_entry_is_named_as_what_must_go_first(self):
|
||||
"""Le remède ordinaire — effacer le fichier — ne suffit PAS derrière
|
||||
le cache : c'est lui qui resservira les mêmes octets, indéfiniment.
|
||||
Ni « --purge », qui efface tout, ni « --purge-older-than », qu'un
|
||||
objet servi rajeunit à chaque fois, ne l'atteignent."""
|
||||
msg = self._refus(("https://miroir.example/nixos.qcow2",))
|
||||
self.assertIn("--oublie", msg)
|
||||
self.assertIn("GET https://miroir.example/nixos.qcow2", msg)
|
||||
|
||||
def test_without_a_url_the_message_stays_short(self):
|
||||
"""Une commande qu'on ne peut pas recopier telle quelle vaut moins
|
||||
que pas de commande du tout."""
|
||||
msg = self._refus()
|
||||
self.assertNotIn("--oublie", msg)
|
||||
self.assertIn("attendu", msg)
|
||||
|
||||
|
||||
class CeQuiSeDitALEcran(unittest.TestCase):
|
||||
def test_the_third_party_origin_is_named(self):
|
||||
|
|
|
|||
Loading…
Reference in a new issue