1055 lines
42 KiB
Python
1055 lines
42 KiB
Python
|
|
#!/usr/bin/env python3
|
||
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
||
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
||
|
|
|
||
|
|
"""L'entrée « cache QEMU » du menu Déploiement mène-t-elle où elle le dit ?
|
||
|
|
|
||
|
|
Le sous-menu est écrit deux fois — une liste de `prompt_description` qui
|
||
|
|
numérote l'affichage, et une chaîne d'`elif status == "N"` qui dispatche.
|
||
|
|
Insérer une entrée au milieu décale les deux, et une seule erreur envoie
|
||
|
|
l'opérateur dans un autre écran sans que rien ne proteste : l'entrée du cache
|
||
|
|
est arrivée en 8, ce qui a poussé le VPN en 9.
|
||
|
|
|
||
|
|
Le test vérifie aussi que chaque clé i18n de l'entrée résout DANS LES DEUX
|
||
|
|
LANGUES. Une clé absente rend sa propre chaîne anglaise, donc un menu
|
||
|
|
français qui affiche de l'anglais est le symptôme d'une clé oubliée, et rien
|
||
|
|
ne lève.
|
||
|
|
"""
|
||
|
|
|
||
|
|
import ast
|
||
|
|
import re
|
||
|
|
import sys
|
||
|
|
import unittest
|
||
|
|
from pathlib import Path
|
||
|
|
from unittest import mock
|
||
|
|
|
||
|
|
RACINE = Path(__file__).resolve().parent.parent
|
||
|
|
TODO_PY = RACINE / "script" / "todo" / "todo.py"
|
||
|
|
sys.path.insert(0, str(RACINE / "script" / "todo"))
|
||
|
|
|
||
|
|
import todo_i18n # noqa: E402
|
||
|
|
|
||
|
|
# Les clés que l'entrée emploie. Les tenir ICI, et non les relire du code,
|
||
|
|
# fait échouer le test quand une clé disparaît du dictionnaire.
|
||
|
|
CLES = (
|
||
|
|
"QEMU cache - Download mirror for local VMs",
|
||
|
|
"QEMU download cache for local VMs",
|
||
|
|
"Cache - Install or reinstall",
|
||
|
|
"Cache - Diagnose: does it serve?",
|
||
|
|
"Cache - Service state",
|
||
|
|
"Cache - VMs kept out of the cache",
|
||
|
|
"Cache - Git mirrors: fill them ahead",
|
||
|
|
"Cache - Age and cleanup",
|
||
|
|
"Cache - Guide: how it works",
|
||
|
|
"Cache - Tests and performance report",
|
||
|
|
"Install the download cache shared by the QEMU VMs of this host",
|
||
|
|
"HTTP port of the cache (default: 8898): ",
|
||
|
|
"TLS port of the cache (default: 8899): ",
|
||
|
|
"Cache directory (default: /var/cache/erplibre_go_qemu_cache): ",
|
||
|
|
"Will write network rules on the host bridge:",
|
||
|
|
"Only what leaves the VM subnet is redirected",
|
||
|
|
"The rules exist only while the service runs",
|
||
|
|
"Go is absent; the installer lays it down",
|
||
|
|
"Install the QEMU download cache?",
|
||
|
|
"Installing the QEMU download cache (requires sudo)...",
|
||
|
|
"QEMU download cache installed and started",
|
||
|
|
"Certificate authority a VM must trust: ",
|
||
|
|
"The cache install failed, nothing is started",
|
||
|
|
"QEMU cache install script not found: ",
|
||
|
|
"No eviction is written: this cache never shrinks by itself",
|
||
|
|
# Le sous-menu du service : allumer, éteindre, lire.
|
||
|
|
"State of the cache service",
|
||
|
|
"Stopping it removes the rules: no VM is redirected.",
|
||
|
|
"starts at boot",
|
||
|
|
"not at boot",
|
||
|
|
"Service - Start (start)",
|
||
|
|
"Service - Start at boot (enable)",
|
||
|
|
"Service - Do not start at boot (disable)",
|
||
|
|
"Service - Stop (stop)",
|
||
|
|
"Service - Detailed state (status)",
|
||
|
|
"Service - Logs (log)",
|
||
|
|
"Access log, last requests:",
|
||
|
|
# Âge et nettoyage : ce qui occupe, depuis quand, et de quoi en rendre.
|
||
|
|
"Age of the cache, and cleanup",
|
||
|
|
"Age - By day",
|
||
|
|
"Age - By week",
|
||
|
|
"Age - By month",
|
||
|
|
"Clean - What has not served for a while",
|
||
|
|
"Clean - Everything",
|
||
|
|
"Not served since (e.g. 30j, 12h)",
|
||
|
|
"Erase what is listed above?",
|
||
|
|
"Erase the whole cache?",
|
||
|
|
# Les exceptions par adresse MAC : une VM soustraite au détournement.
|
||
|
|
"VMs kept out of the download cache",
|
||
|
|
"Exceptions - Remove the stale ones",
|
||
|
|
"Exceptions - Remove one by its MAC",
|
||
|
|
"No exception: every VM goes through the cache.",
|
||
|
|
"MAC to give back to the cache",
|
||
|
|
"VM gone",
|
||
|
|
"Keep this VM out of the download cache",
|
||
|
|
"Keep this VM out of the download cache? (y/N): ",
|
||
|
|
# Combler ce qui a manqué hors ligne : l'entrée 9 et ses verdicts.
|
||
|
|
"Cache - Fill what offline runs lacked",
|
||
|
|
"What offline runs lacked",
|
||
|
|
"Start it from entry 3 of this menu.",
|
||
|
|
"A replay now would only record more misses.",
|
||
|
|
"No offline miss in the recent window: nothing to fill.",
|
||
|
|
"Everything that was missed is held now.",
|
||
|
|
"according to the log: a purge can make it wrong",
|
||
|
|
"replay through the cache",
|
||
|
|
"never kept: the cache keeps only GET and HEAD",
|
||
|
|
"the cache does not keep this address",
|
||
|
|
"host in tunnel: nothing to keep",
|
||
|
|
"git negotiation: fill the mirror from entry 5",
|
||
|
|
"not a host name: a replay could loop back into the cache",
|
||
|
|
"Nothing here can be replayed.",
|
||
|
|
"The replay sends curl's own headers: a server that varies on",
|
||
|
|
"User-Agent or Accept may keep another answer than the VM's.",
|
||
|
|
"Replay these addresses through the cache now?",
|
||
|
|
"held",
|
||
|
|
"not held",
|
||
|
|
"not re-checked: this binary has no --detient",
|
||
|
|
"Tunnel refusals learned by the service:",
|
||
|
|
"curl got no answer",
|
||
|
|
# La coupure que le guet tient, et celle qu'on ne peut pas lire :
|
||
|
|
# diagnostic et entrée 9.
|
||
|
|
"Upstream CUT by an offline deployment still installing,",
|
||
|
|
"held until its last installation ends (12 h at most).",
|
||
|
|
"Lifting it now makes those installations finish online.",
|
||
|
|
"Lift it now with:",
|
||
|
|
"The lift watcher still runs, with no cut left to lift.",
|
||
|
|
"Stop it with:",
|
||
|
|
"Cannot tell whether the upstream is cut: reading nft needs a sudo"
|
||
|
|
" password here.",
|
||
|
|
"Under the cut, a replay would only record more misses.",
|
||
|
|
"Replay anyway?",
|
||
|
|
)
|
||
|
|
|
||
|
|
CACHE_PY = RACINE / "script" / "todo" / "qemu_cache_menu.py"
|
||
|
|
|
||
|
|
|
||
|
|
def corps_de(nom, suivant):
|
||
|
|
"""Le corps d'une méthode de qemu_cache_menu.py, dispatch compris."""
|
||
|
|
src = CACHE_PY.read_text(encoding="utf-8")
|
||
|
|
debut = src.index(f"def {nom}(self):")
|
||
|
|
return src[debut : src.index(f"def {suivant}(self", debut)]
|
||
|
|
|
||
|
|
|
||
|
|
def affichage_et_dispatch(corps):
|
||
|
|
"""Rend (nombre d'entrées affichées, numéros atteignables, triés).
|
||
|
|
|
||
|
|
Un numéro s'atteint de deux façons : une branche « status == "N" », ou
|
||
|
|
une entrée d'une table qui associe le numéro à un verbe. Ne compter que
|
||
|
|
les branches ferait passer pour un trou ce qu'une table couvre.
|
||
|
|
|
||
|
|
Le zéro sort : il ferme le menu et n'est jamais affiché.
|
||
|
|
"""
|
||
|
|
affichees = len(re.findall(r'"prompt_description": t\(', corps))
|
||
|
|
numeros = set(re.findall(r'if status == "(\d+)":', corps))
|
||
|
|
# Toute table qui associe un numéro à quelque chose compte : le dispatch
|
||
|
|
# passe tantôt par une branche, tantôt par une table de verbes ou de
|
||
|
|
# granularités. N'en connaître qu'une ferait passer pour un trou ce
|
||
|
|
# qu'une autre couvre.
|
||
|
|
for table in re.findall(r"=\s*\{([^}]*)\}", corps):
|
||
|
|
numeros |= set(re.findall(r'"(\d+)"\s*:', table))
|
||
|
|
numeros.discard("0")
|
||
|
|
return affichees, sorted(int(n) for n in numeros)
|
||
|
|
|
||
|
|
|
||
|
|
def corps_du_sous_menu():
|
||
|
|
"""Le corps de prompt_execute_deploy(), affichage et dispatch compris."""
|
||
|
|
src = TODO_PY.read_text(encoding="utf-8")
|
||
|
|
debut = src.index("def prompt_execute_deploy(self):")
|
||
|
|
fin = src.index("def prompt_execute_deploy_ssh(self):", debut)
|
||
|
|
return src[debut:fin]
|
||
|
|
|
||
|
|
|
||
|
|
class TestEntreeDuCache(unittest.TestCase):
|
||
|
|
def setUp(self):
|
||
|
|
self.corps = corps_du_sous_menu()
|
||
|
|
|
||
|
|
def test_entree_affichee(self):
|
||
|
|
self.assertIn(
|
||
|
|
"QEMU cache - Download mirror for local VMs",
|
||
|
|
self.corps,
|
||
|
|
"l'entrée du cache ne s'affiche pas dans le menu Déploiement",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_entree_dispatchee(self):
|
||
|
|
self.assertRegex(
|
||
|
|
self.corps,
|
||
|
|
r'elif status == "8":\s*\n\s*self\.prompt_execute_qemu_cache\(\)',
|
||
|
|
"l'entrée 8 ne mène pas au sous-menu du cache",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_vpn_decale_en_neuf(self):
|
||
|
|
"""L'entrée insérée pousse le VPN : sans quoi deux entrées se
|
||
|
|
partagent le numéro 8 et la seconde est inatteignable."""
|
||
|
|
self.assertRegex(
|
||
|
|
self.corps,
|
||
|
|
r'elif status == "9":\s*\n\s*self\.prompt_execute_vpn\(\)',
|
||
|
|
"le VPN n'a pas été décalé en 9",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_numeros_sans_trou_ni_doublon(self):
|
||
|
|
numeros = [
|
||
|
|
int(n) for n in re.findall(r'elif status == "(\d+)":', self.corps)
|
||
|
|
]
|
||
|
|
self.assertEqual(
|
||
|
|
numeros,
|
||
|
|
sorted(numeros),
|
||
|
|
f"les numéros du dispatch ne sont pas croissants : {numeros}",
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
len(numeros),
|
||
|
|
len(set(numeros)),
|
||
|
|
f"un numéro est dispatché deux fois : {numeros}",
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
numeros,
|
||
|
|
list(range(1, len(numeros) + 1)),
|
||
|
|
f"les numéros ne sont pas consécutifs à partir de 1 : {numeros}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_methode_existe(self):
|
||
|
|
src = TODO_PY.read_text(encoding="utf-8")
|
||
|
|
self.assertIn(
|
||
|
|
"def _deploy_qemu_cache(self):",
|
||
|
|
src,
|
||
|
|
"la méthode que le dispatch appelle n'existe pas",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestSousMenusDuCache(unittest.TestCase):
|
||
|
|
"""Affichage et dispatch sont écrits deux fois, et rien ne les relie.
|
||
|
|
|
||
|
|
La liste de `prompt_description` numérote l'écran ; la chaîne d'`elif
|
||
|
|
status` décide où l'on va. Une entrée insérée au milieu de l'une sans
|
||
|
|
l'autre envoie l'opérateur ailleurs qu'où il a lu, ou rend la dernière
|
||
|
|
entrée inatteignable — dans les deux cas sans un mot d'erreur.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def verifier(self, nom, suivant, attendues):
|
||
|
|
affichees, numeros = affichage_et_dispatch(corps_de(nom, suivant))
|
||
|
|
self.assertEqual(
|
||
|
|
affichees, attendues, f"{nom} n'affiche pas {attendues} entrées"
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
numeros,
|
||
|
|
list(range(1, attendues + 1)),
|
||
|
|
f"{nom} : le dispatch {numeros} ne suit pas l'affichage",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_le_menu_du_cache(self):
|
||
|
|
self.verifier("prompt_execute_qemu_cache", "_cache_systemctl", 11)
|
||
|
|
|
||
|
|
def test_le_menu_du_service(self):
|
||
|
|
self.verifier("_cache_service", "_cache_journal_service", 6)
|
||
|
|
|
||
|
|
def test_le_menu_des_exceptions(self):
|
||
|
|
self.verifier("_cache_exceptions", "_cache_miroir_git", 2)
|
||
|
|
|
||
|
|
def test_le_menu_des_miroirs(self):
|
||
|
|
self.verifier("_cache_miroir_git", "_cache_miroir_remplir", 5)
|
||
|
|
|
||
|
|
def test_le_menu_de_lage(self):
|
||
|
|
self.verifier("_cache_age", "_cache_lancer", 5)
|
||
|
|
|
||
|
|
def test_letat_du_service_est_la_troisieme(self):
|
||
|
|
"""Sous le diagnostic, comme demandé : le décalage du guide et des
|
||
|
|
tests est la moitié du changement, et c'est celle qui casse."""
|
||
|
|
corps = corps_de("prompt_execute_qemu_cache", "_cache_systemctl")
|
||
|
|
for numero, methode in (
|
||
|
|
("3", "_cache_service"),
|
||
|
|
("4", "_cache_exceptions"),
|
||
|
|
("5", "_cache_miroir_git"),
|
||
|
|
("6", "_cache_age"),
|
||
|
|
("7", "_cache_guide"),
|
||
|
|
("8", "_cache_tests"),
|
||
|
|
("9", "_cache_combler"),
|
||
|
|
("10", "_cache_journaux"),
|
||
|
|
("11", "_cache_transfert"),
|
||
|
|
):
|
||
|
|
self.assertRegex(
|
||
|
|
corps,
|
||
|
|
rf'elif status == "{numero}":\s*\n\s*self\.{methode}\(\)',
|
||
|
|
f"l'entrée {numero} ne mène pas à {methode}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_les_quatre_verbes_systemd(self):
|
||
|
|
"""start, enable, disable et stop, et pas un cinquième par erreur."""
|
||
|
|
corps = corps_de("_cache_service", "_cache_journal_service")
|
||
|
|
verbes = re.search(r"verbes = \{([^}]*)\}", corps)
|
||
|
|
self.assertIsNotNone(verbes, "la table des verbes a disparu")
|
||
|
|
self.assertEqual(
|
||
|
|
re.findall(r'"(\w+)"', verbes.group(1))[1::2],
|
||
|
|
["start", "enable", "disable", "stop"],
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLeTransfertDuCache(unittest.TestCase):
|
||
|
|
"""Le magasin s'emporte ; les réglages restent.
|
||
|
|
|
||
|
|
Un objet est rangé sous une clé tirée de l'URL, jamais de la machine qui
|
||
|
|
l'a pris : il vaut donc ailleurs. Le pont, le sous-réseau et l'autorité,
|
||
|
|
eux, appartiennent à l'hôte — emporter l'autorité ferait servir là-bas
|
||
|
|
une signature dont aucune VM locale n'a la clé.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def _menu(self):
|
||
|
|
import sys
|
||
|
|
|
||
|
|
sys.argv = ["todo.py"]
|
||
|
|
from script.todo.todo import TODO
|
||
|
|
|
||
|
|
return TODO.__new__(TODO)
|
||
|
|
|
||
|
|
def test_le_flux_va_dun_tar_a_lautre_sans_fichier_intermediaire(self):
|
||
|
|
"""Un magasin de dizaines de gigaoctets n'a pas à exister deux fois."""
|
||
|
|
cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x")
|
||
|
|
# Un chemin sans caractère spécial ressort tel quel : c'est la forme
|
||
|
|
# RENDUE qu'on éprouve, pas celle qu'on imagine.
|
||
|
|
self.assertIn("tar -C /var/cache/x -cf - .", cmd)
|
||
|
|
self.assertIn("| zstd", cmd)
|
||
|
|
self.assertIn("ssh op@ailleurs", cmd)
|
||
|
|
self.assertIn("tar -C /var/cache/x -xf -", cmd)
|
||
|
|
self.assertNotIn(">", cmd, "un fichier intermédiaire est écrit")
|
||
|
|
|
||
|
|
def test_larrivee_rend_les_fichiers_au_compte_du_service(self):
|
||
|
|
"""Le même compte porte rarement le même numéro d'une machine à
|
||
|
|
l'autre : sans ce « chown », le service ne lirait pas son magasin."""
|
||
|
|
from script.qemu import cache_offline
|
||
|
|
|
||
|
|
cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x")
|
||
|
|
self.assertIn("--numeric-owner", cmd)
|
||
|
|
self.assertIn(
|
||
|
|
f"chown -R {cache_offline.SERVICE_USER}:"
|
||
|
|
f"{cache_offline.SERVICE_USER}",
|
||
|
|
cmd,
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_une_cible_hostile_ne_secrit_pas_dans_la_commande(self):
|
||
|
|
"""Le nom vient d'une invite : une apostrophe y casserait la ligne,
|
||
|
|
et le reste s'exécuterait sur CETTE machine."""
|
||
|
|
import shlex
|
||
|
|
|
||
|
|
mechant = "op@x'; rm -rf /; #"
|
||
|
|
cmd = self._menu()._cache_transfert_cmd(mechant, "/var/c")
|
||
|
|
# La preuve de l'échappement : le shell rend la cible en UN seul
|
||
|
|
# argument, identique à ce qui a été tapé. Chercher le texte dangereux
|
||
|
|
# dans la ligne ne prouverait rien — il y est, enfermé.
|
||
|
|
mots = shlex.split(cmd[cmd.index("ssh ") :])
|
||
|
|
self.assertEqual(mots[0], "ssh")
|
||
|
|
self.assertEqual(mots[1], mechant)
|
||
|
|
|
||
|
|
def test_larrivee_ne_demande_le_privilege_quune_fois(self):
|
||
|
|
"""Un ticket sudo se périme ; le transfert, lui, dure. En deux
|
||
|
|
invocations, la seconde — le « chown » — tomberait après des
|
||
|
|
dizaines de minutes sur une demande que plus rien ne peut saisir :
|
||
|
|
le magasin serait posé, et illisible pour le compte qui le sert."""
|
||
|
|
import shlex
|
||
|
|
|
||
|
|
cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x")
|
||
|
|
distant = shlex.split(cmd[cmd.index("ssh ") :])[2]
|
||
|
|
self.assertEqual(distant.count("sudo "), 1, distant)
|
||
|
|
self.assertIn("chown -R", distant)
|
||
|
|
|
||
|
|
def test_le_premier_temps_ne_demande_aucun_privilege_a_larrivee(self):
|
||
|
|
"""C'est ce qui rend ce mode possible sans terminal : « cat » écrit
|
||
|
|
dans le répertoire personnel du compte ssh, qui lui appartient. Un
|
||
|
|
sudo à l'arrivée y ramènerait la panne qu'on contourne."""
|
||
|
|
import shlex
|
||
|
|
|
||
|
|
cmd = self._menu()._cache_envoi_fichier_cmd(
|
||
|
|
"op@ailleurs", "/var/cache/x"
|
||
|
|
)
|
||
|
|
distant = shlex.split(cmd[cmd.index("ssh ") :])[2]
|
||
|
|
self.assertNotIn("sudo", distant, distant)
|
||
|
|
self.assertIn("cat > ~/", distant)
|
||
|
|
|
||
|
|
def test_le_second_temps_extrait_rend_et_retire(self):
|
||
|
|
"""Trois gestes, dans cet ordre : extraire, rendre au compte du
|
||
|
|
service, retirer le fichier. En oublier le dernier laisse le double
|
||
|
|
de l'occupation sur une machine qui n'avait déjà que la place."""
|
||
|
|
cmd = self._menu()._cache_finir_la_bas_cmd("/var/cache/x")
|
||
|
|
self.assertIn("zstd -dc ~/", cmd)
|
||
|
|
self.assertIn("tar -C /var/cache/x -xf -", cmd)
|
||
|
|
self.assertIn("chown -R", cmd)
|
||
|
|
self.assertIn("rm -f ~/", cmd)
|
||
|
|
self.assertEqual(cmd.count("sudo "), 1, cmd)
|
||
|
|
|
||
|
|
def test_les_reglages_ne_voyagent_pas(self):
|
||
|
|
"""Ni l'autorité, ni le pont, ni le sous-réseau."""
|
||
|
|
cmd = self._menu()._cache_transfert_cmd("op@ailleurs", "/var/cache/x")
|
||
|
|
for reste in ("ca.crt", "EL_BRIDGE", "EL_SUBNET", "/etc/"):
|
||
|
|
self.assertNotIn(reste, cmd)
|
||
|
|
|
||
|
|
|
||
|
|
class TestCeQuOnDitQuandLArriveeNeSuitPas(unittest.TestCase):
|
||
|
|
"""Un refus doit nommer le geste qui le lève, et il n'est pas ici.
|
||
|
|
|
||
|
|
L'entrée 1 pose le cache sur CETTE machine : y renvoyer fait réinstaller
|
||
|
|
l'hôte qui en a déjà un, pendant que la machine d'arrivée reste sans
|
||
|
|
rien. Trois situations appellent trois gestes — un lien ssh muet, un
|
||
|
|
cache absent, un cache privé de son compte de service — et un code de
|
||
|
|
retour unique les confondait sous un seul message.
|
||
|
|
|
||
|
|
Les assertions portent sur des ancres qui ne se traduisent pas : une
|
||
|
|
commande, un chemin, un nom de variable. Comparer du texte traduit
|
||
|
|
ferait échouer le test au premier changement de langue.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def refus(self, code, sortie, confirmer=False, taille=0):
|
||
|
|
"""Exerce l'entrée avec une sonde truquée. Rend (texte, lancées)."""
|
||
|
|
import contextlib
|
||
|
|
import io
|
||
|
|
|
||
|
|
from script.todo.todo import TODO
|
||
|
|
|
||
|
|
lancees = []
|
||
|
|
sondes = []
|
||
|
|
|
||
|
|
class Faux(TODO):
|
||
|
|
def __init__(self):
|
||
|
|
self.execute = self
|
||
|
|
|
||
|
|
def exec_command_live(self, cmd, **_kw):
|
||
|
|
lancees.append(cmd)
|
||
|
|
return 0
|
||
|
|
|
||
|
|
def _cache_ssh(self, cible, commande, timeout=30):
|
||
|
|
sondes.append(commande)
|
||
|
|
return code, sortie
|
||
|
|
|
||
|
|
# La taille du magasin se lit par « sudo du » sur des dizaines
|
||
|
|
# de gigaoctets : le test la DONNE, sinon il mesurerait la
|
||
|
|
# machine qui l'exécute et durerait le temps d'un parcours.
|
||
|
|
def _cache_octets(self, chemin):
|
||
|
|
return taille
|
||
|
|
|
||
|
|
tampon = io.StringIO()
|
||
|
|
with contextlib.ExitStack() as pile:
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch("click.prompt", return_value="op@ailleurs")
|
||
|
|
)
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch("click.confirm", return_value=confirmer)
|
||
|
|
)
|
||
|
|
# Le cache local et ses réglages ne sont pas le sujet : le test
|
||
|
|
# doit rendre le même verdict sur une machine qui n'en a pas.
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch(
|
||
|
|
"script.todo.qemu_cache_menu.os.path.isfile",
|
||
|
|
return_value=True,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch(
|
||
|
|
"script.qemu.cache_offline.reglage",
|
||
|
|
return_value="/var/cache/x",
|
||
|
|
)
|
||
|
|
)
|
||
|
|
pile.enter_context(contextlib.redirect_stdout(tampon))
|
||
|
|
Faux()._cache_transfert()
|
||
|
|
self.sondes = sondes
|
||
|
|
return tampon.getvalue(), lancees
|
||
|
|
|
||
|
|
def test_un_lien_ssh_muet_nest_pas_un_cache_absent(self):
|
||
|
|
"""Sans le jeton final, c'est la sonde qui n'a pas tourné : accuser
|
||
|
|
le cache enverrait installer ce qui est peut-être déjà là."""
|
||
|
|
texte, lancees = self.refus(255, "")
|
||
|
|
self.assertIn("ssh-copy-id op@ailleurs", texte)
|
||
|
|
self.assertNotIn("install_qemu_cache.sh", texte)
|
||
|
|
self.assertEqual(lancees, [], "le magasin est parti malgré le refus")
|
||
|
|
|
||
|
|
def test_un_ssh_qui_rend_zero_sans_rien_dire_compte_pour_muet(self):
|
||
|
|
"""La sonde se termine TOUJOURS par son jeton. Un canal qui rend 0
|
||
|
|
sans lui ne l'a donc pas exécutée : l'état du cache de là-bas est
|
||
|
|
inconnu, et l'annoncer absent serait inventer."""
|
||
|
|
texte, lancees = self.refus(0, "")
|
||
|
|
self.assertIn("ssh-copy-id op@ailleurs", texte)
|
||
|
|
self.assertNotIn("install_qemu_cache.sh", texte)
|
||
|
|
self.assertEqual(lancees, [])
|
||
|
|
|
||
|
|
def test_un_cache_absent_donne_les_gestes_a_faire_la_bas(self):
|
||
|
|
"""Le geste est SUR l'arrivée, et l'installateur y meurt sans
|
||
|
|
libvirt : les deux issues sont nommées, pas seulement la première."""
|
||
|
|
texte, lancees = self.refus(0, "FIN\n")
|
||
|
|
self.assertIn("1. ssh op@ailleurs", texte)
|
||
|
|
self.assertIn("git fetch && git switch ", texte)
|
||
|
|
self.assertIn("sudo bash script/install/install_qemu_cache.sh", texte)
|
||
|
|
self.assertIn("systemctl start libvirtd.socket", texte)
|
||
|
|
self.assertIn("net-start default", texte)
|
||
|
|
self.assertIn("EL_BRIDGE=", texte)
|
||
|
|
self.assertNotIn("ssh-copy-id", texte)
|
||
|
|
self.assertEqual(lancees, [])
|
||
|
|
|
||
|
|
def test_la_branche_nommee_est_celle_de_cet_hote(self):
|
||
|
|
"""L'installateur est un FICHIER du dépôt : une machine restée sur
|
||
|
|
une branche qui ne le porte pas répond « fichier introuvable », ce
|
||
|
|
qui ne ressemble en rien à un cache manquant et fait chercher la
|
||
|
|
panne ailleurs. Le nom ne se devine donc pas, il se lit ici."""
|
||
|
|
from script.todo.todo import TODO
|
||
|
|
|
||
|
|
branche = TODO._cache_branche_ici()
|
||
|
|
if not branche:
|
||
|
|
self.skipTest("dépôt en tête détachée : aucune branche à nommer")
|
||
|
|
texte, _lancees = self.refus(0, "FIN\n")
|
||
|
|
self.assertIn(f"git switch {branche}", texte)
|
||
|
|
|
||
|
|
def test_un_cache_sans_son_compte_se_dit_autrement(self):
|
||
|
|
"""Les fichiers arriveraient à root : le service ne les lirait pas.
|
||
|
|
Ce n'est pas une absence de cache, et le remède n'est pas le même."""
|
||
|
|
from script.qemu import cache_offline
|
||
|
|
|
||
|
|
texte, lancees = self.refus(0, "binaire\nFIN\n")
|
||
|
|
self.assertIn(cache_offline.SERVICE_USER, texte)
|
||
|
|
self.assertIn("sudo bash script/install/install_qemu_cache.sh", texte)
|
||
|
|
# Les lignes de libvirt appartiennent à l'autre cas : les voir ici
|
||
|
|
# dirait de réparer un réseau qui n'est pour rien dans la panne.
|
||
|
|
self.assertNotIn("libvirtd.socket", texte)
|
||
|
|
self.assertEqual(lancees, [])
|
||
|
|
|
||
|
|
def test_une_arrivee_complete_mene_a_la_commande(self):
|
||
|
|
"""La contre-épreuve : un jeton renommé ferait refuser une machine
|
||
|
|
prête, et le refus ne se verrait que le jour du transfert."""
|
||
|
|
texte, lancees = self.refus(0, "binaire\ncompte\nsudo\nFIN\n")
|
||
|
|
self.assertIn("tar -C /var/cache/x -cf - .", texte)
|
||
|
|
self.assertNotIn("install_qemu_cache.sh", texte)
|
||
|
|
self.assertEqual(lancees, [], "la confirmation a été refusée")
|
||
|
|
|
||
|
|
def test_un_sudo_qui_reclame_un_mot_de_passe_offre_les_deux_issues(self):
|
||
|
|
"""Le magasin occupe l'entrée standard de ssh, qui porte des octets
|
||
|
|
et non un terminal : sudo refuse de lire un mot de passe ailleurs.
|
||
|
|
Un ticket pris d'avance n'y peut rien — sudo l'attache au terminal
|
||
|
|
qui l'a obtenu — donc les deux issues offertes sont d'élargir les
|
||
|
|
droits une fois, ou de passer par un fichier que le compte
|
||
|
|
d'arrivée écrit lui-même."""
|
||
|
|
texte, lancees = self.refus(0, "binaire\ncompte\ncompte_ssh=op\nFIN\n")
|
||
|
|
self.assertIn("NOPASSWD", texte)
|
||
|
|
self.assertIn("/etc/sudoers.d/erplibre_cache", texte)
|
||
|
|
self.assertIn("op ALL=(root)", texte, "le compte lu n'est pas repris")
|
||
|
|
self.assertNotIn(
|
||
|
|
"sudo -v", texte, "le ticket ne marche pas, ne pas le conseiller"
|
||
|
|
)
|
||
|
|
self.assertEqual(lancees, [])
|
||
|
|
|
||
|
|
def test_la_place_manquante_ecarte_le_mode_en_deux_temps(self):
|
||
|
|
"""Le fichier et le magasin extrait coexistent : il faut DEUX fois
|
||
|
|
la taille. L'annoncer après l'envoi laisserait une machine pleine
|
||
|
|
et un magasin à moitié posé."""
|
||
|
|
texte, lancees = self.refus(
|
||
|
|
0,
|
||
|
|
"binaire\ncompte\nplace_magasin=1000\nplace_compte=1000\nFIN\n",
|
||
|
|
confirmer=True,
|
||
|
|
taille=800,
|
||
|
|
)
|
||
|
|
self.assertIn("✗", texte)
|
||
|
|
self.assertEqual(lancees, [], "l'envoi est parti malgré la place")
|
||
|
|
|
||
|
|
def test_la_sonde_annonce_les_jetons_que_la_lecture_attend(self):
|
||
|
|
"""La sonde et sa lecture sont les deux moitiés d'un accord : en
|
||
|
|
renommer un jeton d'un seul côté ferait refuser toute machine, et
|
||
|
|
le refus ne se verrait qu'au moment d'emporter le magasin.
|
||
|
|
|
||
|
|
Les autres épreuves truquent la sonde pour choisir la situation :
|
||
|
|
aucune ne regarde ce qui part vraiment sur le lien. Celle-ci le
|
||
|
|
lit."""
|
||
|
|
from script.qemu import cache_offline
|
||
|
|
from script.todo import qemu_cache_menu
|
||
|
|
|
||
|
|
self.refus(0, "binaire\ncompte\nsudo\nFIN\n")
|
||
|
|
sonde = self.sondes[0]
|
||
|
|
for jeton in (
|
||
|
|
"echo binaire",
|
||
|
|
"echo compte",
|
||
|
|
"sudo -n true",
|
||
|
|
"echo sudo",
|
||
|
|
"compte_ssh=",
|
||
|
|
"place_magasin=",
|
||
|
|
"place_compte=",
|
||
|
|
"echo FIN",
|
||
|
|
):
|
||
|
|
self.assertIn(jeton, sonde)
|
||
|
|
self.assertIn(qemu_cache_menu.CACHE_BIN, sonde)
|
||
|
|
self.assertIn(cache_offline.SERVICE_USER, sonde)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLesEntreesDesMiroirsVisentLeurListe(unittest.TestCase):
|
||
|
|
"""Chaque entrée de remplissage passe SA liste, et pas une voisine.
|
||
|
|
|
||
|
|
La base, l'extra et le remplissage complet partagent tout — l'en-tête,
|
||
|
|
la confirmation, la commande — sauf la liste qu'ils transmettent. Une
|
||
|
|
entrée « extra » qui passerait la base remplirait des dépôts déjà
|
||
|
|
complets et laisserait ceux qui manquent, sans que rien à l'écran ne le
|
||
|
|
trahisse : la commande affichée a la même forme dans les deux cas.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def remplir(self, choix):
|
||
|
|
"""Pilote le sous-menu. Rend la liste reçue par le remplissage."""
|
||
|
|
import contextlib
|
||
|
|
import io
|
||
|
|
|
||
|
|
from script.todo.todo import TODO
|
||
|
|
|
||
|
|
vu = {}
|
||
|
|
|
||
|
|
class Faux(TODO):
|
||
|
|
def __init__(self):
|
||
|
|
pass
|
||
|
|
|
||
|
|
def _cache_miroir_occupation(self):
|
||
|
|
return 0, "0 o"
|
||
|
|
|
||
|
|
def _cache_place_libre(self):
|
||
|
|
return "?"
|
||
|
|
|
||
|
|
def fill_help_info(self, choices):
|
||
|
|
return ""
|
||
|
|
|
||
|
|
def _cache_miroir_remplir(self, liste):
|
||
|
|
vu["liste"] = list(liste)
|
||
|
|
|
||
|
|
reponses = iter([choix, "0"])
|
||
|
|
with contextlib.ExitStack() as pile:
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch(
|
||
|
|
"click.prompt", side_effect=lambda *a, **k: next(reponses)
|
||
|
|
)
|
||
|
|
)
|
||
|
|
# Le binaire installé et l'état des miroirs ne sont pas le sujet :
|
||
|
|
# le verdict doit tenir sur une machine qui n'a ni l'un ni l'autre.
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch(
|
||
|
|
"script.todo.qemu_cache_menu.os.path.isfile",
|
||
|
|
return_value=True,
|
||
|
|
)
|
||
|
|
)
|
||
|
|
pile.enter_context(
|
||
|
|
mock.patch(
|
||
|
|
"script.qemu.cache_offline.miroirs_absents",
|
||
|
|
return_value=[],
|
||
|
|
)
|
||
|
|
)
|
||
|
|
pile.enter_context(contextlib.redirect_stdout(io.StringIO()))
|
||
|
|
Faux()._cache_miroir_git()
|
||
|
|
return vu.get("liste")
|
||
|
|
|
||
|
|
def version(self):
|
||
|
|
from script.todo.qemu_cache_menu import version_active
|
||
|
|
|
||
|
|
v = version_active(str(RACINE))
|
||
|
|
if not v:
|
||
|
|
self.skipTest("aucune version d'Odoo active dans ce checkout")
|
||
|
|
return v
|
||
|
|
|
||
|
|
def test_lentree_base_passe_la_base(self):
|
||
|
|
from script.todo.qemu_cache_menu import depots_des_manifestes
|
||
|
|
|
||
|
|
v = self.version()
|
||
|
|
self.assertEqual(
|
||
|
|
self.remplir("1"), depots_des_manifestes(str(RACINE), v)
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_lentree_extra_passe_lextra(self):
|
||
|
|
from script.todo.qemu_cache_menu import (
|
||
|
|
depots_des_manifestes,
|
||
|
|
manifeste_extra,
|
||
|
|
)
|
||
|
|
|
||
|
|
v = self.version()
|
||
|
|
extra = depots_des_manifestes(
|
||
|
|
str(RACINE), fichiers=[manifeste_extra(v)]
|
||
|
|
)
|
||
|
|
if not extra:
|
||
|
|
self.skipTest(f"aucun manifeste extra pour {v}")
|
||
|
|
self.assertEqual(self.remplir("2"), extra)
|
||
|
|
|
||
|
|
def test_lentree_complete_passe_tous_les_manifestes(self):
|
||
|
|
from script.todo.qemu_cache_menu import depots_des_manifestes
|
||
|
|
|
||
|
|
self.assertEqual(self.remplir("3"), depots_des_manifestes(str(RACINE)))
|
||
|
|
|
||
|
|
|
||
|
|
class TestLesIconesDeLAssistant(unittest.TestCase):
|
||
|
|
"""Chaque choix de l'assistant porte une icône, système compris.
|
||
|
|
|
||
|
|
Les icônes des systèmes vivent dans le menu et non dans `distro_label`,
|
||
|
|
qui sert aussi aux formulaires de déploiement et aux journaux. Une
|
||
|
|
distribution ajoutée au catalogue sans icône retomberait sur l'icône de
|
||
|
|
repli sans que rien ne le dise : c'est ce que ces épreuves surveillent.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_chaque_systeme_mesurable_a_son_icone(self):
|
||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
||
|
|
|
||
|
|
module = M._cache_module_test()
|
||
|
|
sans = sorted(
|
||
|
|
set(module.systemes_mesurables()) - set(M._CACHE_ICONES_SYSTEMES)
|
||
|
|
)
|
||
|
|
self.assertEqual(sans, [], f"systèmes sans icône : {sans}")
|
||
|
|
|
||
|
|
def test_le_libelle_du_catalogue_reste_intact(self):
|
||
|
|
"""L'icône précède le libellé, elle ne le remplace pas."""
|
||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
||
|
|
|
||
|
|
module = M._cache_module_test()
|
||
|
|
for distro, libelle in M._cache_systemes():
|
||
|
|
self.assertEqual(
|
||
|
|
libelle,
|
||
|
|
f"{M._CACHE_ICONES_SYSTEMES[distro]}"
|
||
|
|
f" {module.distro_label(distro, module.DISTROS[distro][1])}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_les_essais_et_les_charges_portent_une_icone(self):
|
||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
||
|
|
|
||
|
|
cles = [e[1] for e in M._CACHE_ESSAIS] + [
|
||
|
|
c[1] for c in M._CACHE_CHARGES
|
||
|
|
]
|
||
|
|
cles.append("All three, one after another")
|
||
|
|
for cle in cles:
|
||
|
|
for langue in ("fr", "en"):
|
||
|
|
valeur = todo_i18n.TRANSLATIONS[cle][langue]
|
||
|
|
self.assertFalse(
|
||
|
|
valeur[0].isalnum(), f"« {cle} » ({langue}) sans icône"
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLAssistantDesTests(unittest.TestCase):
|
||
|
|
"""Trois questions — quel essai, quelle charge, quel système — puis les
|
||
|
|
essais choisis, l'un après l'autre.
|
||
|
|
|
||
|
|
Deux propriétés que rien d'autre ne tient. « Les trois » doit lancer TROIS
|
||
|
|
commandes, chacune portant le système et la charge choisis : en oublier un
|
||
|
|
ferait mesurer autre chose que ce qui a été demandé, sans rien dire. Et la
|
||
|
|
confirmation porte sur le LOT : la reposer à chaque essai la rendrait
|
||
|
|
machinale, ce qui est exactement ce qui fait qu'on cesse de la lire.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def assistant(self, reponses):
|
||
|
|
from script.todo.todo import TODO
|
||
|
|
|
||
|
|
lancees = []
|
||
|
|
|
||
|
|
class Faux(TODO):
|
||
|
|
def __init__(self):
|
||
|
|
self.execute = self
|
||
|
|
|
||
|
|
def exec_command_live(self, cmd, **_kw):
|
||
|
|
lancees.append(cmd)
|
||
|
|
|
||
|
|
it = iter(reponses)
|
||
|
|
# « click.confirm » et « longtest_menu.click.confirm » sont le MÊME
|
||
|
|
# objet : un seul mock les couvre, et c'est ce qui rend le compte
|
||
|
|
# d'appels lisible — une question en tout, pas une par essai.
|
||
|
|
with mock.patch(
|
||
|
|
"click.prompt", side_effect=lambda *a, **k: next(it)
|
||
|
|
), mock.patch("click.confirm", return_value=True) as confirme:
|
||
|
|
Faux()._cache_assistant()
|
||
|
|
return lancees, confirme
|
||
|
|
|
||
|
|
def test_les_trois_lancent_trois_commandes(self):
|
||
|
|
lancees, _c = self.assistant(["4", "1", "2"])
|
||
|
|
self.assertEqual(len(lancees), 3, f"lancées : {lancees}")
|
||
|
|
options = {"", "--hors-ligne", "--sans-cache"}
|
||
|
|
for attendu in options:
|
||
|
|
self.assertTrue(
|
||
|
|
any(c.rstrip().endswith(attendu) for c in lancees)
|
||
|
|
or attendu == "",
|
||
|
|
f"« {attendu} » n'a pas été lancé : {lancees}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_le_systeme_et_la_charge_suivent_chaque_essai(self):
|
||
|
|
lancees, _c = self.assistant(["4", "2", "3"])
|
||
|
|
for cmd in lancees:
|
||
|
|
self.assertIn("--distro debian", cmd)
|
||
|
|
self.assertIn("--charge erplibre", cmd)
|
||
|
|
|
||
|
|
def test_un_seul_essai_ne_lance_que_lui(self):
|
||
|
|
lancees, _c = self.assistant(["3", "1", "2"])
|
||
|
|
self.assertEqual(len(lancees), 1)
|
||
|
|
self.assertIn("--sans-cache", lancees[0])
|
||
|
|
|
||
|
|
def test_une_seule_question_pour_tout_le_lot(self):
|
||
|
|
"""Trois essais, une question. La reposer à chaque essai la rendrait
|
||
|
|
machinale, ce qui est exactement ce qui fait qu'on cesse de la lire."""
|
||
|
|
lancees, confirme = self.assistant(["4", "1", "2"])
|
||
|
|
self.assertEqual(len(lancees), 3)
|
||
|
|
self.assertEqual(
|
||
|
|
confirme.call_count,
|
||
|
|
1,
|
||
|
|
f"{confirme.call_count} confirmations pour trois essais",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_renoncer_ne_lance_rien(self):
|
||
|
|
for reponses in (["0"], ["4", "0"], ["4", "1", "0"]):
|
||
|
|
lancees, _c = self.assistant(reponses)
|
||
|
|
self.assertEqual(lancees, [], f"réponses {reponses}")
|
||
|
|
|
||
|
|
|
||
|
|
class TestToutesLesClesDuFichier(unittest.TestCase):
|
||
|
|
"""La liste CLES est tenue à la main, donc elle oublie.
|
||
|
|
|
||
|
|
Ce contrôle-ci ne tient aucune liste : il relève par l'ARBRE tout appel
|
||
|
|
« t("…") » du module et vérifie que le dictionnaire répond. Une clé
|
||
|
|
absente ne lève pas — t() rend sa propre chaîne anglaise — et le symptôme
|
||
|
|
est un menu français qui affiche une ligne en anglais, ce qu'aucun test de
|
||
|
|
numérotation ne voit.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def test_chaque_appel_a_sa_traduction(self):
|
||
|
|
arbre = ast.parse(CACHE_PY.read_text(encoding="utf-8"))
|
||
|
|
cles = {
|
||
|
|
n.args[0].value
|
||
|
|
for n in ast.walk(arbre)
|
||
|
|
if isinstance(n, ast.Call)
|
||
|
|
and isinstance(n.func, ast.Name)
|
||
|
|
and n.func.id == "t"
|
||
|
|
and n.args
|
||
|
|
and isinstance(n.args[0], ast.Constant)
|
||
|
|
and isinstance(n.args[0].value, str)
|
||
|
|
}
|
||
|
|
self.assertGreater(len(cles), 50, "le relevé n'a presque rien trouvé")
|
||
|
|
manquantes = sorted(c for c in cles if c not in todo_i18n.TRANSLATIONS)
|
||
|
|
self.assertEqual(
|
||
|
|
manquantes,
|
||
|
|
[],
|
||
|
|
"clés employées mais absentes du dictionnaire — le menu français"
|
||
|
|
f" affichera l'anglais : {manquantes}",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestClesI18n(unittest.TestCase):
|
||
|
|
def test_cles_presentes(self):
|
||
|
|
manquantes = [c for c in CLES if c not in todo_i18n.TRANSLATIONS]
|
||
|
|
self.assertEqual(
|
||
|
|
manquantes, [], f"clés absentes du dictionnaire : {manquantes}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_les_deux_langues_repondent(self):
|
||
|
|
for cle in CLES:
|
||
|
|
entree = todo_i18n.TRANSLATIONS[cle]
|
||
|
|
for langue in ("fr", "en"):
|
||
|
|
self.assertIn(langue, entree, f"« {cle} » n'a pas de {langue}")
|
||
|
|
self.assertTrue(
|
||
|
|
entree[langue].strip(),
|
||
|
|
f"« {cle} » a un {langue} vide",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_le_francais_est_traduit(self):
|
||
|
|
"""Une valeur française identique à l'anglaise trahit une clé posée
|
||
|
|
sans traduction. Les libellés purement techniques y échappent."""
|
||
|
|
sans_traduction = [
|
||
|
|
c
|
||
|
|
for c in CLES
|
||
|
|
if todo_i18n.TRANSLATIONS[c]["fr"]
|
||
|
|
== todo_i18n.TRANSLATIONS[c]["en"]
|
||
|
|
]
|
||
|
|
self.assertEqual(
|
||
|
|
sans_traduction, [], f"non traduites : {sans_traduction}"
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_icones_des_entrees(self):
|
||
|
|
"""L'icône vit DANS la chaîne traduite, comme partout ailleurs dans le
|
||
|
|
menu : les deux langues doivent donc la porter."""
|
||
|
|
attendues = {
|
||
|
|
"QEMU cache - Download mirror for local VMs": "📦",
|
||
|
|
"Cache - Install or reinstall": "📥",
|
||
|
|
"Cache - Diagnose: does it serve?": "🔍",
|
||
|
|
"Cache - Service state": "⚙",
|
||
|
|
"Cache - VMs kept out of the cache": "🎫",
|
||
|
|
"Cache - Git mirrors: fill them ahead": "🪞",
|
||
|
|
"Cache - Age and cleanup": "🧭",
|
||
|
|
"Age - By day": "📅",
|
||
|
|
"Clean - Everything": "🔥",
|
||
|
|
"Cache - Guide: how it works": "📖",
|
||
|
|
"Exceptions - Remove the stale ones": "🧹",
|
||
|
|
"Exceptions - Remove one by its MAC": "✂",
|
||
|
|
"Service - Start (start)": "▶",
|
||
|
|
"Service - Start at boot (enable)": "🔗",
|
||
|
|
"Service - Do not start at boot (disable)": "🚫",
|
||
|
|
"Service - Stop (stop)": "⏹",
|
||
|
|
"Service - Detailed state (status)": "📋",
|
||
|
|
"Service - Logs (log)": "📜",
|
||
|
|
"Cache - Tests and performance report": "🧪",
|
||
|
|
"Cache - Fill what offline runs lacked": "🩹",
|
||
|
|
}
|
||
|
|
for cle, icone in attendues.items():
|
||
|
|
entree = todo_i18n.TRANSLATIONS[cle]
|
||
|
|
for langue in ("fr", "en"):
|
||
|
|
self.assertTrue(
|
||
|
|
entree[langue].startswith(icone),
|
||
|
|
f"« {cle} » en {langue} ne porte pas {icone} :"
|
||
|
|
f" {entree[langue]}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_aucune_cle_en_double(self):
|
||
|
|
"""Une clé en double écrase silencieusement la précédente."""
|
||
|
|
src = (RACINE / "script" / "todo" / "todo_i18n.py").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
for cle in CLES:
|
||
|
|
litteral = ' "%s": {' % cle.replace('"', '\\"')
|
||
|
|
self.assertEqual(
|
||
|
|
src.count(litteral),
|
||
|
|
1,
|
||
|
|
f"« {cle} » apparaît {src.count(litteral)} fois",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestLesIssuesDuJournalSontToutesMontrees(unittest.TestCase):
|
||
|
|
"""Le diagnostic répond « le cache sert-il ? » : il ne peut rien taire.
|
||
|
|
|
||
|
|
La version d'avant écrivait cinq issues en dur. Deux sont apparues depuis —
|
||
|
|
le tunnel opaque et le miroir git — et l'écran les taisait, alors que le
|
||
|
|
miroir portait à lui seul le tiers des lignes du journal. Une liste fermée
|
||
|
|
dérive dès qu'on ne la relit pas.
|
||
|
|
|
||
|
|
Ce contrôle lit les issues que le CACHE définit, en Go, et exige que
|
||
|
|
l'ordre d'affichage les nomme toutes.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def issues_du_cache(self):
|
||
|
|
src = (RACINE / "script" / "qemu_cache" / "proxy.go").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
bloc = src[
|
||
|
|
src.index("Outcome") : src.index("\n)", src.index("Outcome"))
|
||
|
|
]
|
||
|
|
return set(re.findall(r'Outcome\w+\s*=\s*"([a-z-]+)"', bloc))
|
||
|
|
|
||
|
|
def test_lordre_nomme_toutes_les_issues(self):
|
||
|
|
from script.todo.qemu_cache_menu import ORDRE_ISSUES
|
||
|
|
|
||
|
|
issues = self.issues_du_cache()
|
||
|
|
self.assertGreaterEqual(len(issues), 6, "le relevé n'a rien trouvé")
|
||
|
|
manquantes = sorted(issues - set(ORDRE_ISSUES))
|
||
|
|
self.assertEqual(
|
||
|
|
manquantes,
|
||
|
|
[],
|
||
|
|
"des issues du journal ne seraient pas montrées à leur rang :"
|
||
|
|
f" {manquantes}",
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_le_diagnostic_ne_filtre_pas_sur_cet_ordre(self):
|
||
|
|
"""Une issue inconnue de l'ordre doit quand même paraître : c'est ce
|
||
|
|
qui empêche la liste de retaire quelque chose un jour."""
|
||
|
|
src = (RACINE / "script" / "todo" / "qemu_cache_menu.py").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
bloc = src[src.index("def _cache_diagnostic") :]
|
||
|
|
bloc = bloc[: bloc.index("\n @")]
|
||
|
|
self.assertIn("set(compte) - set(ORDRE_ISSUES)", bloc)
|
||
|
|
|
||
|
|
def test_les_objets_de_statut_suivent_stale(self):
|
||
|
|
"""Une redirection ou un refus gardés se lisent à côté de ce qui a
|
||
|
|
été gardé puis resservi, et non à la fin parmi les inconnues."""
|
||
|
|
from script.todo.qemu_cache_menu import ORDRE_ISSUES
|
||
|
|
|
||
|
|
rang = ORDRE_ISSUES.index("stale")
|
||
|
|
self.assertEqual(
|
||
|
|
ORDRE_ISSUES[rang + 1 : rang + 3],
|
||
|
|
("stored-status", "stale-status"),
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
class TestCeQueChaqueMachineATire(unittest.TestCase):
|
||
|
|
"""Un doute sur l'accélération ne s'instruit pas sur un total.
|
||
|
|
|
||
|
|
Le journal disait ce que le cache avait fait, jamais POUR QUI. On ne
|
||
|
|
pouvait donc pas séparer ce qu'une VM a tiré du réseau de ce qu'une autre a
|
||
|
|
été servie du disque — et répondre demandait d'aller lire le journal à la
|
||
|
|
main, hors de l'outil.
|
||
|
|
"""
|
||
|
|
|
||
|
|
def journal(self, lignes):
|
||
|
|
import json as _json
|
||
|
|
import tempfile
|
||
|
|
|
||
|
|
f = tempfile.NamedTemporaryFile(
|
||
|
|
"w", suffix=".jsonl", delete=False, encoding="utf-8"
|
||
|
|
)
|
||
|
|
for l in lignes:
|
||
|
|
f.write(_json.dumps(l) + "\n")
|
||
|
|
f.close()
|
||
|
|
self.addCleanup(lambda: Path(f.name).unlink(missing_ok=True))
|
||
|
|
return f.name
|
||
|
|
|
||
|
|
def par_machine(self, lignes, **kw):
|
||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
||
|
|
|
||
|
|
chemin = self.journal(lignes)
|
||
|
|
with mock.patch.object(M, "_cache_journal", return_value=chemin):
|
||
|
|
return M._cache_par_machine(**kw)
|
||
|
|
|
||
|
|
def test_le_disque_et_lamont_sont_separes(self):
|
||
|
|
got = self.par_machine(
|
||
|
|
[
|
||
|
|
{"client": "10.0.0.1", "bytes": 100, "upstream": True},
|
||
|
|
{"client": "10.0.0.1", "bytes": 900, "upstream": False},
|
||
|
|
{"client": "10.0.0.2", "bytes": 50, "upstream": True},
|
||
|
|
]
|
||
|
|
)
|
||
|
|
self.assertEqual(
|
||
|
|
dict(got), {"10.0.0.1": (900, 100), "10.0.0.2": (0, 50)}
|
||
|
|
)
|
||
|
|
|
||
|
|
def test_les_plus_gros_dabord(self):
|
||
|
|
"""C'est la machine qui a le plus consommé qu'on cherche."""
|
||
|
|
got = self.par_machine(
|
||
|
|
[
|
||
|
|
{"client": "petit", "bytes": 10, "upstream": False},
|
||
|
|
{"client": "gros", "bytes": 10000, "upstream": False},
|
||
|
|
{"client": "moyen", "bytes": 500, "upstream": True},
|
||
|
|
]
|
||
|
|
)
|
||
|
|
self.assertEqual([a for a, _ in got], ["gros", "moyen", "petit"])
|
||
|
|
|
||
|
|
def test_une_ligne_sans_client_est_ecartee(self):
|
||
|
|
"""Le journal d'avant n'avait pas ce champ : ranger ses lignes sous un
|
||
|
|
nom inventé donnerait un relevé faux, pas un relevé incomplet."""
|
||
|
|
got = self.par_machine(
|
||
|
|
[
|
||
|
|
{"bytes": 5000, "upstream": True},
|
||
|
|
{"client": "10.0.0.1", "bytes": 7, "upstream": False},
|
||
|
|
]
|
||
|
|
)
|
||
|
|
self.assertEqual(dict(got), {"10.0.0.1": (7, 0)})
|
||
|
|
|
||
|
|
def test_la_liste_est_bornee(self):
|
||
|
|
got = self.par_machine(
|
||
|
|
[
|
||
|
|
{"client": f"10.0.0.{i}", "bytes": i, "upstream": False}
|
||
|
|
for i in range(1, 30)
|
||
|
|
],
|
||
|
|
limite=3,
|
||
|
|
)
|
||
|
|
self.assertEqual(len(got), 3)
|
||
|
|
|
||
|
|
def test_un_journal_absent_ne_casse_pas(self):
|
||
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
||
|
|
|
||
|
|
with mock.patch.object(M, "_cache_journal", return_value=""):
|
||
|
|
self.assertEqual(M._cache_par_machine(), [])
|
||
|
|
with mock.patch.object(
|
||
|
|
M, "_cache_journal", return_value="/nexiste/pas.jsonl"
|
||
|
|
):
|
||
|
|
self.assertEqual(M._cache_par_machine(), [])
|
||
|
|
|
||
|
|
def test_le_cache_note_le_client(self):
|
||
|
|
"""L'autre moitié : le service doit écrire le champ que ceci lit."""
|
||
|
|
src = (RACINE / "script" / "qemu_cache" / "proxy.go").read_text(
|
||
|
|
encoding="utf-8"
|
||
|
|
)
|
||
|
|
self.assertIn('Client string `json:"client,omitempty"`', src)
|
||
|
|
self.assertGreaterEqual(
|
||
|
|
src.count("Client: clientDe(") + src.count("Client: client,"),
|
||
|
|
4,
|
||
|
|
"un chemin du journal n'écrit pas le client",
|
||
|
|
)
|
||
|
|
|
||
|
|
|
||
|
|
if __name__ == "__main__":
|
||
|
|
unittest.main()
|