[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,
|
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=""):
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ import (
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
const version = "0.2.10"
|
const version = "0.2.11"
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var (
|
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) {
|
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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
//
|
//
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue