Le formateur de ce dépôt est ruff depuis qu'il remplace black, qui ne connaît aucune cible au-delà de py313 ; ce passage applique sa norme à l'arbre entier, d'un coup, pour qu'aucun commit de fond n'ait à porter du style. L'écart tient presque entièrement aux chaînes coupées à la main que ruff recolle quand elles tiennent sur une ligne, et aux « with » multiples qu'il regroupe : aucune valeur ne change, et les clés de traduction non plus. Vérifié : la suite unitaire reste verte après le passage, et le contrôle de syntaxe ne signale rien. --- EN --- This repository's formatter is ruff since it replaced black, which knows no target beyond py313; this pass applies its standard to the whole tree at once, so that no substantive commit has to carry style. The difference is almost entirely the hand-split strings ruff joins back when they fit on one line, and the multiple "with" it merges: no value changes, nor do the translation keys. Checked: the unit suite stays green after the pass, and the syntax check reports nothing. Assisted-by: Claude Opus 5
332 lines
12 KiB
Python
332 lines
12 KiB
Python
#!/usr/bin/env python3
|
|
# © 2026 TechnoLibre (http://www.technolibre.ca)
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl)
|
|
|
|
"""Soustraire UNE VM au cache : l'exception par adresse MAC.
|
|
|
|
Le détournement est transparent et vaut pour tout le pont. Une VM ne peut pas
|
|
s'y soustraire de l'intérieur, et lui retirer l'autorité ne la dispense de
|
|
rien : elle est interceptée quand même et échoue sur un certificat qu'elle ne
|
|
reconnaît pas. L'exception se pose donc sur l'HÔTE, et elle a besoin d'un
|
|
identifiant que la VM porte de façon stable.
|
|
|
|
D'où ce que ces tests tiennent. L'adresse MAC est choisie AVANT la création :
|
|
la lire après laisserait la fenêtre où cloud-init télécharge déjà. Une MAC
|
|
posée à la main l'emporte sur le tirage. Et l'exception l'emporte sur
|
|
l'autorité, qu'il serait absurde de faire approuver à une VM qui ne
|
|
rencontrera jamais le cache.
|
|
"""
|
|
|
|
import argparse
|
|
import sys
|
|
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 deploy_qemu # noqa: E402
|
|
|
|
|
|
class FauxRunner:
|
|
"""Retient les commandes. Rend ce qu'on lui a dit de rendre."""
|
|
|
|
def __init__(self, reponses=None):
|
|
self.commandes = []
|
|
self.reponses = reponses or {}
|
|
self.use_sudo = True
|
|
self.dry_run = False
|
|
|
|
def run(self, cmd, *, privileged=False, check=True, capture=False):
|
|
self.commandes.append(list(cmd))
|
|
for motif, reponse in self.reponses.items():
|
|
if motif in " ".join(cmd):
|
|
return reponse
|
|
return (0, "") if capture else None
|
|
|
|
|
|
def args_neufs(**kw):
|
|
base = dict(
|
|
name="vm-essai",
|
|
network="network=default,model=virtio",
|
|
cache_bypass=True,
|
|
cache_ca="",
|
|
distro="arch",
|
|
dry_run=False,
|
|
)
|
|
base.update(kw)
|
|
return argparse.Namespace(**base)
|
|
|
|
|
|
class TestLaMACChoisie(unittest.TestCase):
|
|
def test_une_mac_deja_demandee_lemporte(self):
|
|
"""Une MAC posée à la main sert une réservation DHCP : la remplacer
|
|
casserait ce que l'appelant a réglé ailleurs."""
|
|
reseau = "network=default,model=virtio,mac=52:54:00:de:ad:be"
|
|
self.assertEqual(
|
|
deploy_qemu.mac_du_network(reseau), "52:54:00:de:ad:be"
|
|
)
|
|
self.assertEqual(
|
|
deploy_qemu.network_avec_mac(reseau, "52:54:00:11:11:11"), reseau
|
|
)
|
|
|
|
def test_sans_mac_elle_est_ajoutee(self):
|
|
reseau = deploy_qemu.network_avec_mac(
|
|
"network=default,model=virtio", "52:54:00:11:22:33"
|
|
)
|
|
self.assertEqual(
|
|
reseau, "network=default,model=virtio,mac=52:54:00:11:22:33"
|
|
)
|
|
self.assertEqual(
|
|
deploy_qemu.mac_du_network(reseau), "52:54:00:11:22:33"
|
|
)
|
|
|
|
def test_la_mac_tiree_porte_le_prefixe_qemu(self):
|
|
mac = deploy_qemu.mac_neuve(set())
|
|
self.assertTrue(
|
|
mac.startswith(deploy_qemu.MAC_PREFIXE),
|
|
f"{mac} n'est pas une adresse QEMU",
|
|
)
|
|
self.assertEqual(len(mac.split(":")), 6)
|
|
|
|
def test_une_mac_prise_nest_pas_retiree(self):
|
|
"""Libvirt refuse un doublon, mais à la création — après le
|
|
téléchargement de l'image. Le refus doit venir avant."""
|
|
prises = set()
|
|
for _ in range(30):
|
|
mac = deploy_qemu.mac_neuve(prises)
|
|
self.assertNotIn(mac, prises)
|
|
prises.add(mac)
|
|
|
|
|
|
class TestLExceptionEstPoseeAvant(unittest.TestCase):
|
|
def test_la_mac_entre_dans_le_reseau_avant_la_creation(self):
|
|
args = args_neufs()
|
|
runner = FauxRunner({"is-active": (0, "")})
|
|
with (
|
|
mock.patch.object(
|
|
deploy_qemu.os.path, "isfile", return_value=True
|
|
),
|
|
mock.patch.object(
|
|
deploy_qemu.shutil, "which", return_value="/usr/bin/nft"
|
|
),
|
|
mock.patch.object(
|
|
deploy_qemu, "macs_deja_prises", return_value=set()
|
|
),
|
|
):
|
|
mac = deploy_qemu.cache_bypass_apply(args, runner)
|
|
self.assertTrue(mac, "aucune MAC retenue")
|
|
self.assertIn(f"mac={mac}", args.network)
|
|
pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)]
|
|
self.assertTrue(pose, "l'exception n'a pas été posée")
|
|
self.assertIn("nft -f -", " ".join(pose[0]))
|
|
|
|
def test_le_message_du_binaire_suit_la_langue(self):
|
|
"""La commande passe par sudo, qui retire EL_LANG : sans l'option, le
|
|
binaire répondrait en français à qui déploie en anglais."""
|
|
args = args_neufs()
|
|
args.lang = "en"
|
|
runner = FauxRunner({"is-active": (0, "")})
|
|
with (
|
|
mock.patch.object(
|
|
deploy_qemu.os.path, "isfile", return_value=True
|
|
),
|
|
mock.patch.object(
|
|
deploy_qemu.shutil, "which", return_value="/usr/bin/nft"
|
|
),
|
|
mock.patch.object(
|
|
deploy_qemu, "macs_deja_prises", return_value=set()
|
|
),
|
|
):
|
|
deploy_qemu.cache_bypass_apply(args, runner)
|
|
pose = [c for c in runner.commandes if "--bypass-add" in " ".join(c)]
|
|
self.assertIn("--lang en", " ".join(pose[0]))
|
|
|
|
def test_sans_cache_installe_rien_nest_pose(self):
|
|
"""Exiger un cache pour pouvoir s'en passer n'aurait aucun sens :
|
|
sans lui, rien n'intercepte."""
|
|
args = args_neufs()
|
|
runner = FauxRunner()
|
|
with mock.patch.object(
|
|
deploy_qemu.os.path, "isfile", return_value=False
|
|
):
|
|
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
|
|
self.assertEqual(runner.commandes, [])
|
|
self.assertNotIn("mac=", args.network)
|
|
|
|
def test_service_arrete_rien_nest_pose(self):
|
|
args = args_neufs()
|
|
runner = FauxRunner({"is-active": (3, "")})
|
|
with mock.patch.object(
|
|
deploy_qemu.os.path, "isfile", return_value=True
|
|
):
|
|
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
|
|
self.assertNotIn("mac=", args.network)
|
|
|
|
def test_sans_la_demande_rien_ne_bouge(self):
|
|
args = args_neufs(cache_bypass=False)
|
|
runner = FauxRunner()
|
|
self.assertEqual(deploy_qemu.cache_bypass_apply(args, runner), "")
|
|
self.assertEqual(runner.commandes, [])
|
|
|
|
def test_sans_nft_le_service_est_repose(self):
|
|
"""iptables n'a pas d'ensemble nommé : l'exception n'entre qu'en
|
|
reposant la chaîne, et seul un redémarrage la repose."""
|
|
args = args_neufs()
|
|
runner = FauxRunner({"is-active": (0, "")})
|
|
with (
|
|
mock.patch.object(
|
|
deploy_qemu.os.path, "isfile", return_value=True
|
|
),
|
|
mock.patch.object(deploy_qemu.shutil, "which", return_value=None),
|
|
mock.patch.object(
|
|
deploy_qemu, "macs_deja_prises", return_value=set()
|
|
),
|
|
):
|
|
deploy_qemu.cache_bypass_apply(args, runner)
|
|
dit = [" ".join(c) for c in runner.commandes]
|
|
self.assertTrue(
|
|
any("systemctl" in c and "restart" in c for c in dit),
|
|
f"le service n'est pas reposé : {dit}",
|
|
)
|
|
|
|
|
|
class TestLExceptionLemporteSurLAutorite(unittest.TestCase):
|
|
def test_pas_dautorite_dans_une_vm_exceptee(self):
|
|
"""Elle ne rencontrera jamais le cache : poser cette signature dans
|
|
son magasin n'aurait aucun objet."""
|
|
args = args_neufs(cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt")
|
|
self.assertEqual(deploy_qemu.cache_files(args), [])
|
|
|
|
def test_la_ligne_de_commande_ne_porte_pas_les_deux(self):
|
|
from script.todo.qemu_deploy import QemuDeployMixin
|
|
|
|
parts = QemuDeployMixin._qemu_build_deploy_parts(
|
|
mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"),
|
|
"arch",
|
|
"latest",
|
|
"x86_64",
|
|
"vm",
|
|
4096,
|
|
2,
|
|
"20G",
|
|
"",
|
|
"master",
|
|
True,
|
|
cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt",
|
|
cache_bypass=True,
|
|
)
|
|
self.assertIn("--cache-bypass", parts)
|
|
self.assertNotIn("--cache-ca", parts)
|
|
|
|
def test_sans_exception_lautorite_passe(self):
|
|
"""Le cas courant : la VM approuve l'autorité et traverse le cache."""
|
|
from script.todo.qemu_deploy import QemuDeployMixin
|
|
|
|
parts = QemuDeployMixin._qemu_build_deploy_parts(
|
|
mock.MagicMock(_qemu_script_path=lambda: "deploy_qemu.py"),
|
|
"arch",
|
|
"latest",
|
|
"x86_64",
|
|
"vm",
|
|
4096,
|
|
2,
|
|
"20G",
|
|
"",
|
|
"master",
|
|
True,
|
|
cache_ca="/var/lib/erplibre_go_qemu_cache/ca.crt",
|
|
)
|
|
self.assertIn("--cache-ca", parts)
|
|
self.assertNotIn("--cache-bypass", parts)
|
|
|
|
|
|
class TestLeMenageDesExceptions(unittest.TestCase):
|
|
"""Une exception qui survit à sa VM est le danger de cette liste.
|
|
|
|
L'adresse MAC est libérée par la destruction et se réattribue : l'entrée
|
|
restée derrière soustrairait au cache une machine neuve que personne n'a
|
|
exceptée. Ni la VM ni le cache ne le diraient — la VM télécharge
|
|
normalement, le journal du cache reste seulement muet à son sujet.
|
|
"""
|
|
|
|
def test_une_vm_disparue_rend_son_entree_orpheline(self):
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
|
|
|
entrees = [
|
|
("52:54:00:00:00:01", "vm-vivante"),
|
|
("52:54:00:00:00:02", "vm-detruite"),
|
|
]
|
|
with mock.patch.object(
|
|
M, "_cache_domaines", return_value={"vm-vivante"}
|
|
):
|
|
orphelines = M._cache_bypass_orphelines(entrees)
|
|
self.assertEqual(orphelines, [("52:54:00:00:00:02", "vm-detruite")])
|
|
|
|
def test_une_entree_sans_nom_nest_jamais_orpheline(self):
|
|
"""Posée à la main : rien ne dit à quelle VM elle se rapporte, et la
|
|
retirer d'office déferait le travail de quelqu'un."""
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
|
|
|
with mock.patch.object(M, "_cache_domaines", return_value=set()):
|
|
self.assertEqual(
|
|
M._cache_bypass_orphelines([("52:54:00:00:00:03", "")]), []
|
|
)
|
|
|
|
def test_le_menage_retire_du_fichier_et_du_noyau(self):
|
|
"""Les deux : le fichier est ce que le service reposera, l'ensemble
|
|
est ce qui s'applique en ce moment."""
|
|
from script.todo import qemu_cache_menu as menu
|
|
|
|
execute = mock.MagicMock()
|
|
with (
|
|
mock.patch.object(menu.os.path, "isfile", return_value=True),
|
|
mock.patch.object(
|
|
menu.QemuCacheMenuMixin,
|
|
"_cache_bypass_orphelines",
|
|
return_value=[("52:54:00:00:00:04", "partie")],
|
|
),
|
|
):
|
|
self.assertEqual(menu.bypass_menage(execute), 1)
|
|
cmd = execute.exec_command_live.call_args.args[0]
|
|
self.assertIn("--bypass-del 52:54:00:00:00:04", cmd)
|
|
self.assertIn("nft -f -", cmd)
|
|
|
|
def test_sans_cache_pose_le_menage_ne_fait_rien(self):
|
|
from script.todo import qemu_cache_menu as menu
|
|
|
|
execute = mock.MagicMock()
|
|
with mock.patch.object(menu.os.path, "isfile", return_value=False):
|
|
self.assertEqual(menu.bypass_menage(execute), 0)
|
|
execute.exec_command_live.assert_not_called()
|
|
|
|
def test_la_liste_est_lue_du_binaire(self):
|
|
"""Lui seul normalise une adresse et saute une ligne fautive : une
|
|
seconde lecture écrite ici dériverait de la sienne."""
|
|
from script.todo.qemu_cache_menu import QemuCacheMenuMixin as M
|
|
|
|
sortie = "52:54:00:aa:bb:cc vm-une\n52:54:00:11:22:33\n\n"
|
|
with (
|
|
mock.patch.object(M, "_cache_lire", return_value=sortie),
|
|
mock.patch("os.path.isfile", return_value=True),
|
|
):
|
|
self.assertEqual(
|
|
M._cache_bypass_lire(),
|
|
[("52:54:00:aa:bb:cc", "vm-une"), ("52:54:00:11:22:33", "")],
|
|
)
|
|
|
|
|
|
class TestLaSuppressionFaitLeMenage(unittest.TestCase):
|
|
def test_la_suppression_de_vm_appelle_le_menage(self):
|
|
"""Le seul endroit qui SAIT que la VM vient de disparaître."""
|
|
src = (RACINE / "script" / "todo" / "qemu_manage.py").read_text(
|
|
encoding="utf-8"
|
|
)
|
|
bloc = src[src.index("def _qemu_delete_vm(") :]
|
|
bloc = bloc[: bloc.index("\n def ")]
|
|
self.assertIn("bypass_menage(self.execute)", bloc)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|