[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:
parent
6a4bd546fc
commit
433fe753c3
8 changed files with 198 additions and 3 deletions
|
|
@ -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=""):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@ import (
|
|||
"time"
|
||||
)
|
||||
|
||||
const version = "0.2.10"
|
||||
const version = "0.2.11"
|
||||
|
||||
func main() {
|
||||
var (
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue