1669 lines
64 KiB
Python
1669 lines
64 KiB
Python
|
|
#!/usr/bin/env python3
|
|||
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|||
|
|
|
|||
|
|
"""Savoir avant de couper ce qui a manqué ; le combler tant qu'on est en ligne.
|
|||
|
|
|
|||
|
|
Un déploiement hors ligne qui a manqué une adresse la manquera encore au
|
|||
|
|
suivant, si rien ne l'a remplie entre-temps. Le journal d'accès le sait —
|
|||
|
|
chaque « offline-miss » porte son client et son instant — et le suivi des
|
|||
|
|
installations sait quelle VM portait quelle adresse, et quand. Réunis, ils
|
|||
|
|
disent quoi annoncer avant la coupure, et quoi rejouer par le cache tant que
|
|||
|
|
l'amont répond.
|
|||
|
|
|
|||
|
|
Aucun test ne touche la machine qui les exécute : ni pare-feu, ni service, ni
|
|||
|
|
réseau. Toute commande est interceptée pour la CLASSE entière, et la doublure
|
|||
|
|
ne relaie jamais vers la vraie : un chemin qui lancerait une commande imprévue
|
|||
|
|
échoue sur une réponse vide au lieu d'agir.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import contextlib
|
|||
|
|
import io
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import re
|
|||
|
|
import shutil
|
|||
|
|
import stat
|
|||
|
|
import subprocess
|
|||
|
|
import sys
|
|||
|
|
import tempfile
|
|||
|
|
import time
|
|||
|
|
import unittest
|
|||
|
|
from pathlib import Path
|
|||
|
|
from unittest import mock
|
|||
|
|
|
|||
|
|
RACINE = Path(__file__).resolve().parent.parent
|
|||
|
|
sys.path.insert(0, str(RACINE))
|
|||
|
|
|
|||
|
|
from script.qemu import cache_offline # noqa: E402
|
|||
|
|
from script.todo import qemu_cache_menu as menu # noqa: E402
|
|||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M # noqa: E402
|
|||
|
|
from script.todo.qemu_install_monitor import EXIT_MARKER # noqa: E402
|
|||
|
|
from script.todo.todo_i18n import t # noqa: E402
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import textual # noqa: F401
|
|||
|
|
|
|||
|
|
TEXTUAL = True
|
|||
|
|
except ImportError:
|
|||
|
|
TEXTUAL = False
|
|||
|
|
|
|||
|
|
INSTALLATEUR = RACINE / "script" / "install" / "install_qemu_cache.sh"
|
|||
|
|
QEMU_FORM = RACINE / "script" / "todo" / "qemu_deploy_form.py"
|
|||
|
|
|
|||
|
|
# Des valeurs inventées : un nom de VM qu'aucun parc ne porte, des adresses
|
|||
|
|
# de documentation (RFC 5737) et des domaines réservés (RFC 2606).
|
|||
|
|
MAINTENANT = 1_900_000_000.0
|
|||
|
|
NOM = "vm-essai-hl"
|
|||
|
|
AUTRE = "vm-essai-autre"
|
|||
|
|
IP = "192.0.2.10"
|
|||
|
|
URL_A = "https://example.com/depot/a.tar.gz?v=1&w=2"
|
|||
|
|
URL_A2 = "https://example.com/depot/cible.tar.gz"
|
|||
|
|
URL_B = "http://example.org/liste"
|
|||
|
|
URL_C = "https://example.net/racine/16.json"
|
|||
|
|
URL_POST = "https://example.net/api/stats"
|
|||
|
|
URL_IP = "https://192.0.2.99/objet"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def iso(instant):
|
|||
|
|
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(instant))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def ligne(instant, issue, url, methode="GET", client=IP):
|
|||
|
|
"""Une ligne du journal d'accès, telle que le service l'écrit."""
|
|||
|
|
return {
|
|||
|
|
"time": iso(instant),
|
|||
|
|
"method": methode,
|
|||
|
|
"url": url,
|
|||
|
|
"class": "volatile",
|
|||
|
|
"outcome": issue,
|
|||
|
|
"status": 504 if issue == "offline-miss" else 200,
|
|||
|
|
"bytes": 0,
|
|||
|
|
"upstream": False,
|
|||
|
|
"client": client,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
class SansSysteme(unittest.TestCase):
|
|||
|
|
"""subprocess.run est remplacé pour toute la classe, sans relais.
|
|||
|
|
|
|||
|
|
La doublure note chaque appel et répond par « repondre », qui rend par
|
|||
|
|
défaut un échec vide. Le jour où le code sous test lance une commande
|
|||
|
|
que le test n'attend pas, il reçoit ce vide — jamais la machine réelle.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
self.lancees = []
|
|||
|
|
self.tmp = tempfile.TemporaryDirectory()
|
|||
|
|
self.addCleanup(self.tmp.cleanup)
|
|||
|
|
self.dossier = Path(self.tmp.name)
|
|||
|
|
|
|||
|
|
def doublure(cmd, *a, **kw):
|
|||
|
|
texte = cmd if isinstance(cmd, str) else " ".join(map(str, cmd))
|
|||
|
|
self.lancees.append((texte, kw))
|
|||
|
|
rc, sortie = self.repondre(texte, kw)
|
|||
|
|
return subprocess.CompletedProcess(cmd, rc, sortie, "")
|
|||
|
|
|
|||
|
|
patch = mock.patch("subprocess.run", doublure)
|
|||
|
|
patch.start()
|
|||
|
|
self.addCleanup(patch.stop)
|
|||
|
|
|
|||
|
|
def repondre(self, texte, kw):
|
|||
|
|
return 127, ""
|
|||
|
|
|
|||
|
|
def journal(self, lignes):
|
|||
|
|
chemin = self.dossier / "acces.jsonl"
|
|||
|
|
with open(chemin, "w", encoding="utf-8") as fh:
|
|||
|
|
for d in lignes:
|
|||
|
|
# Compact, comme le json.Marshal de Go.
|
|||
|
|
fh.write(json.dumps(d, separators=(",", ":")) + "\n")
|
|||
|
|
return str(chemin)
|
|||
|
|
|
|||
|
|
def deploiement(
|
|||
|
|
self,
|
|||
|
|
label,
|
|||
|
|
debut,
|
|||
|
|
vms,
|
|||
|
|
branche="develop",
|
|||
|
|
deploy_started=None,
|
|||
|
|
hors_ligne=None,
|
|||
|
|
):
|
|||
|
|
"""Un répertoire de déploiement : session.json et un log par VM.
|
|||
|
|
|
|||
|
|
vms : [(nom, ip, fin, code)]. « fin » date le log ; un code None le
|
|||
|
|
laisse sans marqueur de sortie, comme une installation en cours.
|
|||
|
|
« deploy_started » et « hors_ligne », quand ils sont donnés, sont
|
|||
|
|
écrits au manifeste comme le fait le suivi des installations — le
|
|||
|
|
second sous « offline ».
|
|||
|
|
"""
|
|||
|
|
d = self.dossier / "runs" / label
|
|||
|
|
d.mkdir(parents=True)
|
|||
|
|
entrees = []
|
|||
|
|
for nom, ip, fin, code in vms:
|
|||
|
|
log = d / f"{nom}.log"
|
|||
|
|
texte = "en-tête\n"
|
|||
|
|
if code is not None:
|
|||
|
|
texte += f"{EXIT_MARKER} {code}\n"
|
|||
|
|
log.write_text(texte)
|
|||
|
|
os.utime(log, (fin, fin))
|
|||
|
|
entrees.append({"name": nom, "ip": ip, "log": str(log)})
|
|||
|
|
manifeste = {"branch": branche, "started": debut, "vms": entrees}
|
|||
|
|
if deploy_started is not None:
|
|||
|
|
manifeste["deploy_started"] = deploy_started
|
|||
|
|
if hors_ligne is not None:
|
|||
|
|
manifeste["offline"] = hors_ligne
|
|||
|
|
(d / "session.json").write_text(json.dumps(manifeste))
|
|||
|
|
return str(self.dossier / "runs")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Le choix des déploiements qui renseignent
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLeBilanDesDeploiements(SansSysteme):
|
|||
|
|
"""Quels déploiements parlent, et pour quelle VM.
|
|||
|
|
|
|||
|
|
Deux pannes muettes sont gardées ici. Prendre le seul dernier déploiement
|
|||
|
|
rend l'avertissement muet dès qu'il s'est arrêté avant d'avoir rien
|
|||
|
|
demandé. Et une adresse IP se réattribue : sans la fenêtre du déploiement,
|
|||
|
|
les manques d'une VM seraient comptés à une autre.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def bilan(self, racine, lignes, nom=NOM):
|
|||
|
|
runs = cache_offline.lire_runs(racine)
|
|||
|
|
releve = cache_offline.releve_amont_muet(
|
|||
|
|
self.journal(lignes), {IP, "192.0.2.11"}
|
|||
|
|
)
|
|||
|
|
return cache_offline.bilan_du_nom(runs, nom, releve, MAINTENANT)
|
|||
|
|
|
|||
|
|
def test_les_deploiements_renseignes_sont_reunis(self):
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)])
|
|||
|
|
self.deploiement("recent", t2 - 600, [(NOM, IP, t2, 1)])
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(t2 - 300, "offline-miss", URL_B),
|
|||
|
|
ligne(t2 - 200, "offline-miss", URL_A),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertEqual(set(b["manques"]), {("GET", URL_A), ("GET", URL_B)})
|
|||
|
|
self.assertEqual(b["runs"], ["recent", "ancien"])
|
|||
|
|
self.assertAlmostEqual(b["age"], 3600, delta=1)
|
|||
|
|
|
|||
|
|
def test_un_deploiement_arrete_tot_ne_renseigne_pas(self):
|
|||
|
|
"""Coupé, arrêté sur un verrou apt avant d'avoir rien manqué : il ne
|
|||
|
|
dit rien, et ne doit pas faire taire le précédent."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)])
|
|||
|
|
self.deploiement("verrou", t2 - 600, [(NOM, IP, t2, 100)])
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(t2 - 300, "stale", URL_B),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertIsNotNone(b, "l'arrêt précoce a fait taire l'avertissement")
|
|||
|
|
self.assertEqual(b["runs"], ["ancien"])
|
|||
|
|
self.assertEqual(list(b["manques"]), [("GET", URL_A)])
|
|||
|
|
|
|||
|
|
def test_une_reussite_hors_ligne_clot_la_lecture(self):
|
|||
|
|
"""Réussi coupé : ce qui manquait avant a été rempli, ou n'est plus
|
|||
|
|
demandé. Le rappeler serait un avertissement sans motif."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)])
|
|||
|
|
self.deploiement(
|
|||
|
|
"reussi", t2 - 600, [(NOM, IP, t2, 0)], hors_ligne=True
|
|||
|
|
)
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(t2 - 300, "stale", URL_B),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertIsNone(b)
|
|||
|
|
|
|||
|
|
def test_une_reussite_quon_ne_sait_pas_coupee_ne_clot_rien(self):
|
|||
|
|
"""Un amont qui vient de refuser une connexion est tenu pour muet
|
|||
|
|
quelques secondes : des lignes « stale » naissent alors EN LIGNE. Une
|
|||
|
|
réussite n'est une preuve hors ligne que si le manifeste le dit."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
for hors_ligne in (False, None):
|
|||
|
|
with self.subTest(hors_ligne=hors_ligne):
|
|||
|
|
racine = self.deploiement(
|
|||
|
|
f"ancien-{hors_ligne}", t1 - 600, [(NOM, IP, t1, 1)]
|
|||
|
|
)
|
|||
|
|
self.deploiement(
|
|||
|
|
f"recent-{hors_ligne}",
|
|||
|
|
t2 - 600,
|
|||
|
|
[(NOM, IP, t2, 0)],
|
|||
|
|
hors_ligne=hors_ligne,
|
|||
|
|
)
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(t2 - 300, "stale", URL_B),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertIsNotNone(b, "une réussite en ligne a tout tu")
|
|||
|
|
self.assertEqual(list(b["manques"]), [("GET", URL_A)])
|
|||
|
|
shutil.rmtree(racine)
|
|||
|
|
|
|||
|
|
def test_le_hors_ligne_du_manifeste_est_lu(self):
|
|||
|
|
"""Un booléen seul fait foi : toute autre valeur ne dit rien."""
|
|||
|
|
for ecrit, lu in ((True, True), (False, False), ("oui", None)):
|
|||
|
|
with self.subTest(ecrit=ecrit):
|
|||
|
|
racine = self.deploiement(
|
|||
|
|
"seul",
|
|||
|
|
MAINTENANT - 600,
|
|||
|
|
[(NOM, IP, MAINTENANT, 1)],
|
|||
|
|
hors_ligne=ecrit,
|
|||
|
|
)
|
|||
|
|
runs = cache_offline.lire_runs(racine)
|
|||
|
|
self.assertIs(runs[0]["hors_ligne"], lu)
|
|||
|
|
shutil.rmtree(racine)
|
|||
|
|
racine = self.deploiement(
|
|||
|
|
"sans", MAINTENANT - 600, [(NOM, IP, MAINTENANT, 1)]
|
|||
|
|
)
|
|||
|
|
self.assertIsNone(cache_offline.lire_runs(racine)[0]["hors_ligne"])
|
|||
|
|
|
|||
|
|
def test_une_reussite_hors_ligne_garde_ses_propres_manques(self):
|
|||
|
|
"""Les blocs d'outils facultatifs préviennent et rendent 0 : une
|
|||
|
|
installation coupée qui en a manqué plusieurs réussit quand même.
|
|||
|
|
Ses manques parlent ; seuls les déploiements plus anciens se
|
|||
|
|
taisent."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)])
|
|||
|
|
self.deploiement(
|
|||
|
|
"reussi", t2 - 600, [(NOM, IP, t2, 0)], hors_ligne=True
|
|||
|
|
)
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(t2 - 400, "stale", URL_C),
|
|||
|
|
ligne(t2 - 300, "offline-miss", URL_B),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertIsNotNone(b, "les manques d'une réussite sont perdus")
|
|||
|
|
self.assertEqual(b["runs"], ["reussi"])
|
|||
|
|
self.assertEqual(list(b["manques"]), [("GET", URL_B)])
|
|||
|
|
|
|||
|
|
def test_la_fenetre_souvre_au_debut_du_deploiement(self):
|
|||
|
|
"""Le premier démarrage d'une VM coupée passe par le cache avant le
|
|||
|
|
lancement des installations : « deploy_started » ouvre la fenêtre
|
|||
|
|
là, et un manque de ce démarrage est attribué."""
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement(
|
|||
|
|
"seul", fin - 600, [(NOM, IP, fin, 1)], deploy_started=fin - 1500
|
|||
|
|
)
|
|||
|
|
b = self.bilan(racine, [ligne(fin - 1200, "offline-miss", URL_A)])
|
|||
|
|
self.assertIsNotNone(b, "le manque du premier démarrage est perdu")
|
|||
|
|
self.assertEqual(list(b["manques"]), [("GET", URL_A)])
|
|||
|
|
|
|||
|
|
def test_sans_deploy_started_la_fenetre_souvre_au_lancement(self):
|
|||
|
|
"""Un manifeste qui ne le porte pas garde la règle d'avant : le plus
|
|||
|
|
tôt du répertoire et de « started »."""
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
self.assertIsNone(
|
|||
|
|
self.bilan(racine, [ligne(fin - 1200, "offline-miss", URL_A)])
|
|||
|
|
)
|
|||
|
|
self.assertIsNotNone(
|
|||
|
|
self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)])
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_reussite_en_ligne_ne_clot_rien(self):
|
|||
|
|
"""Réussi amont branché : il ne prouve rien du hors ligne."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("ancien", t1 - 600, [(NOM, IP, t1, 1)])
|
|||
|
|
self.deploiement("en_ligne", t2 - 600, [(NOM, IP, t2, 0)])
|
|||
|
|
b = self.bilan(racine, [ligne(t1 - 300, "offline-miss", URL_A)])
|
|||
|
|
self.assertIsNotNone(b)
|
|||
|
|
self.assertEqual(b["runs"], ["ancien"])
|
|||
|
|
|
|||
|
|
def test_la_cle_est_le_nom_et_la_fenetre(self):
|
|||
|
|
"""La même adresse, portée par deux VM à deux moments."""
|
|||
|
|
t1, t2 = MAINTENANT - 7200, MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("autre", t1 - 600, [(AUTRE, IP, t1, 1)])
|
|||
|
|
self.deploiement("la_notre", t2 - 600, [(NOM, IP, t2, 1)])
|
|||
|
|
lignes = [
|
|||
|
|
ligne(t1 - 300, "offline-miss", URL_C),
|
|||
|
|
ligne(t2 - 300, "offline-miss", URL_A),
|
|||
|
|
]
|
|||
|
|
self.assertEqual(
|
|||
|
|
list(self.bilan(racine, lignes)["manques"]), [("GET", URL_A)]
|
|||
|
|
)
|
|||
|
|
self.assertEqual(
|
|||
|
|
list(self.bilan(racine, lignes, nom=AUTRE)["manques"]),
|
|||
|
|
[("GET", URL_C)],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_la_fenetre_ecarte_avant_et_apres(self):
|
|||
|
|
"""Une ligne écrite après la fin du log vient d'un autre geste sur la
|
|||
|
|
VM, pas du déploiement."""
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
b = self.bilan(
|
|||
|
|
racine,
|
|||
|
|
[
|
|||
|
|
ligne(fin - 900, "offline-miss", URL_B),
|
|||
|
|
ligne(fin - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(fin + 60, "offline-miss", URL_C),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertEqual(list(b["manques"]), [("GET", URL_A)])
|
|||
|
|
|
|||
|
|
def test_le_debut_est_le_plus_tot_des_deux(self):
|
|||
|
|
"""« started » est écrit après le lancement de toutes les VM ; le nom
|
|||
|
|
du répertoire date le début du lancement."""
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
label = time.strftime("%Y%m%d-%H%M%S", time.localtime(fin - 900))
|
|||
|
|
racine = self.deploiement(label, fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
b = self.bilan(racine, [ligne(fin - 800, "offline-miss", URL_A)])
|
|||
|
|
self.assertIsNotNone(b, "la première requête a été perdue")
|
|||
|
|
|
|||
|
|
def test_trop_vieux_se_tait(self):
|
|||
|
|
fin = MAINTENANT - cache_offline.AGE_MAX - 60
|
|||
|
|
racine = self.deploiement("vieux", fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
self.assertIsNone(
|
|||
|
|
self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)])
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_le_nombre_de_deploiements_est_borne(self):
|
|||
|
|
lignes = []
|
|||
|
|
racine = ""
|
|||
|
|
for i in range(4):
|
|||
|
|
fin = MAINTENANT - 3600 * (i + 1)
|
|||
|
|
racine = self.deploiement(f"r{i}", fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
lignes.append(ligne(fin - 300, "offline-miss", f"{URL_B}/{i}"))
|
|||
|
|
b = self.bilan(racine, lignes)
|
|||
|
|
self.assertEqual(len(b["runs"]), cache_offline.RUNS_RETENUS)
|
|||
|
|
|
|||
|
|
def test_sans_branche_rien_nest_installe(self):
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement(
|
|||
|
|
"demarrage", fin - 600, [(NOM, IP, fin, 1)], branche=""
|
|||
|
|
)
|
|||
|
|
self.assertIsNone(
|
|||
|
|
self.bilan(racine, [ligne(fin - 300, "offline-miss", URL_A)])
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Ce que le magasin détient, ou le journal à défaut
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestCeQueLeMagasinDetient(SansSysteme):
|
|||
|
|
"""Le bilan moins ce que le cache détient MAINTENANT."""
|
|||
|
|
|
|||
|
|
def preflight(self, detient, extra=()):
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("seul", fin - 600, [(NOM, IP, fin, 1)])
|
|||
|
|
lignes = [
|
|||
|
|
ligne(fin - 400, "offline-miss", URL_A),
|
|||
|
|
ligne(fin - 350, "offline-miss", URL_B),
|
|||
|
|
ligne(fin - 300, "offline-miss", URL_POST, methode="POST"),
|
|||
|
|
ligne(fin - 250, "offline-miss", URL_C, methode="HEAD"),
|
|||
|
|
] + list(extra)
|
|||
|
|
return cache_offline.manques_hors_ligne(
|
|||
|
|
[{"name": NOM}],
|
|||
|
|
racine=racine,
|
|||
|
|
chemin=self.journal(lignes),
|
|||
|
|
maintenant=MAINTENANT,
|
|||
|
|
detient=detient,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_le_magasin_soustrait_ce_quil_detient(self):
|
|||
|
|
demandes = []
|
|||
|
|
|
|||
|
|
def detient(paires):
|
|||
|
|
demandes.extend(paires)
|
|||
|
|
return {
|
|||
|
|
("GET", URL_A): {"verdict": "garde"},
|
|||
|
|
("HEAD", URL_C): {"verdict": "statut"},
|
|||
|
|
("GET", URL_B): {"verdict": "absent"},
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
(b,) = self.preflight(detient)
|
|||
|
|
self.assertEqual(b["manquants"], [("GET", URL_B)])
|
|||
|
|
self.assertFalse(b["selon_journal"])
|
|||
|
|
self.assertNotIn(
|
|||
|
|
("POST", URL_POST), demandes, "un POST n'a rien à demander"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_post_nest_pas_compte_mais_nomme(self):
|
|||
|
|
(b,) = self.preflight(lambda paires: {})
|
|||
|
|
self.assertNotIn(("POST", URL_POST), b["manquants"])
|
|||
|
|
self.assertEqual(b["jamais"], [("POST", URL_POST)])
|
|||
|
|
|
|||
|
|
def test_ce_que_le_cache_ne_garde_pas_va_avec_les_post(self):
|
|||
|
|
(b,) = self.preflight(
|
|||
|
|
lambda paires: {("GET", URL_B): {"verdict": "non-cachable"}}
|
|||
|
|
)
|
|||
|
|
self.assertIn(("GET", URL_B), b["jamais"])
|
|||
|
|
self.assertNotIn(("GET", URL_B), b["manquants"])
|
|||
|
|
|
|||
|
|
def test_une_negociation_git_nomme_son_depot_a_part(self):
|
|||
|
|
"""Un GET de négociation n'est ni un POST ni une adresse à
|
|||
|
|
rejouer : c'est un dépôt que le miroir remplit. Compté par dépôt, et
|
|||
|
|
jamais demandé au magasin."""
|
|||
|
|
depot = "https://example.com/o/d.git"
|
|||
|
|
demandes = []
|
|||
|
|
|
|||
|
|
def detient(paires):
|
|||
|
|
demandes.extend(paires)
|
|||
|
|
return {}
|
|||
|
|
|
|||
|
|
(b,) = self.preflight(
|
|||
|
|
detient,
|
|||
|
|
extra=[
|
|||
|
|
ligne(
|
|||
|
|
MAINTENANT - 3900,
|
|||
|
|
"offline-miss",
|
|||
|
|
f"{depot}/info/refs?service=git-upload-pack",
|
|||
|
|
),
|
|||
|
|
ligne(
|
|||
|
|
MAINTENANT - 3850,
|
|||
|
|
"offline-miss",
|
|||
|
|
f"{depot}/git-upload-pack",
|
|||
|
|
methode="POST",
|
|||
|
|
),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertEqual(b["git"], [depot])
|
|||
|
|
for cle in b["jamais"] + b["manquants"] + demandes:
|
|||
|
|
self.assertNotIn(depot, cle[1], cle)
|
|||
|
|
self.assertEqual(b["jamais"], [("POST", URL_POST)])
|
|||
|
|
|
|||
|
|
def test_selon_le_journal_une_negociation_git_nest_pas_comblable(self):
|
|||
|
|
"""Sans « --detient », le journal ne dit rien d'une négociation : la
|
|||
|
|
compter parmi les manquants promettrait un rejeu qui ne garde rien."""
|
|||
|
|
url = "https://example.com/o/d.git/info/refs?service=git-upload-pack"
|
|||
|
|
(b,) = self.preflight(
|
|||
|
|
lambda paires: None,
|
|||
|
|
extra=[ligne(MAINTENANT - 3900, "offline-miss", url)],
|
|||
|
|
)
|
|||
|
|
self.assertNotIn(("GET", url), b["manquants"])
|
|||
|
|
self.assertEqual(b["git"], ["https://example.com/o/d.git"])
|
|||
|
|
|
|||
|
|
def test_le_depot_dune_negociation(self):
|
|||
|
|
for url, attendu in (
|
|||
|
|
(
|
|||
|
|
"https://example.com/o/d.git/info/refs?service=x",
|
|||
|
|
"https://example.com/o/d.git",
|
|||
|
|
),
|
|||
|
|
("http://example.org/d/git-receive-pack", "http://example.org/d"),
|
|||
|
|
("https://example.com/info/refs", "https://example.com/"),
|
|||
|
|
("https://example.com/o/d.git/HEAD", ""),
|
|||
|
|
("", ""),
|
|||
|
|
):
|
|||
|
|
self.assertEqual(cache_offline.depot_git(url), attendu, url)
|
|||
|
|
|
|||
|
|
def test_selon_le_journal_une_garde_anterieure_ne_compte_pas(self):
|
|||
|
|
"""Un objet gardé puis purgé laisse ses lignes « stored » : seule
|
|||
|
|
une garde POSTÉRIEURE au manque vaut détention."""
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
(b,) = self.preflight(
|
|||
|
|
lambda paires: None,
|
|||
|
|
extra=[
|
|||
|
|
ligne(fin - 5000, "stored", URL_A),
|
|||
|
|
ligne(fin + 900, "stored", URL_B),
|
|||
|
|
ligne(fin + 950, "stored-status", URL_C, methode="HEAD"),
|
|||
|
|
],
|
|||
|
|
)
|
|||
|
|
self.assertTrue(b["selon_journal"])
|
|||
|
|
self.assertEqual(b["manquants"], [("GET", URL_A)])
|
|||
|
|
|
|||
|
|
def test_tout_detenu_se_tait(self):
|
|||
|
|
self.assertEqual(
|
|||
|
|
self.preflight(
|
|||
|
|
lambda paires: {p: {"verdict": "garde"} for p in paires}
|
|||
|
|
),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_sans_deploiement_rien_nest_lu_ni_lance(self):
|
|||
|
|
fin = MAINTENANT - 3600
|
|||
|
|
racine = self.deploiement("autre", fin - 600, [(AUTRE, IP, fin, 1)])
|
|||
|
|
|
|||
|
|
def interdit(paires):
|
|||
|
|
raise AssertionError("le magasin a été interrogé pour rien")
|
|||
|
|
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.manques_hors_ligne(
|
|||
|
|
[{"name": NOM}],
|
|||
|
|
racine=racine,
|
|||
|
|
chemin=str(self.dossier / "absent.jsonl"),
|
|||
|
|
maintenant=MAINTENANT,
|
|||
|
|
detient=interdit,
|
|||
|
|
),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
self.assertEqual(self.lancees, [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLesComposantsQueLeCacheNaPas(SansSysteme):
|
|||
|
|
"""Le verdict par SUITE ne suffit pas.
|
|||
|
|
|
|||
|
|
Une seule URL en réserve sous « /dists/<code>/ » le rend muet, alors que
|
|||
|
|
« restricted » ou « -security » peuvent manquer en entier. apt ne le dit
|
|||
|
|
qu'à l'installation — « Unable to locate package gnome-core » —, vingt
|
|||
|
|
minutes après la coupure, et ce message accuse le dépôt.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
VM = {"distro": "ubuntu", "version": "26.04", "arch": "amd64"}
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def code(self):
|
|||
|
|
"""Le nom de code vient du CATALOGUE, jamais d'une table recopiée
|
|||
|
|
ici : le test reste vrai quand le catalogue change."""
|
|||
|
|
jeton = cache_offline.jeton_de_suite("ubuntu", "26.04")
|
|||
|
|
return jeton.strip("/").split("/")[-1]
|
|||
|
|
|
|||
|
|
def _journal(self, paires, issue="hit"):
|
|||
|
|
"""Un journal où chaque (suite, composant) donné est en réserve."""
|
|||
|
|
return self.journal(
|
|||
|
|
[
|
|||
|
|
{
|
|||
|
|
"url": f"http://m.invalid/ubuntu/dists/{suite}"
|
|||
|
|
f"/{composant}/binary-amd64/Packages.xz",
|
|||
|
|
"outcome": issue,
|
|||
|
|
}
|
|||
|
|
for suite, composant in paires
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def _tout(self):
|
|||
|
|
return [
|
|||
|
|
(self.code + suffixe, composant)
|
|||
|
|
for suffixe in cache_offline.SUFFIXES_APT
|
|||
|
|
for composant in cache_offline.COMPOSANTS_APT
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
def test_main_seul_ne_suffit_pas(self):
|
|||
|
|
"""Le cas vécu : « main » de la suite de base était en réserve, et
|
|||
|
|
l'écran s'est tu ; « restricted » et « -security » manquaient."""
|
|||
|
|
chemin = self._journal([(self.code, "main")])
|
|||
|
|
rendu = cache_offline.composants_absents([self.VM], chemin)
|
|||
|
|
self.assertEqual(len(rendu), 1)
|
|||
|
|
distro, version, manque = rendu[0]
|
|||
|
|
self.assertEqual((distro, version), ("ubuntu", "26.04"))
|
|||
|
|
self.assertIn(f"{self.code}/restricted", manque)
|
|||
|
|
self.assertIn(f"{self.code}-security/main", manque)
|
|||
|
|
self.assertNotIn(f"{self.code}/main", manque)
|
|||
|
|
|
|||
|
|
def test_un_cache_complet_se_tait(self):
|
|||
|
|
chemin = self._journal(self._tout())
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.composants_absents([self.VM], chemin), []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_cache_qui_ignore_tout_se_tait_aussi(self):
|
|||
|
|
"""« suites_absentes » le dit déjà : deux avertissements pour une
|
|||
|
|
cause apprennent à les enchaîner."""
|
|||
|
|
chemin = self._journal([("autrechose", "main")])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.composants_absents([self.VM], chemin), []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_issue_sans_corps_ne_compte_pas(self):
|
|||
|
|
"""« offline-miss » prouve le contraire de ce qu'on cherche."""
|
|||
|
|
chemin = self._journal(self._tout(), issue="offline-miss")
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.composants_absents([self.VM], chemin), []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_larchitecture_est_respectee(self):
|
|||
|
|
"""Un index amd64 ne dit rien d'une VM arm64 : tout lui manque, donc
|
|||
|
|
on se tait — c'est le cas que « suites_absentes » couvre."""
|
|||
|
|
chemin = self._journal(self._tout())
|
|||
|
|
arm = dict(self.VM, arch="arm64")
|
|||
|
|
self.assertEqual(cache_offline.composants_absents([arm], chemin), [])
|
|||
|
|
|
|||
|
|
def test_deux_vm_du_meme_systeme_ne_parlent_quune_fois(self):
|
|||
|
|
chemin = self._journal([(self.code, "main")])
|
|||
|
|
rendu = cache_offline.composants_absents(
|
|||
|
|
[self.VM, dict(self.VM)], chemin
|
|||
|
|
)
|
|||
|
|
self.assertEqual(len(rendu), 1)
|
|||
|
|
|
|||
|
|
def test_un_systeme_sans_jeton_se_tait(self):
|
|||
|
|
"""Fedora et openSUSE se ressemblent dans l'URL : ne pas se prononcer
|
|||
|
|
vaut mieux que rassurer à tort."""
|
|||
|
|
chemin = self._journal([(self.code, "main")])
|
|||
|
|
autre = {"distro": "fedora", "version": "44", "arch": "amd64"}
|
|||
|
|
self.assertEqual(cache_offline.composants_absents([autre], chemin), [])
|
|||
|
|
|
|||
|
|
def test_un_journal_illisible_se_tait(self):
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.composants_absents([self.VM], "/inexistant.jsonl"),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestUnObjetDeStatutNestPasUnCorps(unittest.TestCase):
|
|||
|
|
"""Une redirection ou un refus gardés ne rendent pas une suite lisible.
|
|||
|
|
|
|||
|
|
Le contrôle de la suite compte les issues qui prouvent un CORPS ; y mêler
|
|||
|
|
les objets de statut ferait taire « le cache n'a rien pour ce système »
|
|||
|
|
devant un index gardé en 404. Pour le bilan hors ligne, en revanche, ils
|
|||
|
|
valent réponse : la VM reçoit ce que l'amont avait rendu.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def test_les_issues_de_statut_restent_hors_de_la_reserve(self):
|
|||
|
|
for issue in ("stored-status", "stale-status"):
|
|||
|
|
self.assertNotIn(issue, cache_offline.ISSUES_EN_RESERVE)
|
|||
|
|
self.assertIn(issue, cache_offline.ISSUES_REPONSE_GARDEE)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLaQuestionAuMagasin(SansSysteme):
|
|||
|
|
"""« --detient » : sondé dans l'aide, interrogé sur l'entrée standard."""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
super().setUp()
|
|||
|
|
self.binaire = self.dossier / "binaire"
|
|||
|
|
self.binaire.write_text("")
|
|||
|
|
self.aide = " -cache-dir string\n -detient\n lit l'entrée\n"
|
|||
|
|
self.rc = 0
|
|||
|
|
self.sortie = (
|
|||
|
|
f"garde\t200\t2030-01-01T00:00:00Z\tvolatile\tGET\t{URL_A}\n"
|
|||
|
|
f"absent\t0\t-\tvolatile\tGET\t{URL_B}\n"
|
|||
|
|
)
|
|||
|
|
self.entree = None
|
|||
|
|
|
|||
|
|
def repondre(self, texte, kw):
|
|||
|
|
if texte.endswith("--help"):
|
|||
|
|
return 0, self.aide
|
|||
|
|
if texte.endswith("--detient"):
|
|||
|
|
self.entree = kw.get("input")
|
|||
|
|
return self.rc, self.sortie
|
|||
|
|
return 127, ""
|
|||
|
|
|
|||
|
|
def interroger(self, paires=(("GET", URL_A), ("GET", URL_B))):
|
|||
|
|
return cache_offline.detient_interroger(
|
|||
|
|
list(paires), str(self.binaire), "/casiers/essai"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_le_rendu_lit_les_six_champs(self):
|
|||
|
|
rendu = cache_offline.detient_rendu(self.sortie + "ligne mal formée\n")
|
|||
|
|
self.assertEqual(rendu[("GET", URL_A)]["verdict"], "garde")
|
|||
|
|
self.assertEqual(rendu[("GET", URL_B)]["garde_le"], "-")
|
|||
|
|
self.assertEqual(len(rendu), 2)
|
|||
|
|
|
|||
|
|
def test_la_question_part_sur_lentree_standard(self):
|
|||
|
|
rendu = self.interroger()
|
|||
|
|
self.assertEqual(rendu[("GET", URL_A)]["verdict"], "garde")
|
|||
|
|
self.assertEqual(self.entree, f"GET {URL_A}\nGET {URL_B}\n")
|
|||
|
|
(argv,) = [c for c, _kw in self.lancees if c.endswith("--detient")]
|
|||
|
|
self.assertIn("--cache-dir /casiers/essai", argv)
|
|||
|
|
self.assertNotIn("sudo", argv)
|
|||
|
|
|
|||
|
|
def test_un_binaire_sans_detient_rend_none(self):
|
|||
|
|
self.aide = " -cache-dir string\n -status\n"
|
|||
|
|
self.assertIsNone(self.interroger())
|
|||
|
|
self.assertFalse(
|
|||
|
|
any(c.endswith("--detient") for c, _kw in self.lancees),
|
|||
|
|
"la question est partie vers un binaire qui ne la connaît pas",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_echec_rend_none(self):
|
|||
|
|
self.rc = 2
|
|||
|
|
self.assertIsNone(self.interroger())
|
|||
|
|
|
|||
|
|
def test_un_binaire_absent_rend_none_sans_rien_lancer(self):
|
|||
|
|
self.binaire.unlink()
|
|||
|
|
self.assertIsNone(self.interroger())
|
|||
|
|
self.assertEqual(self.lancees, [])
|
|||
|
|
|
|||
|
|
def test_rien_a_demander_ne_lance_rien(self):
|
|||
|
|
self.assertEqual(self.interroger(()), {})
|
|||
|
|
self.assertEqual(self.lancees, [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLesManquesRecents(SansSysteme):
|
|||
|
|
"""Ce que le menu relit pour combler : les invités seuls, sans doublon."""
|
|||
|
|
|
|||
|
|
def test_le_releve(self):
|
|||
|
|
maintenant = time.time()
|
|||
|
|
chemin = self.journal(
|
|||
|
|
[
|
|||
|
|
ligne(maintenant - 60, "offline-miss", URL_A),
|
|||
|
|
ligne(
|
|||
|
|
maintenant - 30, "offline-miss", URL_A, client="192.0.2.11"
|
|||
|
|
),
|
|||
|
|
ligne(
|
|||
|
|
maintenant - 20, "offline-miss", URL_B, client="127.0.0.1"
|
|||
|
|
),
|
|||
|
|
ligne(
|
|||
|
|
maintenant - 10,
|
|||
|
|
"offline-miss",
|
|||
|
|
URL_C,
|
|||
|
|
client="198.51.100.7",
|
|||
|
|
),
|
|||
|
|
ligne(maintenant - 9 * 86400, "offline-miss", URL_POST),
|
|||
|
|
ligne(maintenant - 5, "stored", URL_A2),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
releve = cache_offline.manques_recents(
|
|||
|
|
chemin, "192.0.2.0/24", maintenant - 7 * 86400
|
|||
|
|
)
|
|||
|
|
self.assertEqual(
|
|||
|
|
[(e["methode"], e["url"]) for e in releve], [("GET", URL_A)]
|
|||
|
|
)
|
|||
|
|
self.assertEqual(releve[0]["n"], 2)
|
|||
|
|
self.assertEqual(releve[0]["clients"], {IP, "192.0.2.11"})
|
|||
|
|
|
|||
|
|
def test_sans_sous_reseau_la_boucle_locale_reste_ecartee(self):
|
|||
|
|
maintenant = time.time()
|
|||
|
|
chemin = self.journal(
|
|||
|
|
[ligne(maintenant - 20, "offline-miss", URL_B, client="127.0.0.1")]
|
|||
|
|
)
|
|||
|
|
self.assertEqual(cache_offline.manques_recents(chemin, "", 0.0), [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Le verdict de rejeu et la commande
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
def exclusions_du_go():
|
|||
|
|
"""DefaultExclusions, relues dans la source Go comme le fait le test qui
|
|||
|
|
lit proxy.go."""
|
|||
|
|
src = (RACINE / "script" / "qemu_cache" / "mitm.go").read_text(
|
|||
|
|
encoding="utf-8"
|
|||
|
|
)
|
|||
|
|
bloc = re.search(r"var DefaultExclusions = \[\]string\{([^}]*)\}", src)
|
|||
|
|
return re.findall(r'"([^"]+)"', bloc.group(1))
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLeVerdictDeRejeu(unittest.TestCase):
|
|||
|
|
"""Ce qui se rejoue, et ce qui ne doit JAMAIS l'être.
|
|||
|
|
|
|||
|
|
Un hôte en tunnel rejoué depuis l'hôte revient dans le cache : sans
|
|||
|
|
détournement, la destination d'origine d'une connexion locale est
|
|||
|
|
l'écoute elle-même, et le tunnel s'y rappelle sans fin.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def test_get_et_head_sur_un_nom_se_rejouent(self):
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu("GET", URL_A), "rejouable")
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu("HEAD", URL_B), "rejouable")
|
|||
|
|
|
|||
|
|
def test_un_post_ne_se_garde_jamais(self):
|
|||
|
|
for methode in ("POST", "PUT", "DELETE"):
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu(methode, URL_A), "jamais")
|
|||
|
|
|
|||
|
|
def test_une_adresse_ip_en_https_est_un_tunnel(self):
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu("GET", URL_IP), "tunnel")
|
|||
|
|
|
|||
|
|
def test_une_adresse_ip_en_http_ou_localhost_nest_pas_rejouee(self):
|
|||
|
|
for url in (
|
|||
|
|
"http://192.0.2.99/x",
|
|||
|
|
"http://localhost/x",
|
|||
|
|
"ftp://example.com/x",
|
|||
|
|
):
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu("GET", url), "adresse", url)
|
|||
|
|
|
|||
|
|
def test_les_exclusions_declarees_passent_en_tunnel(self):
|
|||
|
|
exclus = menu.exclusions_declarees(str(RACINE))
|
|||
|
|
self.assertEqual(sorted(exclus), sorted(exclusions_du_go()))
|
|||
|
|
self.assertGreater(len(exclus), 0)
|
|||
|
|
for hote in exclus:
|
|||
|
|
self.assertEqual(
|
|||
|
|
menu.verdict_de_rejeu("GET", f"https://{hote}/v1/x", exclus),
|
|||
|
|
"tunnel",
|
|||
|
|
hote,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_exclusion_en_suffixe_couvre_le_domaine(self):
|
|||
|
|
exclus = [".example.org"]
|
|||
|
|
self.assertEqual(
|
|||
|
|
menu.verdict_de_rejeu("GET", "https://a.example.org/x", exclus),
|
|||
|
|
"tunnel",
|
|||
|
|
)
|
|||
|
|
self.assertEqual(
|
|||
|
|
menu.verdict_de_rejeu(
|
|||
|
|
"GET", "https://example.org.invalid/x", exclus
|
|||
|
|
),
|
|||
|
|
"rejouable",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_negociation_git_va_au_miroir(self):
|
|||
|
|
url = "https://example.com/o/d.git/info/refs?service=git-upload-pack"
|
|||
|
|
self.assertEqual(menu.verdict_de_rejeu("GET", url), "git")
|
|||
|
|
|
|||
|
|
def test_les_chemins_git_suivent_le_go(self):
|
|||
|
|
src = (RACINE / "script" / "qemu_cache" / "classify.go").read_text(
|
|||
|
|
encoding="utf-8"
|
|||
|
|
)
|
|||
|
|
bloc = re.search(r"var gitSmartPaths = \[\]string\{([^}]*)\}", src)
|
|||
|
|
self.assertEqual(
|
|||
|
|
tuple(re.findall(r'"([^"]+)"', bloc.group(1))),
|
|||
|
|
menu.GIT_NEGOCIATION,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLaCommandeDeRejeu(unittest.TestCase):
|
|||
|
|
"""La connexion va à l'écoute locale ; le nom d'hôte, lui, ne change pas.
|
|||
|
|
|
|||
|
|
C'est ce qui donne au rejeu la clé qu'une VM produirait. L'autorité est
|
|||
|
|
APPROUVÉE, jamais contournée : « -k » ferait passer n'importe quoi pour le
|
|||
|
|
cache.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
CA = "/var/lib/essai/ca.crt"
|
|||
|
|
|
|||
|
|
def cmd(self, methode, url):
|
|||
|
|
return menu.rejeu_cmd(methode, url, "8898", "8899", self.CA)
|
|||
|
|
|
|||
|
|
def test_http_passe_par_le_mandataire_du_cache(self):
|
|||
|
|
argv = self.cmd("GET", URL_B)
|
|||
|
|
self.assertEqual(argv[0], "curl")
|
|||
|
|
self.assertIn("-x", argv)
|
|||
|
|
self.assertEqual(argv[argv.index("-x") + 1], "http://127.0.0.1:8898")
|
|||
|
|
self.assertNotIn("--connect-to", argv)
|
|||
|
|
self.assertNotIn("-I", argv)
|
|||
|
|
self.assertEqual(argv[-1], URL_B)
|
|||
|
|
self.assertIn("--max-time", argv)
|
|||
|
|
|
|||
|
|
def test_https_mene_la_connexion_a_lecoute_tls(self):
|
|||
|
|
argv = self.cmd("GET", URL_A)
|
|||
|
|
self.assertEqual(
|
|||
|
|
argv[argv.index("--connect-to") + 1], "::127.0.0.1:8899"
|
|||
|
|
)
|
|||
|
|
self.assertEqual(argv[argv.index("--cacert") + 1], self.CA)
|
|||
|
|
for interdit in ("-k", "--insecure", "-L", "-x"):
|
|||
|
|
self.assertNotIn(interdit, argv)
|
|||
|
|
self.assertEqual(argv[-1], URL_A)
|
|||
|
|
|
|||
|
|
def test_head_se_rejoue_en_head(self):
|
|||
|
|
self.assertIn("-I", self.cmd("HEAD", URL_A))
|
|||
|
|
self.assertIn("-I", self.cmd("HEAD", URL_B))
|
|||
|
|
|
|||
|
|
def test_le_curlrc_de_loperateur_est_ecarte(self):
|
|||
|
|
"""curl n'honore « -q » qu'en tout premier argument. Ailleurs, un
|
|||
|
|
« proxy » du ~/.curlrc enverrait le rejeu https hors du cache, et
|
|||
|
|
un en-tête ou « compressed » changerait ce qui entre au magasin."""
|
|||
|
|
for methode, url in (("GET", URL_A), ("GET", URL_B), ("HEAD", URL_A)):
|
|||
|
|
argv = self.cmd(methode, url)
|
|||
|
|
self.assertEqual(argv[:2], ["curl", "-q"], argv)
|
|||
|
|
|
|||
|
|
def test_la_redirection_se_lit_dans_le_dernier_bloc(self):
|
|||
|
|
entetes = (
|
|||
|
|
"HTTP/1.1 100 Continue\r\n\r\n"
|
|||
|
|
"HTTP/1.1 302 Found\r\nLocation: /depot/cible.tar.gz\r\n\r\n"
|
|||
|
|
)
|
|||
|
|
self.assertEqual(menu.statut_et_cible(entetes, URL_A), (302, URL_A2))
|
|||
|
|
self.assertEqual(
|
|||
|
|
menu.statut_et_cible("HTTP/2 200\r\n\r\n", URL_A), (200, "")
|
|||
|
|
)
|
|||
|
|
self.assertEqual(menu.statut_et_cible("", URL_A), (None, ""))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# L'entrée 9 : refuser sous la coupure, montrer, confirmer, rejouer, vérifier
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class Faux(M):
|
|||
|
|
def __init__(self):
|
|||
|
|
self.execute = self
|
|||
|
|
self.executees = []
|
|||
|
|
|
|||
|
|
def exec_command_live(self, cmd, **_kw):
|
|||
|
|
self.executees.append(cmd)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLeComblement(SansSysteme):
|
|||
|
|
def setUp(self):
|
|||
|
|
super().setUp()
|
|||
|
|
self.binaire = self.dossier / "binaire"
|
|||
|
|
self.binaire.write_text("")
|
|||
|
|
conf = self.dossier / "env"
|
|||
|
|
conf.write_text(
|
|||
|
|
"EL_SUBNET=192.0.2.0/24\nEL_HTTP_PORT=8898\nEL_TLS_PORT=8899\n"
|
|||
|
|
f"EL_CACHE_DIR={self.dossier / 'casiers'}\nEL_EXCLUDE=\n"
|
|||
|
|
)
|
|||
|
|
maintenant = time.time()
|
|||
|
|
self.chemin = self.journal(
|
|||
|
|
[
|
|||
|
|
ligne(maintenant - 300, "offline-miss", URL_A),
|
|||
|
|
ligne(maintenant - 200, "offline-miss", URL_B),
|
|||
|
|
ligne(
|
|||
|
|
maintenant - 150, "offline-miss", URL_POST, methode="POST"
|
|||
|
|
),
|
|||
|
|
ligne(maintenant - 100, "offline-miss", URL_IP),
|
|||
|
|
]
|
|||
|
|
)
|
|||
|
|
self.coupe = False
|
|||
|
|
self.guet = False
|
|||
|
|
self.actif = True
|
|||
|
|
self.rejoue = False
|
|||
|
|
for cible, valeur in (
|
|||
|
|
("CACHE_BIN", str(self.binaire)),
|
|||
|
|
("CACHE_CONF", str(conf)),
|
|||
|
|
):
|
|||
|
|
p = mock.patch.object(menu, cible, valeur)
|
|||
|
|
p.start()
|
|||
|
|
self.addCleanup(p.stop)
|
|||
|
|
for nom, fonction in (
|
|||
|
|
("_cache_journal", staticmethod(lambda: self.chemin)),
|
|||
|
|
("_cache_actif", classmethod(lambda cls: self.actif)),
|
|||
|
|
("_cache_amont_coupe", classmethod(lambda cls: self.coupe)),
|
|||
|
|
("_cache_guet_actif", classmethod(lambda cls: self.guet)),
|
|||
|
|
):
|
|||
|
|
p = mock.patch.object(M, nom, fonction)
|
|||
|
|
p.start()
|
|||
|
|
self.addCleanup(p.stop)
|
|||
|
|
|
|||
|
|
def repondre(self, texte, kw):
|
|||
|
|
if texte.endswith("--help"):
|
|||
|
|
return 0, " -detient\n"
|
|||
|
|
if texte.endswith("--detient"):
|
|||
|
|
lignes = []
|
|||
|
|
for question in (kw.get("input") or "").splitlines():
|
|||
|
|
methode, url = question.split(" ", 1)
|
|||
|
|
garde = self.rejoue and url in (URL_A2, URL_B)
|
|||
|
|
verdict = "garde" if garde else "absent"
|
|||
|
|
lignes.append(f"{verdict}\t200\t-\tvolatile\t{methode}\t{url}")
|
|||
|
|
return 0, "\n".join(lignes) + "\n"
|
|||
|
|
if texte.startswith("curl "):
|
|||
|
|
self.rejoue = True
|
|||
|
|
if texte.endswith(" " + URL_A):
|
|||
|
|
return 0, f"HTTP/1.1 302 Found\r\nLocation: {URL_A2}\r\n\r\n"
|
|||
|
|
return 0, "HTTP/1.1 200 OK\r\n\r\n"
|
|||
|
|
return 0, ""
|
|||
|
|
|
|||
|
|
def combler(self, confirmer=True):
|
|||
|
|
sortie = io.StringIO()
|
|||
|
|
faux = Faux()
|
|||
|
|
with mock.patch(
|
|||
|
|
"click.confirm", return_value=confirmer
|
|||
|
|
) as confirme, contextlib.redirect_stdout(sortie):
|
|||
|
|
faux._cache_combler()
|
|||
|
|
return sortie.getvalue(), confirme, faux
|
|||
|
|
|
|||
|
|
def curls(self):
|
|||
|
|
return [(c, kw) for c, kw in self.lancees if c.startswith("curl ")]
|
|||
|
|
|
|||
|
|
def test_sous_la_coupure_rien_ne_part(self):
|
|||
|
|
self.coupe = True
|
|||
|
|
texte, confirme, faux = self.combler()
|
|||
|
|
self.assertEqual(
|
|||
|
|
self.curls(), [], "un rejeu est parti sous la coupure"
|
|||
|
|
)
|
|||
|
|
confirme.assert_not_called()
|
|||
|
|
self.assertIn(cache_offline.restore_cmd(), texte)
|
|||
|
|
self.assertEqual(faux.executees, [])
|
|||
|
|
|
|||
|
|
def test_sous_le_guet_rien_ne_part_et_le_geste_arrete_le_guet(self):
|
|||
|
|
"""Le guet tient la coupure jusqu'à la fin de la dernière
|
|||
|
|
installation détachée. Le geste donné l'arrête ; retirer la table
|
|||
|
|
seule ferait finir ces installations en ligne et laisserait le guet
|
|||
|
|
tourner pour rien."""
|
|||
|
|
self.guet = True
|
|||
|
|
texte, confirme, faux = self.combler()
|
|||
|
|
self.assertEqual(self.curls(), [], "un rejeu est parti sous le guet")
|
|||
|
|
confirme.assert_not_called()
|
|||
|
|
self.assertIn(cache_offline.lever_maintenant_cmd(), texte)
|
|||
|
|
self.assertNotIn(cache_offline.restore_cmd(), texte)
|
|||
|
|
self.assertIn(
|
|||
|
|
t("Lifting it now makes those installations finish online."), texte
|
|||
|
|
)
|
|||
|
|
self.assertEqual(faux.executees, [])
|
|||
|
|
|
|||
|
|
def test_une_coupure_illisible_fait_demander_non_par_defaut(self):
|
|||
|
|
"""sudo exige un mot de passe : la coupure ne se lit pas, et la
|
|||
|
|
prendre pour absente ferait rejouer sous la coupure."""
|
|||
|
|
self.coupe = None
|
|||
|
|
texte, confirme, _f = self.combler(confirmer=False)
|
|||
|
|
self.assertEqual(self.curls(), [])
|
|||
|
|
confirme.assert_called_once()
|
|||
|
|
self.assertIs(confirme.call_args.kwargs.get("default"), False)
|
|||
|
|
self.assertIn(
|
|||
|
|
t(
|
|||
|
|
"Cannot tell whether the upstream is cut: reading nft needs a"
|
|||
|
|
" sudo password here."
|
|||
|
|
),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
self.assertEqual(
|
|||
|
|
[c for c, _kw in self.lancees if c.endswith("--detient")],
|
|||
|
|
[],
|
|||
|
|
"le magasin a été interrogé avant la réponse",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_coupure_illisible_acceptee_rejoue(self):
|
|||
|
|
self.coupe = None
|
|||
|
|
_texte, confirme, _f = self.combler(confirmer=True)
|
|||
|
|
self.assertEqual(confirme.call_count, 2)
|
|||
|
|
self.assertNotEqual(self.curls(), [])
|
|||
|
|
|
|||
|
|
def test_service_arrete_rien_ne_part(self):
|
|||
|
|
self.actif = False
|
|||
|
|
_texte, confirme, _f = self.combler()
|
|||
|
|
self.assertEqual(self.curls(), [])
|
|||
|
|
confirme.assert_not_called()
|
|||
|
|
|
|||
|
|
def test_sans_binaire_rien_ne_part(self):
|
|||
|
|
self.binaire.unlink()
|
|||
|
|
_texte, confirme, _f = self.combler()
|
|||
|
|
self.assertEqual(self.lancees, [])
|
|||
|
|
confirme.assert_not_called()
|
|||
|
|
|
|||
|
|
def test_refuser_la_confirmation_ne_lance_rien(self):
|
|||
|
|
texte, confirme, _f = self.combler(confirmer=False)
|
|||
|
|
confirme.assert_called_once()
|
|||
|
|
self.assertEqual(self.curls(), [])
|
|||
|
|
self.assertIn(URL_POST, texte, "le plan tait ce qui ne se rejoue pas")
|
|||
|
|
|
|||
|
|
def test_le_rejeu_passe_par_le_cache_et_se_verifie(self):
|
|||
|
|
with mock.patch.dict(
|
|||
|
|
os.environ, {"https_proxy": "http://198.51.100.1:3128"}
|
|||
|
|
):
|
|||
|
|
texte, _c, _f = self.combler()
|
|||
|
|
curls = self.curls()
|
|||
|
|
cibles = [c.rsplit(" ", 1)[-1] for c, _kw in curls]
|
|||
|
|
# Le manque le plus récent d'abord ; une redirection est suivie
|
|||
|
|
# aussitôt, avant l'adresse suivante.
|
|||
|
|
self.assertEqual(cibles, [URL_B, URL_A, URL_A2])
|
|||
|
|
for c, kw in curls:
|
|||
|
|
argv = c.split()
|
|||
|
|
self.assertTrue(
|
|||
|
|
("--connect-to" in argv and "--cacert" in argv)
|
|||
|
|
or "http://127.0.0.1:8898" in argv,
|
|||
|
|
c,
|
|||
|
|
)
|
|||
|
|
for interdit in ("-k", "--insecure", "-L"):
|
|||
|
|
self.assertNotIn(interdit, argv)
|
|||
|
|
self.assertNotIn(
|
|||
|
|
"https_proxy", kw.get("env") or {}, "le mandataire hérité"
|
|||
|
|
)
|
|||
|
|
# Le rejeu et sa vérification tournent sans privilège. Le seul sudo
|
|||
|
|
# admis est la lecture non interactive du journal du service, qui
|
|||
|
|
# nomme les refus de tunnel appris.
|
|||
|
|
for c, _kw in self.lancees:
|
|||
|
|
self.assertNotIn("nft", c)
|
|||
|
|
if c.startswith("sudo"):
|
|||
|
|
self.assertTrue(c.startswith("sudo -n journalctl "), c)
|
|||
|
|
for c, _kw in curls:
|
|||
|
|
self.assertNotIn("sudo", c)
|
|||
|
|
self.assertNotIn(URL_POST, cibles)
|
|||
|
|
self.assertNotIn(URL_IP, cibles)
|
|||
|
|
self.assertIn(t("held"), texte)
|
|||
|
|
self.assertIn(t("not held"), texte)
|
|||
|
|
self.assertIn(
|
|||
|
|
t("User-Agent or Accept may keep another answer than the VM's."),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLesMotifsPartagesParLesDeuxFormulaires(SansSysteme):
|
|||
|
|
"""Le texte de l'avertissement vit dans le socle commun aux deux
|
|||
|
|
formulaires, qui l'affichent tel quel : une épreuve du texte vaut donc
|
|||
|
|
pour les deux.
|
|||
|
|
|
|||
|
|
Éprouver un verdict ne suffit pas : une fonction juste dont le résultat
|
|||
|
|
n'atteint jamais l'écran laisse partir la VM sans un mot, et c'est
|
|||
|
|
précisément ce que l'avertissement doit empêcher.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def motifs(self, **verdicts):
|
|||
|
|
"""Les phrases rendues, tous les verdicts truqués sauf ceux nommés."""
|
|||
|
|
from script.todo.deploy_form_lib import motifs_hors_ligne
|
|||
|
|
|
|||
|
|
valeurs = {
|
|||
|
|
"suites_absentes": [],
|
|||
|
|
"composants_absents": [],
|
|||
|
|
"manques_hors_ligne": [],
|
|||
|
|
"paquets_absents": [],
|
|||
|
|
"miroirs_absents": [],
|
|||
|
|
}
|
|||
|
|
valeurs.update(verdicts)
|
|||
|
|
with contextlib.ExitStack() as pile:
|
|||
|
|
for nom, valeur in valeurs.items():
|
|||
|
|
pile.enter_context(
|
|||
|
|
mock.patch.object(
|
|||
|
|
cache_offline, nom, lambda *a, _v=valeur, **k: _v
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
return motifs_hors_ligne(
|
|||
|
|
[{"distro": "ubuntu", "version": "26.04"}]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_paquet_manquant_atteint_lecran(self):
|
|||
|
|
motifs = self.motifs(paquets_absents=["qemu-guest-agent"])
|
|||
|
|
self.assertTrue(any("qemu-guest-agent" in m for m in motifs), motifs)
|
|||
|
|
|
|||
|
|
def test_un_depot_sans_miroir_atteint_lecran(self):
|
|||
|
|
motifs = self.motifs(miroirs_absents=["forge.example/g/p"])
|
|||
|
|
self.assertTrue(any("forge.example/g/p" in m for m in motifs), motifs)
|
|||
|
|
self.assertTrue(
|
|||
|
|
any(t("fill them from Cache › Git mirrors") in m for m in motifs),
|
|||
|
|
"le geste qui lève le manque n'est pas donné",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_beaucoup_de_depots_ne_deroulent_pas_la_liste(self):
|
|||
|
|
"""Cinquante dépôts ne se lisent pas, et le geste est le même pour
|
|||
|
|
tous : trois exemples, puis le compte."""
|
|||
|
|
motifs = self.motifs(
|
|||
|
|
miroirs_absents=[f"forge.example/g/p{i}" for i in range(50)]
|
|||
|
|
)
|
|||
|
|
joints = " ".join(motifs)
|
|||
|
|
self.assertIn("(+47)", joints)
|
|||
|
|
self.assertNotIn("p49", joints)
|
|||
|
|
|
|||
|
|
def test_rien_ne_manque_rien_ne_sort(self):
|
|||
|
|
"""Un avertissement qui tombe quand rien ne manque s'apprend par
|
|||
|
|
cœur, et c'est ainsi qu'on cesse de le lire."""
|
|||
|
|
self.assertEqual(self.motifs(), [])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLesPaquetsQueLeCacheNaPas(SansSysteme):
|
|||
|
|
"""Le verdict par index ne descend jamais au FICHIER.
|
|||
|
|
|
|||
|
|
Un cache qui détient l'index d'une suite passe pour complet alors
|
|||
|
|
qu'aucun octet du paquet lui-même ne l'a traversé. Hors ligne,
|
|||
|
|
l'installation échoue vingt minutes plus tard sur « Unable to locate
|
|||
|
|
package », un message qui accuse le dépôt et jamais le cache. Le cas qui
|
|||
|
|
le montre : un paquet posé par une unité DÉTACHÉE, dont l'échec ne
|
|||
|
|
remonte nulle part.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
VM = {"distro": "ubuntu", "version": "26.04", "arch": "amd64"}
|
|||
|
|
URL = "http://m/pool/main/q/qemu/qemu-guest-agent_9.2_amd64.deb"
|
|||
|
|
|
|||
|
|
def _journal(self, urls, issue="hit"):
|
|||
|
|
return self.journal([{"url": u, "outcome": issue} for u in urls])
|
|||
|
|
|
|||
|
|
def test_un_paquet_en_reserve_ne_se_dit_pas(self):
|
|||
|
|
chemin = self._journal([self.URL])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.paquets_absents([self.VM], chemin=chemin), []
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_paquet_jamais_vu_est_nomme(self):
|
|||
|
|
chemin = self._journal(["http://m/pool/main/a/a/autre_1_amd64.deb"])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.paquets_absents([self.VM], chemin=chemin),
|
|||
|
|
list(cache_offline.PAQUETS_HORS_SUIVI),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_manque_hors_ligne_ne_vaut_pas_une_reserve(self):
|
|||
|
|
"""« offline-miss » dit qu'on l'a CHERCHÉ sans le trouver : le
|
|||
|
|
compter comme détenu ferait taire l'avertissement précisément quand
|
|||
|
|
il a raison."""
|
|||
|
|
chemin = self._journal([self.URL], issue="offline-miss")
|
|||
|
|
self.assertIn(
|
|||
|
|
"qemu-guest-agent",
|
|||
|
|
cache_offline.paquets_absents([self.VM], chemin=chemin),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_sans_vm_ou_sans_journal_le_verdict_se_tait(self):
|
|||
|
|
"""Accuser un cache qu'on ne peut pas interroger ferait cesser de
|
|||
|
|
lire l'avertissement le jour où il compte."""
|
|||
|
|
chemin = self._journal([])
|
|||
|
|
self.assertEqual(cache_offline.paquets_absents([], chemin=chemin), [])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.paquets_absents(
|
|||
|
|
[self.VM], chemin=str(self.dossier / "absent.jsonl")
|
|||
|
|
),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLesMiroirsGitQuiManquent(SansSysteme):
|
|||
|
|
"""Une négociation git ne se garde pas : le cache tient un dépôt NU par
|
|||
|
|
amont et le sert localement. Sans miroir, le clone échoue une fois le
|
|||
|
|
réseau coupé, et aucun verdict ne l'annonçait — celui par index ne parle
|
|||
|
|
que d'apt, et le journal des manques ne connaît que ce qu'une coupure a
|
|||
|
|
déjà fait rater.
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def _racine(self, presents=()):
|
|||
|
|
racine = self.dossier / "git"
|
|||
|
|
racine.mkdir(parents=True, exist_ok=True)
|
|||
|
|
for nom in presents:
|
|||
|
|
(racine / nom).mkdir(parents=True, exist_ok=True)
|
|||
|
|
return str(racine)
|
|||
|
|
|
|||
|
|
def test_un_depot_sans_miroir_est_nomme(self):
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.miroirs_absents(
|
|||
|
|
["https://forge.example/groupe/projet.git"], self._racine()
|
|||
|
|
),
|
|||
|
|
["forge.example/groupe/projet"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_un_depot_deja_en_miroir_se_tait(self):
|
|||
|
|
racine = self._racine(["forge.example/groupe/projet.git"])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.miroirs_absents(
|
|||
|
|
["https://forge.example/groupe/projet.git"], racine
|
|||
|
|
),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_lhote_fait_partie_du_chemin(self):
|
|||
|
|
"""Deux forges peuvent servir « /odoo/odoo » : les confondre
|
|||
|
|
donnerait à l'une le contenu de l'autre."""
|
|||
|
|
racine = self._racine(["forge-a.example/odoo/odoo.git"])
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.miroirs_absents(
|
|||
|
|
["https://forge-b.example/odoo/odoo"], racine
|
|||
|
|
),
|
|||
|
|
["forge-b.example/odoo/odoo"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_une_racine_absente_ne_dit_rien(self):
|
|||
|
|
"""Un magasin absent n'est pas un miroir manquant, et deux causes
|
|||
|
|
sous un seul message font cesser de lire."""
|
|||
|
|
self.assertEqual(
|
|||
|
|
cache_offline.miroirs_absents(
|
|||
|
|
["https://forge.example/g/p"], str(self.dossier / "nulle")
|
|||
|
|
),
|
|||
|
|
[],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLeMenuLitLaCoupureEtLeGuet(SansSysteme):
|
|||
|
|
"""Ce que le menu du cache croit de la coupure et du guet.
|
|||
|
|
|
|||
|
|
Trois lectures sans privilège ou sans mot de passe, chacune réduite à
|
|||
|
|
une marque que la commande écrit quand elle réussit : les messages de
|
|||
|
|
nft, de sudo et de systemctl se traduisent, et une inclusion de texte
|
|||
|
|
lit « inactive » comme « active ».
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
def setUp(self):
|
|||
|
|
super().setUp()
|
|||
|
|
self.sorties = {}
|
|||
|
|
|
|||
|
|
def repondre(self, texte, kw):
|
|||
|
|
for motif, sortie in self.sorties.items():
|
|||
|
|
if motif in texte:
|
|||
|
|
return 0, sortie
|
|||
|
|
return 1, ""
|
|||
|
|
|
|||
|
|
def test_la_coupure_a_trois_etats(self):
|
|||
|
|
posee = (
|
|||
|
|
"table inet x {\n meta skuid 900 tcp dport { 80, 443 } drop\n}\n"
|
|||
|
|
)
|
|||
|
|
for sortie, attendu in (
|
|||
|
|
(f"{menu.NFT_LISIBLE}\n{posee}", True),
|
|||
|
|
(f"{menu.NFT_LISIBLE}\nError: la table n'existe pas\n", False),
|
|||
|
|
("sudo: un mot de passe est nécessaire\n", None),
|
|||
|
|
("", None),
|
|||
|
|
):
|
|||
|
|
self.sorties = {"nft list": sortie}
|
|||
|
|
self.assertIs(M._cache_amont_coupe(), attendu, sortie)
|
|||
|
|
for c, _kw in self.lancees:
|
|||
|
|
self.assertNotRegex(c, r"sudo (?!-n )", "sudo peut demander")
|
|||
|
|
|
|||
|
|
def test_le_prefixe_des_regles_a_trois_etats(self):
|
|||
|
|
"""Confondre « illisible » avec « aucune règle » fait annoncer un
|
|||
|
|
détournement absent par un cache qui détourne pourtant, et envoie
|
|||
|
|
réinstaller ce qui fonctionne déjà."""
|
|||
|
|
posee = (
|
|||
|
|
"table ip erplibre_qemu_cache {\n"
|
|||
|
|
" chain prerouting {\n"
|
|||
|
|
" ip saddr 192.168.122.0/24 tcp dport 80 dnat to :8898\n"
|
|||
|
|
" }\n"
|
|||
|
|
"}\n"
|
|||
|
|
)
|
|||
|
|
for sortie, attendu in (
|
|||
|
|
(f"{menu.NFT_LISIBLE}\n{posee}", "192.168.122"),
|
|||
|
|
(f"{menu.NFT_LISIBLE}\nError: la table n'existe pas\n", ""),
|
|||
|
|
("sudo: un mot de passe est nécessaire\n", None),
|
|||
|
|
("", None),
|
|||
|
|
):
|
|||
|
|
self.sorties = {"nft list": sortie}
|
|||
|
|
self.assertEqual(M._cache_prefixe_regles(), attendu, sortie)
|
|||
|
|
for c, _kw in self.lancees:
|
|||
|
|
self.assertNotRegex(c, r"sudo (?!-n )", "sudo peut demander")
|
|||
|
|
|
|||
|
|
def test_le_diagnostic_ne_crie_pas_labsence_dune_regle_illisible(self):
|
|||
|
|
"""« Aucune règle » et « je n'ai pas pu lire » appellent deux gestes
|
|||
|
|
opposés : le premier fait réinstaller, le second fait donner un mot
|
|||
|
|
de passe. Les confondre envoie refaire ce qui fonctionne."""
|
|||
|
|
import contextlib
|
|||
|
|
import io as _io
|
|||
|
|
|
|||
|
|
class Muet(M):
|
|||
|
|
@classmethod
|
|||
|
|
def _cache_prefixe_regles(cls):
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
@classmethod
|
|||
|
|
def _cache_prefixe_libvirt(cls):
|
|||
|
|
return "192.168.122"
|
|||
|
|
|
|||
|
|
tampon = _io.StringIO()
|
|||
|
|
with contextlib.ExitStack() as pile:
|
|||
|
|
pile.enter_context(mock.patch("os.path.isfile", return_value=True))
|
|||
|
|
pile.enter_context(contextlib.redirect_stdout(tampon))
|
|||
|
|
Muet()._cache_diagnostic()
|
|||
|
|
texte = tampon.getvalue()
|
|||
|
|
self.assertIn(
|
|||
|
|
t(
|
|||
|
|
"Cannot tell where the rules point: reading nft needs a"
|
|||
|
|
" sudo password here."
|
|||
|
|
),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
self.assertNotIn(t("No redirection rule is posted"), texte)
|
|||
|
|
|
|||
|
|
def test_le_guet_se_lit_sans_sudo(self):
|
|||
|
|
self.sorties = {cache_offline.guet_actif_cmd(): menu.GUET_ACTIF}
|
|||
|
|
self.assertTrue(M._cache_guet_actif())
|
|||
|
|
self.sorties = {}
|
|||
|
|
self.assertFalse(M._cache_guet_actif())
|
|||
|
|
for c, _kw in self.lancees:
|
|||
|
|
self.assertIn(cache_offline.guet_actif_cmd(), c)
|
|||
|
|
self.assertNotIn("sudo", c)
|
|||
|
|
|
|||
|
|
def test_un_service_inactif_nest_pas_actif(self):
|
|||
|
|
for sortie, attendu in (
|
|||
|
|
("active\n", True),
|
|||
|
|
("inactive\n", False),
|
|||
|
|
("failed\n", False),
|
|||
|
|
("", False),
|
|||
|
|
):
|
|||
|
|
self.sorties = {"systemctl is-active": sortie}
|
|||
|
|
self.assertIs(M._cache_actif(), attendu, sortie)
|
|||
|
|
|
|||
|
|
def diagnostic(self, coupe, guet):
|
|||
|
|
binaire = self.dossier / "binaire"
|
|||
|
|
binaire.write_text("")
|
|||
|
|
sortie = io.StringIO()
|
|||
|
|
faux = Faux()
|
|||
|
|
with mock.patch.object(menu, "CACHE_BIN", str(binaire)), mock.patch(
|
|||
|
|
"script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_amont_coupe",
|
|||
|
|
classmethod(lambda cls: coupe),
|
|||
|
|
), mock.patch(
|
|||
|
|
"script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_guet_actif",
|
|||
|
|
classmethod(lambda cls: guet),
|
|||
|
|
), mock.patch(
|
|||
|
|
"script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_par_machine",
|
|||
|
|
return_value=[],
|
|||
|
|
), mock.patch(
|
|||
|
|
"script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_compte_issues",
|
|||
|
|
return_value={},
|
|||
|
|
), mock.patch(
|
|||
|
|
"script.todo.qemu_cache_menu.QemuCacheMenuMixin._cache_bypass_lire",
|
|||
|
|
return_value=[],
|
|||
|
|
), contextlib.redirect_stdout(
|
|||
|
|
sortie
|
|||
|
|
):
|
|||
|
|
faux._cache_diagnostic()
|
|||
|
|
return sortie.getvalue()
|
|||
|
|
|
|||
|
|
def test_sous_le_guet_le_diagnostic_donne_larret_du_guet(self):
|
|||
|
|
"""Donner le retrait de la table ferait finir en ligne les
|
|||
|
|
installations qui tournent, et le guet resterait sans rien à
|
|||
|
|
lever."""
|
|||
|
|
for coupe in (True, None):
|
|||
|
|
texte = self.diagnostic(coupe, guet=True)
|
|||
|
|
self.assertIn(cache_offline.lever_maintenant_cmd(), texte)
|
|||
|
|
self.assertNotIn(cache_offline.restore_cmd(), texte)
|
|||
|
|
self.assertIn(
|
|||
|
|
t("held until its last installation ends (12 h at most)."),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_sans_guet_le_diagnostic_donne_le_retrait(self):
|
|||
|
|
texte = self.diagnostic(True, guet=False)
|
|||
|
|
self.assertIn(cache_offline.restore_cmd(), texte)
|
|||
|
|
self.assertNotIn(cache_offline.lever_maintenant_cmd(), texte)
|
|||
|
|
|
|||
|
|
def test_un_guet_sans_coupure_est_nomme(self):
|
|||
|
|
texte = self.diagnostic(False, guet=True)
|
|||
|
|
self.assertIn(
|
|||
|
|
t("The lift watcher still runs, with no cut left to lift."), texte
|
|||
|
|
)
|
|||
|
|
self.assertIn(cache_offline.lever_maintenant_cmd(), texte)
|
|||
|
|
|
|||
|
|
def test_une_coupure_illisible_est_dite_sans_alarme(self):
|
|||
|
|
texte = self.diagnostic(None, guet=False)
|
|||
|
|
self.assertNotIn(cache_offline.restore_cmd(), texte)
|
|||
|
|
self.assertNotIn(
|
|||
|
|
t("Upstream CUT: the cache can pull nothing from the internet"),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
self.assertIn(
|
|||
|
|
t(
|
|||
|
|
"Cannot tell whether the upstream is cut: reading nft needs a"
|
|||
|
|
" sudo password here."
|
|||
|
|
),
|
|||
|
|
texte,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# Remplir les miroirs sous le compte du service
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TestLeRemplissageDesMiroirs(SansSysteme):
|
|||
|
|
"""Un objet posé par root dans un miroir est interdit en écriture au
|
|||
|
|
service : son rafraîchissement échoue, il le prend pour un amont muet, et
|
|||
|
|
le miroir se fige sans rien dire."""
|
|||
|
|
|
|||
|
|
def unite_de_linstallateur(self):
|
|||
|
|
src = INSTALLATEUR.read_text(encoding="utf-8")
|
|||
|
|
unite = self.dossier / "unite.service"
|
|||
|
|
unite.write_text(
|
|||
|
|
"\n".join(
|
|||
|
|
l for l in src.splitlines() if l.startswith("Environment=")
|
|||
|
|
)
|
|||
|
|
)
|
|||
|
|
return str(unite)
|
|||
|
|
|
|||
|
|
def test_lunite_donne_son_environnement_a_git(self):
|
|||
|
|
env = menu.environnement_de_l_unite(self.unite_de_linstallateur())
|
|||
|
|
self.assertEqual(
|
|||
|
|
{e.split("=", 1)[0] for e in env},
|
|||
|
|
{"HOME", "GIT_CONFIG_GLOBAL", "GIT_TERMINAL_PROMPT"},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_la_commande_tourne_sous_le_compte_du_service(self):
|
|||
|
|
cmd = menu.miroir_prefetch_cmd(
|
|||
|
|
"/liste/miroirs.txt", ["HOME=/casiers", "GIT_TERMINAL_PROMPT=0"]
|
|||
|
|
)
|
|||
|
|
self.assertTrue(
|
|||
|
|
cmd.startswith(f"sudo -u {cache_offline.SERVICE_USER} env "), cmd
|
|||
|
|
)
|
|||
|
|
self.assertIn("HOME=/casiers GIT_TERMINAL_PROMPT=0 ", cmd)
|
|||
|
|
self.assertIn(
|
|||
|
|
"--git-mirror-prefetch /dev/stdin < /liste/miroirs.txt", cmd
|
|||
|
|
)
|
|||
|
|
self.assertNotIn(f"sudo {menu.CACHE_BIN}", cmd)
|
|||
|
|
|
|||
|
|
def test_le_menu_lance_cette_commande(self):
|
|||
|
|
maison = self.dossier / "maison"
|
|||
|
|
vraie = os.path.expanduser
|
|||
|
|
with mock.patch.object(
|
|||
|
|
menu, "CACHE_UNITE", self.unite_de_linstallateur()
|
|||
|
|
), mock.patch(
|
|||
|
|
"os.path.expanduser",
|
|||
|
|
lambda p: str(maison) + p[1:] if p.startswith("~") else vraie(p),
|
|||
|
|
), mock.patch(
|
|||
|
|
"click.confirm", return_value=True
|
|||
|
|
):
|
|||
|
|
faux = Faux()
|
|||
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|||
|
|
faux._cache_miroir_remplir(["https://example.com/o/d.git"])
|
|||
|
|
(cmd,) = faux.executees
|
|||
|
|
self.assertTrue(
|
|||
|
|
cmd.startswith(f"sudo -u {cache_offline.SERVICE_USER} ")
|
|||
|
|
)
|
|||
|
|
self.assertIn("GIT_CONFIG_GLOBAL=/dev/null", cmd)
|
|||
|
|
fichier = maison / ".erplibre" / "miroirs_git.txt"
|
|||
|
|
self.assertEqual(stat.S_IMODE(fichier.stat().st_mode), 0o644)
|
|||
|
|
|
|||
|
|
def test_les_chemins_suivent_linstallateur(self):
|
|||
|
|
src = INSTALLATEUR.read_text(encoding="utf-8")
|
|||
|
|
self.assertEqual(
|
|||
|
|
re.search(r'^UNIT="([^"]+)"', src, re.M).group(1), menu.CACHE_UNITE
|
|||
|
|
)
|
|||
|
|
self.assertEqual(cache_offline.BINAIRE, menu.CACHE_BIN)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
# F5 : prévenir avant la coupure, une fois, pour les deux motifs
|
|||
|
|
# ---------------------------------------------------------------------------
|
|||
|
|
|
|||
|
|
|
|||
|
|
@unittest.skipUnless(TEXTUAL, "Textual absent")
|
|||
|
|
class TestLAvertissementAvantLaCoupure(SansSysteme):
|
|||
|
|
"""Même idiome que la suite absente : un premier F5 prévient, un second
|
|||
|
|
passe outre. Les deux motifs partagent UN avertissement — deux
|
|||
|
|
confirmations d'affilée apprendraient à les enchaîner sans lire."""
|
|||
|
|
|
|||
|
|
MANQUE = {
|
|||
|
|
"nom": "vm-essai-hl",
|
|||
|
|
"age": 7200,
|
|||
|
|
"runs": ["r"],
|
|||
|
|
"manquants": [("GET", URL_A), ("GET", URL_B)],
|
|||
|
|
"git": ["https://example.com/o/d.git"],
|
|||
|
|
"jamais": [("POST", URL_POST), ("GET", URL_C)],
|
|||
|
|
"selon_journal": False,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def deployer(self, absentes, manques):
|
|||
|
|
import asyncio
|
|||
|
|
|
|||
|
|
from textual.widgets import Checkbox, SelectionList
|
|||
|
|
|
|||
|
|
from script.todo.qemu_deploy_form import run_deploy_form
|
|||
|
|
from script.todo.todo import TODO
|
|||
|
|
|
|||
|
|
todo = TODO.__new__(TODO)
|
|||
|
|
mod = todo._qemu_import_module()
|
|||
|
|
todo._qemu_list_domains = lambda: []
|
|||
|
|
todo._qemu_branch_list = lambda: ["develop", "master"]
|
|||
|
|
with contextlib.redirect_stdout(io.StringIO()):
|
|||
|
|
ctx = dict(todo._qemu_form_context(mod), cache_offert=True)
|
|||
|
|
vu = {"notes": []}
|
|||
|
|
|
|||
|
|
async def scenario():
|
|||
|
|
app = run_deploy_form(ctx, run_app=False)
|
|||
|
|
async with app.run_test(size=(200, 70)) as pilote:
|
|||
|
|
await pilote.pause()
|
|||
|
|
liste = app.query_one("#f_catalog", SelectionList)
|
|||
|
|
liste.select(liste.options[0].value)
|
|||
|
|
app._recompute()
|
|||
|
|
await pilote.pause()
|
|||
|
|
app.query_one("#f_offline", Checkbox).value = True
|
|||
|
|
await pilote.pause()
|
|||
|
|
app.notify = lambda message, **kw: vu["notes"].append(message)
|
|||
|
|
app.action_deploy()
|
|||
|
|
vu["premier"] = app._result.get("spec")
|
|||
|
|
app.action_deploy()
|
|||
|
|
vu["second"] = app._result.get("spec")
|
|||
|
|
|
|||
|
|
# Les cinq verdicts sont truqués, et non deux : cette épreuve isole
|
|||
|
|
# ce qu'elle nomme. Un verdict laissé libre lit la machine qui
|
|||
|
|
# exécute les tests — son magasin, ses miroirs — et le résultat
|
|||
|
|
# change alors d'un poste à l'autre.
|
|||
|
|
with mock.patch.object(
|
|||
|
|
cache_offline, "suites_absentes", lambda vms: absentes
|
|||
|
|
), mock.patch.object(
|
|||
|
|
cache_offline, "manques_hors_ligne", lambda vms: manques
|
|||
|
|
), mock.patch.object(
|
|||
|
|
cache_offline, "composants_absents", lambda *a, **k: []
|
|||
|
|
), mock.patch.object(
|
|||
|
|
cache_offline, "paquets_absents", lambda *a, **k: []
|
|||
|
|
), mock.patch.object(
|
|||
|
|
cache_offline, "miroirs_absents", lambda *a, **k: []
|
|||
|
|
):
|
|||
|
|
asyncio.run(scenario())
|
|||
|
|
return vu
|
|||
|
|
|
|||
|
|
def test_les_manques_bloquent_le_premier_f5(self):
|
|||
|
|
vu = self.deployer([], [self.MANQUE])
|
|||
|
|
self.assertIsNone(
|
|||
|
|
vu["premier"], "le déploiement est parti sans prévenir"
|
|||
|
|
)
|
|||
|
|
self.assertIsNotNone(vu["second"], "un second F5 ne passe pas outre")
|
|||
|
|
(note,) = vu["notes"]
|
|||
|
|
self.assertIn("vm-essai-hl", note)
|
|||
|
|
self.assertIn(f"{t('lacked at least')} 2 ", note)
|
|||
|
|
self.assertIn("2 h", note)
|
|||
|
|
self.assertIn(URL_A, note)
|
|||
|
|
|
|||
|
|
def test_git_et_jamais_gardees_sont_nommes_pour_ce_quils_sont(self):
|
|||
|
|
"""Une négociation git se comble par le miroir, pas par un rejeu ;
|
|||
|
|
et un GET que le cache ne garde pas n'est pas un POST."""
|
|||
|
|
vu = self.deployer([], [self.MANQUE])
|
|||
|
|
(note,) = vu["notes"]
|
|||
|
|
self.assertIn(
|
|||
|
|
"+1 "
|
|||
|
|
+ t(
|
|||
|
|
"git repositories not mirrored: fill them from entry 5 of"
|
|||
|
|
" the cache menu"
|
|||
|
|
),
|
|||
|
|
note,
|
|||
|
|
)
|
|||
|
|
self.assertIn(
|
|||
|
|
"+2 " + t("requests the cache never keeps:") + " GET, POST", note
|
|||
|
|
)
|
|||
|
|
# L'ancien libellé, qui rangeait tout sous « POST », n'a plus de
|
|||
|
|
# clé : t() le rendrait tel quel, en anglais, et la sonde ne verrait
|
|||
|
|
# jamais le français. Les deux langues sont donc cherchées en clair.
|
|||
|
|
for ancien in (
|
|||
|
|
"POST requests are never kept",
|
|||
|
|
"requêtes POST ne sont jamais gardées",
|
|||
|
|
):
|
|||
|
|
self.assertNotIn(ancien, note)
|
|||
|
|
|
|||
|
|
def test_sans_git_ni_jamais_rien_nest_ajoute(self):
|
|||
|
|
vu = self.deployer([], [dict(self.MANQUE, git=[], jamais=[])])
|
|||
|
|
(note,) = vu["notes"]
|
|||
|
|
self.assertNotIn(t("requests the cache never keeps:"), note)
|
|||
|
|
self.assertNotIn(
|
|||
|
|
t(
|
|||
|
|
"git repositories not mirrored: fill them from entry 5 of"
|
|||
|
|
" the cache menu"
|
|||
|
|
),
|
|||
|
|
note,
|
|||
|
|
)
|
|||
|
|
self.assertNotIn(" (+", note)
|
|||
|
|
|
|||
|
|
def test_un_seul_avertissement_pour_les_deux(self):
|
|||
|
|
vu = self.deployer([("ubuntu", "26.04")], [self.MANQUE])
|
|||
|
|
self.assertIsNone(vu["premier"])
|
|||
|
|
self.assertIsNotNone(vu["second"])
|
|||
|
|
(note,) = vu["notes"]
|
|||
|
|
self.assertIn("ubuntu 26.04", note)
|
|||
|
|
self.assertIn(URL_A, note)
|
|||
|
|
|
|||
|
|
def test_le_journal_seul_est_dit(self):
|
|||
|
|
vu = self.deployer([], [dict(self.MANQUE, selon_journal=True)])
|
|||
|
|
(note,) = vu["notes"]
|
|||
|
|
self.assertIn(
|
|||
|
|
t("according to the log: a purge can make it wrong"), note
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def test_rien_a_dire_rien_ne_bloque(self):
|
|||
|
|
vu = self.deployer([], [])
|
|||
|
|
self.assertIsNotNone(
|
|||
|
|
vu["premier"], "un avertissement sans motif apprend à passer outre"
|
|||
|
|
)
|
|||
|
|
self.assertEqual(vu["notes"], [])
|
|||
|
|
|
|||
|
|
def test_le_formulaire_ne_sait_toujours_pas_couper(self):
|
|||
|
|
src = QEMU_FORM.read_text(encoding="utf-8")
|
|||
|
|
for interdit in ("cut_cmd", "restore_cmd", "nft", "curl"):
|
|||
|
|
self.assertNotIn(interdit, src, interdit)
|
|||
|
|
|
|||
|
|
|
|||
|
|
if __name__ == "__main__":
|
|||
|
|
unittest.main()
|