[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,
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=""):

View file

@ -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.

View file

@ -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) {

View file

@ -25,7 +25,7 @@ import (
"time"
)
const version = "0.2.10"
const version = "0.2.11"
func main() {
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) {
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,
)

View file

@ -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 = "<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
// « 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)
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

View file

@ -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(