From 433fe753c3044b9b0ec3fe06e35a3b71d39dce9a Mon Sep 17 00:00:00 2001 From: Mathieu Benoit Date: Wed, 16 Sep 2026 03:00:57 -0400 Subject: [PATCH] =?UTF-8?q?[FIX]=20hors=20ligne=20:=20HEAD=20servi,=20atte?= =?UTF-8?q?nte=20NTP=20lev=C3=A9e,=20=C3=A9tape=20nomm=C3=A9e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- long_test/qemu_cache.py | 14 ++++++- script/qemu/deploy_qemu.py | 14 +++++++ script/qemu_cache/detient.go | 15 +++++++ script/qemu_cache/main.go | 2 +- script/qemu_cache/proxy.go | 13 ++++++ script/qemu_cache/statut_test.go | 41 +++++++++++++++++++ test/test_qemu_cache_charge.py | 68 ++++++++++++++++++++++++++++++++ test/test_qemu_cache_guest.py | 34 ++++++++++++++++ 8 files changed, 198 insertions(+), 3 deletions(-) diff --git a/long_test/qemu_cache.py b/long_test/qemu_cache.py index 25dfbb7..e2d5259 100755 --- a/long_test/qemu_cache.py +++ b/long_test/qemu_cache.py @@ -960,13 +960,17 @@ def contre_epreuve( C'est ce qui distingue un cache d'une simple accélération : sans réseau, le déploiement tient encore sur l'index stocké. + + Un échec note l'étape dans rapport["etape_en_echec"], comme la boucle le + fait pour les deux premières VM : le résumé d'une série la nomme. """ dire("", journal) dire(" ── Contre-épreuve : amont coupé ──", journal) + nom = f"{base}-3" if not couper_lamont(journal, dry_run): + rapport["etape_en_echec"] = f"{nom} : coupure de l'amont" return False try: - nom = f"{base}-3" rapport["vms"].append(nom) ecrire_rapport(rapport) adresse = deployer( @@ -979,11 +983,15 @@ def contre_epreuve( hors_ligne=True, ) if not adresse: + rapport["etape_en_echec"] = f"{nom} : déploiement" return False noter_uuid(rapport, nom, dry_run) if not dry_run and not attendre_ssh(adresse, journal): + rapport["etape_en_echec"] = f"{nom} : ssh" return False ok = poser_les_paquets(adresse, journal, dry_run, distro, charge) + if not ok: + rapport["etape_en_echec"] = f"{nom} : paquets" if ok: dire( " ✓ la troisième VM s'est bâtie sans que le cache joigne" @@ -1364,7 +1372,9 @@ def _boucle(args, rapport, journal, acces, decalage): and ok ) - return _clore(args, rapport, journal, ok) + return _clore( + args, rapport, journal, ok, rapport.pop("etape_en_echec", "") + ) def _clore(args, rapport, journal, ok, etape=""): diff --git a/script/qemu/deploy_qemu.py b/script/qemu/deploy_qemu.py index a482f64..2a5311d 100755 --- a/script/qemu/deploy_qemu.py +++ b/script/qemu/deploy_qemu.py @@ -2535,6 +2535,18 @@ CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS") # d'accord avec le Go compare. OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),) +# Ce qu'une VM déployée l'amont coupé exécute tôt au démarrage. Une image qui +# active systemd-time-wait-sync — celle d'Arch — retient time-sync.target +# jusqu'à la première synchronisation NTP, et « cloud-final » est ordonné +# après : sans serveur de temps joignable, l'étape finale ne démarre jamais, ni +# les clés d'hôte ssh qu'elle génère, et la VM reste sans ssh. L'arrêter lève +# l'attente ; une image qui ne l'active pas n'en voit aucun effet. « bootcmd » +# tourne à l'étape réseau de cloud-init, avant l'étape finale. +OFFLINE_BOOTCMD = [ + "bootcmd:", + " - systemctl stop --no-block systemd-time-wait-sync.service || true", +] + CACHE_CERT_NAME = "erplibre-cache.crt" @@ -2960,6 +2972,8 @@ def build_cloud_config( # Active et démarre SSH quel que soit le nom du service (ssh sur # Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter # sans SSH accessible. + if getattr(args, "offline", False): + lines += OFFLINE_BOOTCMD lines += ["runcmd:"] # En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à # jour un invité rejette le certificat que le cache présente. diff --git a/script/qemu_cache/detient.go b/script/qemu_cache/detient.go index d2c9209..1855430 100644 --- a/script/qemu_cache/detient.go +++ b/script/qemu_cache/detient.go @@ -97,6 +97,7 @@ func (s *Store) Detenir(methode, brut string) Detention { return d } if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) { + s.corpsDuGET(&d, u) return d } d.Verdict = VerdictAbsent @@ -106,10 +107,24 @@ func (s *Store) Detenir(methode, brut string) Detention { } if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() { d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt + return d + } + if methode == "HEAD" { + s.corpsDuGET(&d, u) } return d } +// 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 + } +} + // 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) { diff --git a/script/qemu_cache/main.go b/script/qemu_cache/main.go index c623909..cc51234 100644 --- a/script/qemu_cache/main.go +++ b/script/qemu_cache/main.go @@ -25,7 +25,7 @@ import ( "time" ) -const version = "0.2.10" +const version = "0.2.11" func main() { var ( diff --git a/script/qemu_cache/proxy.go b/script/qemu_cache/proxy.go index fbe771a..7283cde 100644 --- a/script/qemu_cache/proxy.go +++ b/script/qemu_cache/proxy.go @@ -423,6 +423,19 @@ func (p *Proxy) serve(w http.ResponseWriter, r *http.Request, scheme string) { if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) { return } + // Un HEAD sans statut gardé demande les en-têtes que rendrait le GET : + // le corps gardé pour ce GET les porte. zypper vérifie ainsi chaque + // dépôt avant de le lire, et un HEAD ne se garde jamais sous une clé + // portable ; sans ce repli, le dépôt dont le magasin tient l'index est + // déclaré invalide hors ligne. ServeContent n'écrit aucun corps pour + // un HEAD. + if cacheable && r.Method == http.MethodHead { + cleCorps := CleDe(http.MethodGet, u) + if !p.indexIncoherent(u, cleCorps) && + p.serveFromStore(w, r, u, cleCorps, class, OutcomeStale) { + return + } + } p.offlineMiss( w, u, class, r.Method, clientDe(r.RemoteAddr), upErr, ) diff --git a/script/qemu_cache/statut_test.go b/script/qemu_cache/statut_test.go index d8c4e5f..23dd92e 100644 --- a/script/qemu_cache/statut_test.go +++ b/script/qemu_cache/statut_test.go @@ -14,6 +14,7 @@ import ( "net/url" "os" "path/filepath" + "strconv" "strings" "sync" "sync/atomic" @@ -397,6 +398,46 @@ func TestLImmuableNeGardePasSonRefus(t *testing.T) { } } +// Hors ligne, un HEAD sur un index dont le GET a son corps en réserve reçoit +// les en-têtes de ce corps, sans corps : c'est ainsi que zypper juge un dépôt +// valide avant de le lire. Sous une clé portable, aucun statut de HEAD ne se +// garde, et sans ce repli le dépôt entier était déclaré invalide. +func TestHorsLigneUnHeadSortDuCorpsDuGet(t *testing.T) { + const corps = "index" + const chemin = "/distribution/leap/16.0/repo/oss/x86_64/repodata/repomd.xml" + a := nouvelAmontScripte(t, servir(corps)) + p := proxyDeTest(t) + u, _ := url.Parse("http://" + a.hote() + chemin) + if Classify(u) != ClassVolatile || !PortableParChemin(u) { + t.Fatalf("%s n'est plus un index volatil portable", chemin) + } + joue(t, p, "GET", a.hote(), chemin) + joue(t, p, "HEAD", a.hote(), chemin) + base := "http://" + a.hote() + + hote := a.couper() + w := joue(t, p, "HEAD", hote, chemin) + if w.Code != http.StatusOK { + t.Fatalf("HEAD hors ligne : code %d, attendu 200", w.Code) + } + if w.Body.Len() != 0 { + t.Errorf("HEAD hors ligne avec un corps de %d octets", w.Body.Len()) + } + if got := w.Header().Get("Content-Length"); got != strconv.Itoa(len(corps)) { + t.Errorf("longueur %q, attendu celle du corps gardé (%d)", got, len(corps)) + } + if got := w.Header().Get("X-ERPLibre-Cache"); got != OutcomeStale { + t.Errorf("servi « %s », attendu « %s »", got, OutcomeStale) + } + if got := p.Store.Detenir("HEAD", base+chemin).Verdict; got != VerdictGarde { + t.Errorf("--detient dit %q pour un HEAD que le service sert", got) + } + // Sans GET gardé, rien ne change : le HEAD reste un 504. + if w := joue(t, p, "HEAD", hote, "/distribution/autre/repodata/repomd.xml"); w.Code != http.StatusGatewayTimeout { + t.Errorf("HEAD sans corps gardé : code %d, attendu 504", w.Code) + } +} + // Un HEAD garde son statut : un installateur lit la version publiée dans le // « Location » d'un HEAD, et n'a besoin de rien d'autre. // diff --git a/test/test_qemu_cache_charge.py b/test/test_qemu_cache_charge.py index 609d919..4e09504 100644 --- a/test/test_qemu_cache_charge.py +++ b/test/test_qemu_cache_charge.py @@ -250,6 +250,74 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase): self.assertEqual(len(ecrit["vms"]), 1) +class TestLaContreEpreuveNommeSonEtape(unittest.TestCase): + """Une troisième VM en échec doit nommer son étape dans le rapport, comme + les deux premières : le résumé d'une série n'a sinon qu'un « échec ».""" + + def boucler(self, echoue): + import argparse + import json + import tempfile + from unittest import mock + + args = argparse.Namespace( + dry_run=False, + sans_cache=False, + hors_ligne=True, + distro="debian", + version="12", + charge="minimum", + ) + # Les deux premières VM réussissent ; la troisième échoue à l'appel + # nommé, c'est-à-dire au troisième appel de cette fonction. + appels = {"n": 0} + + def selon(nom, reussi): + def f(*a, **k): + appels["n"] += nom == echoue + if nom == echoue and appels["n"] == 3: + return "" if nom == "deployer" else False + return reussi + + return f + + with tempfile.TemporaryDirectory() as rep: + fichier = str(Path(rep) / "rapport.json") + rapport = {"_fichier": fichier, "vms": []} + with mock.patch.object(QC, "dire"), mock.patch.object( + QC, "noter_uuid" + ), mock.patch.object(QC, "eteindre"), mock.patch.object( + QC, "verdict", return_value=True + ), mock.patch.object( + QC, "couper_lamont", return_value=True + ), mock.patch.object( + QC, "rebrancher_lamont", create=True + ), mock.patch.object( + QC, "deployer", side_effect=selon("deployer", "10.0.0.1") + ), mock.patch.object( + QC, "attendre_ssh", side_effect=selon("attendre_ssh", True) + ), mock.patch.object( + QC, + "poser_les_paquets", + side_effect=selon("poser_les_paquets", True), + ): + code = QC._boucle(args, rapport, None, "", 0) + with open(fichier, encoding="utf-8") as fh: + return code, json.load(fh) + + def test_chaque_etape_de_la_troisieme_vm_est_nommee(self): + for etape, mot in ( + ("deployer", "-3 : déploiement"), + ("attendre_ssh", "-3 : ssh"), + ("poser_les_paquets", "-3 : paquets"), + ): + with self.subTest(etape=etape): + code, ecrit = self.boucler(etape) + self.assertEqual(code, 1) + self.assertEqual(ecrit.get("verdict"), "échec") + self.assertIn(mot, ecrit.get("etape_en_echec", "")) + + class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase): """Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long doit le lui demander, comme le formulaire, sans quoi il mesure une VM que diff --git a/test/test_qemu_cache_guest.py b/test/test_qemu_cache_guest.py index a313016..257099d 100644 --- a/test/test_qemu_cache_guest.py +++ b/test/test_qemu_cache_guest.py @@ -26,6 +26,8 @@ import sys import unittest from pathlib import Path +import yaml + RACINE = Path(__file__).resolve().parent.parent sys.path.insert(0, str(RACINE)) @@ -34,7 +36,10 @@ from script.qemu.deploy_qemu import ( # noqa: E402 CACHE_ENV_VARS, CACHE_SUDOERS, CACHE_TRUST, + OFFLINE_BOOTCMD, OFFLINE_ENV_VARS, + build_cloud_config, + build_parser, cache_commands, cache_env_reload, cache_family, @@ -355,6 +360,35 @@ class TestLesVariablesTraversentSudo(unittest.TestCase): fichier.unlink() +class TestLeHorsLigneNAttendPasLHeure(unittest.TestCase): + """Une image qui attend la synchronisation NTP avant « cloud-final » ne + démarre jamais son étape finale sans serveur de temps : ni clés d'hôte, ni + ssh. Une VM déployée hors ligne lève cette attente dès bootcmd.""" + + def config(self, *extra): + args = build_parser().parse_args( + ["--distro", "arch", "--hostname", "vm", *extra] + ) + return yaml.safe_load(build_cloud_config(args, None, [])) + + def test_une_vm_hors_ligne_arrete_l_attente(self): + cmd = self.config("--offline").get("bootcmd") or [] + self.assertTrue( + any("systemd-time-wait-sync" in c and "stop" in c for c in cmd), + cmd, + ) + + def test_la_commande_ne_bloque_ni_n_echoue(self): + """--no-block : bootcmd ne doit pas attendre un travail qui attend + lui-même le réseau ; « || true » : une image sans l'unité continue.""" + for ligne in OFFLINE_BOOTCMD[1:]: + self.assertIn("--no-block", ligne) + self.assertTrue(ligne.rstrip().endswith("|| true")) + + def test_une_vm_en_ligne_garde_sa_synchronisation(self): + self.assertNotIn("bootcmd", self.config()) + + class TestLaLangueDesMessagesDuCache(unittest.TestCase): def test_l_option_du_deploiement_l_emporte(self): self.assertEqual(