erplibre/test/test_qemu_cache_preflight.py

1669 lines
64 KiB
Python
Raw Normal View History

#!/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()