From e0624d9aaa420f7fd5e2916337ea069ab3f2f203 Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 00:43:23 -0400 Subject: [PATCH] =?UTF-8?q?[ADD]=20cache=20:=20retirer=20une=20seule=20ent?= =?UTF-8?q?r=C3=A9e=20du=20magasin,=20par=20URL?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- script/proxmox/proxmox_deploy.py | 17 ++- script/qemu/deploy_qemu.py | 28 ++++- script/qemu_cache/catalogue_en.go | 3 +- script/qemu_cache/main.go | 20 ++++ script/qemu_cache/oublie.go | 167 ++++++++++++++++++++++++++ script/qemu_cache/oublie_test.go | 188 ++++++++++++++++++++++++++++++ test/test_proxmox_deploy.py | 21 ++++ test/test_qemu_nixos.py | 22 ++++ 8 files changed, 460 insertions(+), 6 deletions(-) create mode 100644 script/qemu_cache/oublie.go create mode 100644 script/qemu_cache/oublie_test.go diff --git a/script/proxmox/proxmox_deploy.py b/script/proxmox/proxmox_deploy.py index 2913ce0..9784c77 100644 --- a/script/proxmox/proxmox_deploy.py +++ b/script/proxmox/proxmox_deploy.py @@ -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 diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index d40edc8..faba732 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -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) diff --git a/script/qemu_cache/catalogue_en.go b/script/qemu_cache/catalogue_en.go index a6c9b1c..7aa501a 100644 --- a/script/qemu_cache/catalogue_en.go +++ b/script/qemu_cache/catalogue_en.go @@ -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", diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index f5e86ae..07e1152 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -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 diff --git a/script/qemu_cache/oublie.go b/script/qemu_cache/oublie.go new file mode 100644 index 0000000..79b8373 --- /dev/null +++ b/script/qemu_cache/oublie.go @@ -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 ». 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() +} diff --git a/script/qemu_cache/oublie_test.go b/script/qemu_cache/oublie_test.go new file mode 100644 index 0000000..b59adfb --- /dev/null +++ b/script/qemu_cache/oublie_test.go @@ -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()) }) +} diff --git a/test/test_proxmox_deploy.py b/test/test_proxmox_deploy.py index b4065bb..cf45906 100644 --- a/test/test_proxmox_deploy.py +++ b/test/test_proxmox_deploy.py @@ -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, diff --git a/test/test_qemu_nixos.py b/test/test_qemu_nixos.py index 927ec51..f2dc553 100644 --- a/test/test_qemu_nixos.py +++ b/test/test_qemu_nixos.py @@ -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):