[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
This commit is contained in:
Mathieu Benoit 2026-09-16 03:00:57 -04:00
parent 6a4bd546fc
commit 433fe753c3
8 changed files with 198 additions and 3 deletions

View file

@ -960,13 +960,17 @@ def contre_epreuve(
C'est ce qui distingue un cache d'une simple accélération : sans réseau, 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é. 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("", journal)
dire(" ── Contre-épreuve : amont coupé ──", journal) dire(" ── Contre-épreuve : amont coupé ──", journal)
nom = f"{base}-3"
if not couper_lamont(journal, dry_run): if not couper_lamont(journal, dry_run):
rapport["etape_en_echec"] = f"{nom} : coupure de l'amont"
return False return False
try: try:
nom = f"{base}-3"
rapport["vms"].append(nom) rapport["vms"].append(nom)
ecrire_rapport(rapport) ecrire_rapport(rapport)
adresse = deployer( adresse = deployer(
@ -979,11 +983,15 @@ def contre_epreuve(
hors_ligne=True, hors_ligne=True,
) )
if not adresse: if not adresse:
rapport["etape_en_echec"] = f"{nom} : déploiement"
return False return False
noter_uuid(rapport, nom, dry_run) noter_uuid(rapport, nom, dry_run)
if not dry_run and not attendre_ssh(adresse, journal): if not dry_run and not attendre_ssh(adresse, journal):
rapport["etape_en_echec"] = f"{nom} : ssh"
return False return False
ok = poser_les_paquets(adresse, journal, dry_run, distro, charge) ok = poser_les_paquets(adresse, journal, dry_run, distro, charge)
if not ok:
rapport["etape_en_echec"] = f"{nom} : paquets"
if ok: if ok:
dire( dire(
" ✓ la troisième VM s'est bâtie sans que le cache joigne" " ✓ 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 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=""): def _clore(args, rapport, journal, ok, etape=""):

View file

@ -2535,6 +2535,18 @@ CACHE_ENV_VARS = ("PIP_CERT", "REQUESTS_CA_BUNDLE", "NODE_EXTRA_CA_CERTS")
# d'accord avec le Go compare. # d'accord avec le Go compare.
OFFLINE_ENV_VARS = (("NPM_CONFIG_AUDIT", "false"),) 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" 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 # 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 # Debian/Ubuntu, sshd sur Fedora/Arch) — sans quoi la VM peut booter
# sans SSH accessible. # sans SSH accessible.
if getattr(args, "offline", False):
lines += OFFLINE_BOOTCMD
lines += ["runcmd:"] lines += ["runcmd:"]
# En TÊTE : ce qui suit peut télécharger, et sans magasin de confiance à # 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. # jour un invité rejette le certificat que le cache présente.

View file

@ -97,6 +97,7 @@ func (s *Store) Detenir(methode, brut string) Detention {
return d return d
} }
if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) { if methode == "HEAD" && (class != ClassVolatile || PortableParChemin(u)) {
s.corpsDuGET(&d, u)
return d return d
} }
d.Verdict = VerdictAbsent 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() { if m, ok := s.lirePresent(CleStatut(methode, u)); ok && m.StatutSeul() {
d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt d.Verdict, d.Statut, d.StockeLe = VerdictStatut, m.StatutReel(), m.StoredAt
return d
}
if methode == "HEAD" {
s.corpsDuGET(&d, u)
} }
return d 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 // 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é. // et corps de la taille annoncée. Ni l'un ni l'autre n'est modifié.
func (s *Store) lirePresent(key string) (*Meta, bool) { func (s *Store) lirePresent(key string) (*Meta, bool) {

View file

@ -25,7 +25,7 @@ import (
"time" "time"
) )
const version = "0.2.10" const version = "0.2.11"
func main() { func main() {
var ( var (

View file

@ -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) { if cacheable && p.rejouerStatut(w, r, u, cleStatut, class) {
return 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( p.offlineMiss(
w, u, class, r.Method, clientDe(r.RemoteAddr), upErr, w, u, class, r.Method, clientDe(r.RemoteAddr), upErr,
) )

View file

@ -14,6 +14,7 @@ import (
"net/url" "net/url"
"os" "os"
"path/filepath" "path/filepath"
"strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic" "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 = "<repomd>index</repomd>"
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 // 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. // « Location » d'un HEAD, et n'a besoin de rien d'autre.
// //

View file

@ -250,6 +250,74 @@ class TestLeRapportSeClotSurUnEchec(unittest.TestCase):
self.assertEqual(len(ecrit["vms"]), 1) 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): class TestLaTroisiemeVmNaitHorsLigne(unittest.TestCase):
"""Le déploiement coupe l'audit de npm d'une VM hors ligne : le test long """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 doit le lui demander, comme le formulaire, sans quoi il mesure une VM que

View file

@ -26,6 +26,8 @@ import sys
import unittest import unittest
from pathlib import Path from pathlib import Path
import yaml
RACINE = Path(__file__).resolve().parent.parent RACINE = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(RACINE)) sys.path.insert(0, str(RACINE))
@ -34,7 +36,10 @@ from script.qemu.deploy_qemu import ( # noqa: E402
CACHE_ENV_VARS, CACHE_ENV_VARS,
CACHE_SUDOERS, CACHE_SUDOERS,
CACHE_TRUST, CACHE_TRUST,
OFFLINE_BOOTCMD,
OFFLINE_ENV_VARS, OFFLINE_ENV_VARS,
build_cloud_config,
build_parser,
cache_commands, cache_commands,
cache_env_reload, cache_env_reload,
cache_family, cache_family,
@ -355,6 +360,35 @@ class TestLesVariablesTraversentSudo(unittest.TestCase):
fichier.unlink() 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): class TestLaLangueDesMessagesDuCache(unittest.TestCase):
def test_l_option_du_deploiement_l_emporte(self): def test_l_option_du_deploiement_l_emporte(self):
self.assertEqual( self.assertEqual(